Release 0.2.22: capital chart, Investor View, GP stakes, doc folders
Snapshot commit bringing the uncommitted phase-2 work into version control
together with four new features and the 0.2.22 version bump.
New features:
- Investor capital-over-time chart (value, paid-in, distributions per
quarter), rendered from existing capital-account history.
- Admin Investor View: read-only reconstruction of an investor's portal
(GET /api/users/{id}/investor-view), reusing the investor portal UI.
- Document upload scoped to the selected fund's own investors, with an
explicit upload-target confirmation to prevent mis-attaching.
- GP/mgmt entities gain an Assets tab listing their stakes in the funds
they manage (new entity_stakes table + /api/entities/{id}/stakes).
- Edit-entity form (change type/status/etc.), so GP entities can be
categorized correctly.
Verified: 11/11 backend tests pass; alembic upgrades to head b8c9d0e1f2a3;
frontend tsc + vite build clean; s9pk packs at 0.2.22:0 (x86_64).
Also: ignore .DS_Store and *.s9pk artifacts.
This commit is contained in:
+310
-53
@@ -2,6 +2,29 @@ import { sdk } from '../sdk'
|
||||
|
||||
const { InputSpec, Value, Action } = sdk
|
||||
|
||||
// Run a ten31portal CLI subcommand inside the service container against the data volume.
|
||||
async function runCli(effects: any, args: string[], taskName: string) {
|
||||
const sub = await sdk.SubContainer.of(
|
||||
effects,
|
||||
{ imageId: 'main' },
|
||||
sdk.Mounts.of().mountVolume({
|
||||
volumeId: 'main',
|
||||
subpath: null,
|
||||
mountpoint: '/data',
|
||||
readonly: false,
|
||||
}),
|
||||
taskName,
|
||||
)
|
||||
return sub.exec(
|
||||
['python3', '-m', 'ten31portal.cli', ...args],
|
||||
{ env: { TEN31_DB_PATH: '/data/portal.db' } },
|
||||
30000,
|
||||
)
|
||||
}
|
||||
|
||||
const errorResult = (message: string) =>
|
||||
({ version: '1' as const, title: 'Error', message, result: null })
|
||||
|
||||
// ============================================
|
||||
// Action: Create User
|
||||
// ============================================
|
||||
@@ -26,6 +49,13 @@ const createUserInputSpec = InputSpec.of({
|
||||
},
|
||||
],
|
||||
}),
|
||||
username: Value.text({
|
||||
name: 'Username',
|
||||
description: 'Login username',
|
||||
default: '',
|
||||
required: true,
|
||||
placeholder: 'jsmith',
|
||||
}),
|
||||
password: Value.text({
|
||||
name: 'Password',
|
||||
description: 'Initial password (user should change after first login)',
|
||||
@@ -36,13 +66,12 @@ const createUserInputSpec = InputSpec.of({
|
||||
role: Value.select({
|
||||
name: 'Role',
|
||||
description:
|
||||
'approver: full access including sign-off. cfo: read + edit + submit. fund_admin: read + edit + submit. viewer: read only.',
|
||||
default: 'viewer',
|
||||
'Managing Partner: full access including valuation sign-off. Operations: full access except final sign-off. Fund Admin: edit holdings/NAV and submit, no user or document admin.',
|
||||
default: 'operations',
|
||||
values: {
|
||||
approver: 'Approver',
|
||||
cfo: 'CFO',
|
||||
approver: 'Managing Partner',
|
||||
operations: 'Operations',
|
||||
fund_admin: 'Fund Admin',
|
||||
viewer: 'Viewer',
|
||||
},
|
||||
}),
|
||||
})
|
||||
@@ -51,7 +80,7 @@ const createUserAction = Action.withInput(
|
||||
'create-user',
|
||||
{
|
||||
name: 'Create User',
|
||||
description: 'Add a new user account with a role',
|
||||
description: 'Add a new staff user account with a role',
|
||||
warning: null,
|
||||
allowedStatuses: 'only-running',
|
||||
group: null,
|
||||
@@ -61,69 +90,297 @@ const createUserAction = Action.withInput(
|
||||
async () => ({
|
||||
name: '',
|
||||
email: '',
|
||||
username: '',
|
||||
password: '',
|
||||
role: 'viewer' as const,
|
||||
role: 'operations' as const,
|
||||
}),
|
||||
async ({ input, effects }) => {
|
||||
try {
|
||||
const sub = await sdk.SubContainer.of(
|
||||
const result = await runCli(
|
||||
effects,
|
||||
{ imageId: 'main' },
|
||||
sdk.Mounts.of().mountVolume({
|
||||
volumeId: 'main',
|
||||
subpath: null,
|
||||
mountpoint: '/data',
|
||||
readonly: false,
|
||||
}),
|
||||
[
|
||||
'create-user',
|
||||
'--name', input.name,
|
||||
'--username', input.username,
|
||||
'--email', input.email,
|
||||
'--role', input.role,
|
||||
'--password', input.password,
|
||||
],
|
||||
'create-user-task',
|
||||
)
|
||||
|
||||
const result = await sub.exec(
|
||||
[
|
||||
'python3',
|
||||
'-m',
|
||||
'ten31portal.cli',
|
||||
'create-user',
|
||||
'--name',
|
||||
input.name,
|
||||
'--email',
|
||||
input.email,
|
||||
'--role',
|
||||
input.role,
|
||||
'--password',
|
||||
input.password,
|
||||
],
|
||||
{
|
||||
env: { TEN31_DB_PATH: '/data/portal.db' },
|
||||
},
|
||||
30000,
|
||||
)
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
const stderr = result.stderr?.toString() || 'Unknown error'
|
||||
return {
|
||||
version: '1' as const,
|
||||
title: 'Error',
|
||||
message: `Failed to create user: ${stderr}`,
|
||||
result: null,
|
||||
}
|
||||
return errorResult(`Failed to create user: ${result.stderr?.toString() || 'Unknown error'}`)
|
||||
}
|
||||
|
||||
return {
|
||||
version: '1' as const,
|
||||
title: 'User Created',
|
||||
message: `Created user ${input.name} (${input.email}) with role: ${input.role}`,
|
||||
message: `Created ${input.name} (${input.username}) with role: ${input.role}`,
|
||||
result: null,
|
||||
}
|
||||
} catch (e: any) {
|
||||
return {
|
||||
version: '1' as const,
|
||||
title: 'Error',
|
||||
message: `Failed to create user: ${e.message || e}`,
|
||||
result: null,
|
||||
}
|
||||
return errorResult(`Failed to create user: ${e.message || e}`)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const actions = sdk.Actions.of().addAction(createUserAction)
|
||||
// ============================================
|
||||
// Action: Reset Password
|
||||
// ============================================
|
||||
const resetPasswordInputSpec = InputSpec.of({
|
||||
username: Value.text({
|
||||
name: 'Username',
|
||||
description: 'Username of the account to reset (e.g. the Admin / Service Admin)',
|
||||
default: '',
|
||||
required: true,
|
||||
placeholder: 'admin',
|
||||
}),
|
||||
password: Value.text({
|
||||
name: 'New Password',
|
||||
description: 'The new password for this account',
|
||||
default: '',
|
||||
required: true,
|
||||
placeholder: 'minimum 4 characters',
|
||||
}),
|
||||
})
|
||||
|
||||
const resetPasswordAction = Action.withInput(
|
||||
'reset-password',
|
||||
{
|
||||
name: 'Reset Password',
|
||||
description: "Reset any user's password (including the Admin / Service Admin)",
|
||||
warning: null,
|
||||
allowedStatuses: 'only-running',
|
||||
group: null,
|
||||
visibility: 'enabled',
|
||||
},
|
||||
resetPasswordInputSpec,
|
||||
async () => ({ username: '', password: '' }),
|
||||
async ({ input, effects }) => {
|
||||
try {
|
||||
const result = await runCli(
|
||||
effects,
|
||||
['reset-password', '--username', input.username, '--password', input.password],
|
||||
'reset-password-task',
|
||||
)
|
||||
if (result.exitCode !== 0) {
|
||||
return errorResult(result.stderr?.toString() || 'Failed to reset password')
|
||||
}
|
||||
return {
|
||||
version: '1' as const,
|
||||
title: 'Password Reset',
|
||||
message: `Password reset for ${input.username}. Their login is enabled.`,
|
||||
result: null,
|
||||
}
|
||||
} catch (e: any) {
|
||||
return errorResult(`Failed to reset password: ${e.message || e}`)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// Action: List Users
|
||||
// ============================================
|
||||
const listUsersAction = Action.withoutInput(
|
||||
'list-users',
|
||||
{
|
||||
name: 'List Users',
|
||||
description: 'Show every user account and role',
|
||||
warning: null,
|
||||
allowedStatuses: 'only-running',
|
||||
group: null,
|
||||
visibility: 'enabled',
|
||||
},
|
||||
async ({ effects }) => {
|
||||
try {
|
||||
const result = await runCli(effects, ['list-users'], 'list-users-task')
|
||||
if (result.exitCode !== 0) {
|
||||
return errorResult(result.stderr?.toString() || 'Failed to list users')
|
||||
}
|
||||
return {
|
||||
version: '1' as const,
|
||||
title: 'Users',
|
||||
message: 'The Service Admin cannot be deleted.',
|
||||
result: {
|
||||
type: 'single' as const,
|
||||
value: result.stdout?.toString() || 'No users.',
|
||||
copyable: true,
|
||||
qr: false,
|
||||
masked: false,
|
||||
},
|
||||
}
|
||||
} catch (e: any) {
|
||||
return errorResult(`Failed to list users: ${e.message || e}`)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// Action: Delete User
|
||||
// ============================================
|
||||
const deleteUserInputSpec = InputSpec.of({
|
||||
username: Value.text({
|
||||
name: 'Username',
|
||||
description: 'Username of the account to delete (use List Users to see them)',
|
||||
default: '',
|
||||
required: true,
|
||||
placeholder: 'jsmith',
|
||||
}),
|
||||
})
|
||||
|
||||
const deleteUserAction = Action.withInput(
|
||||
'delete-user',
|
||||
{
|
||||
name: 'Delete User',
|
||||
description: 'Permanently delete a user account',
|
||||
warning:
|
||||
'This permanently deletes the user and their data (access grants, capital statements, private documents). The Service Admin cannot be deleted.',
|
||||
allowedStatuses: 'only-running',
|
||||
group: null,
|
||||
visibility: 'enabled',
|
||||
},
|
||||
deleteUserInputSpec,
|
||||
async () => ({ username: '' }),
|
||||
async ({ input, effects }) => {
|
||||
try {
|
||||
const result = await runCli(
|
||||
effects,
|
||||
['delete-user', '--username', input.username],
|
||||
'delete-user-task',
|
||||
)
|
||||
if (result.exitCode !== 0) {
|
||||
return errorResult(result.stderr?.toString() || 'Failed to delete user')
|
||||
}
|
||||
return {
|
||||
version: '1' as const,
|
||||
title: 'User Deleted',
|
||||
message: result.stdout?.toString() || `Deleted ${input.username}.`,
|
||||
result: null,
|
||||
}
|
||||
} catch (e: any) {
|
||||
return errorResult(`Failed to delete user: ${e.message || e}`)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// Action: Fix Duplicate Holdings
|
||||
// ============================================
|
||||
const dedupeAction = Action.withoutInput(
|
||||
'dedupe-holdings',
|
||||
{
|
||||
name: 'Fix Duplicate Holdings',
|
||||
description:
|
||||
'Remove duplicate holdings/positions left by repeated imports on older versions, which inflated the Invested totals on the Entities view. Safe to run anytime.',
|
||||
warning: null,
|
||||
allowedStatuses: 'only-running',
|
||||
group: null,
|
||||
visibility: 'enabled',
|
||||
},
|
||||
async ({ effects }) => {
|
||||
try {
|
||||
const result = await runCli(effects, ['dedupe-holdings'], 'dedupe-holdings-task')
|
||||
if (result.exitCode !== 0) {
|
||||
return errorResult(result.stderr?.toString() || 'Failed to clean up duplicates')
|
||||
}
|
||||
return {
|
||||
version: '1' as const,
|
||||
title: 'Duplicates Cleaned Up',
|
||||
message: result.stdout?.toString() || 'Done.',
|
||||
result: null,
|
||||
}
|
||||
} catch (e: any) {
|
||||
return errorResult(`Failed to clean up duplicates: ${e.message || e}`)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// Action: List Funds
|
||||
// ============================================
|
||||
const listFundsAction = Action.withoutInput(
|
||||
'list-funds',
|
||||
{
|
||||
name: 'List Funds',
|
||||
description: 'Show every fund/SPV and its exact name (for Reset Fund Holdings)',
|
||||
warning: null,
|
||||
allowedStatuses: 'only-running',
|
||||
group: null,
|
||||
visibility: 'enabled',
|
||||
},
|
||||
async ({ effects }) => {
|
||||
try {
|
||||
const result = await runCli(effects, ['list-funds'], 'list-funds-task')
|
||||
if (result.exitCode !== 0) {
|
||||
return errorResult(result.stderr?.toString() || 'Failed to list funds')
|
||||
}
|
||||
return {
|
||||
version: '1' as const,
|
||||
title: 'Funds',
|
||||
message: null,
|
||||
result: {
|
||||
type: 'single' as const,
|
||||
value: result.stdout?.toString() || 'No funds.',
|
||||
copyable: true,
|
||||
qr: false,
|
||||
masked: false,
|
||||
},
|
||||
}
|
||||
} catch (e: any) {
|
||||
return errorResult(`Failed to list funds: ${e.message || e}`)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// Action: Reset Fund Holdings
|
||||
// ============================================
|
||||
const resetHoldingsInputSpec = InputSpec.of({
|
||||
name: Value.text({
|
||||
name: 'Fund Name',
|
||||
description: 'Exact name of the fund to clear (see List Funds)',
|
||||
default: '',
|
||||
required: true,
|
||||
placeholder: 'Low Time Preference Fund III, LP',
|
||||
}),
|
||||
})
|
||||
|
||||
const resetHoldingsAction = Action.withInput(
|
||||
'reset-holdings',
|
||||
{
|
||||
name: 'Reset Fund Holdings',
|
||||
description:
|
||||
'Clear a fund\'s holdings, positions, and valuation rounds so it can be re-imported from scratch. Use after switching source workbooks (e.g. Carta → eNAV) renamed the positions. Investor capital accounts are NOT affected.',
|
||||
warning:
|
||||
'This permanently deletes the fund\'s holdings, positions, and valuation history. Re-import the fund\'s NAV afterward to repopulate it. Investor capital accounts are kept.',
|
||||
allowedStatuses: 'only-running',
|
||||
group: null,
|
||||
visibility: 'enabled',
|
||||
},
|
||||
resetHoldingsInputSpec,
|
||||
async () => ({ name: '' }),
|
||||
async ({ input, effects }) => {
|
||||
try {
|
||||
const result = await runCli(effects, ['reset-holdings', '--name', input.name], 'reset-holdings-task')
|
||||
if (result.exitCode !== 0) {
|
||||
return errorResult(result.stderr?.toString() || 'Failed to reset fund')
|
||||
}
|
||||
return {
|
||||
version: '1' as const,
|
||||
title: 'Fund Cleared',
|
||||
message: result.stdout?.toString() || `Cleared ${input.name}.`,
|
||||
result: null,
|
||||
}
|
||||
} catch (e: any) {
|
||||
return errorResult(`Failed to reset fund: ${e.message || e}`)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const actions = sdk.Actions.of()
|
||||
.addAction(createUserAction)
|
||||
.addAction(resetPasswordAction)
|
||||
.addAction(listUsersAction)
|
||||
.addAction(deleteUserAction)
|
||||
.addAction(dedupeAction)
|
||||
.addAction(listFundsAction)
|
||||
.addAction(resetHoldingsAction)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { v_0_2_21 as current } from './v_0_2_21'
|
||||
export { v_0_2_22 as current } from './v_0_2_22'
|
||||
import { v_0_1_0 } from './v_0_1_0'
|
||||
import { v_0_2_0 } from './v_0_2_0'
|
||||
import { v_0_2_1 } from './v_0_2_1'
|
||||
@@ -20,4 +20,5 @@ import { v_0_2_17 } from './v_0_2_17'
|
||||
import { v_0_2_18 } from './v_0_2_18'
|
||||
import { v_0_2_19 } from './v_0_2_19'
|
||||
import { v_0_2_20 } from './v_0_2_20'
|
||||
export const other = [v_0_1_0, v_0_2_0, v_0_2_1, v_0_2_3, v_0_2_4, v_0_2_5, v_0_2_6, v_0_2_7, v_0_2_8, v_0_2_9, v_0_2_10, v_0_2_11, v_0_2_12, v_0_2_13, v_0_2_14, v_0_2_15, v_0_2_16, v_0_2_17, v_0_2_18, v_0_2_19, v_0_2_20]
|
||||
import { v_0_2_21 } from './v_0_2_21'
|
||||
export const other = [v_0_1_0, v_0_2_0, v_0_2_1, v_0_2_3, v_0_2_4, v_0_2_5, v_0_2_6, v_0_2_7, v_0_2_8, v_0_2_9, v_0_2_10, v_0_2_11, v_0_2_12, v_0_2_13, v_0_2_14, v_0_2_15, v_0_2_16, v_0_2_17, v_0_2_18, v_0_2_19, v_0_2_20, v_0_2_21]
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_22 = VersionInfo.of({
|
||||
version: '0.2.22:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
'Investors see a graph of their capital over time (value, paid-in, and distributions per quarter). A new admin Investor View shows exactly what an investor sees, read-only. Document uploads are scoped to the chosen fund\'s own investors with a clear target, so a file cannot go to the wrong person. GP and management-company entities get an Assets tab that lists their interests in the funds they manage. You can now edit an existing entity, including its type.',
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -23,6 +23,7 @@ export const main = sdk.setupMain(async ({ effects }) => {
|
||||
command: ['sh', '-c', '/app/start.sh'],
|
||||
env: {
|
||||
TEN31_DB_PATH: '/data/portal.db',
|
||||
TEN31_DOCS_DIR: '/data/documents',
|
||||
TEN31_SESSION_SECRET: process.env.TEN31_SESSION_SECRET || 'change-me',
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user