From f0f8fd15c668db5f91dc63ee6044ecb36e6075c1 Mon Sep 17 00:00:00 2001 From: Jonathan Kirkwood Date: Wed, 1 Jul 2026 14:25:50 -0500 Subject: [PATCH] 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. --- .claude/launch.json | 12 + .gitignore | 13 + README.md | 25 + .../versions/a1b2c3d4e5f6_investor_access.py | 101 +++ .../b2c3d4e5f6a7_external_investor_id.py | 30 + .../versions/b8c9d0e1f2a3_entity_stakes.py | 41 ++ .../versions/c3d4e5f6a7b8_login_enabled.py | 29 + .../versions/d4e5f6a7b8c9_commitment_cents.py | 29 + .../e5f6a7b8c9d0_primary_account_id.py | 33 + .../versions/f6a7b8c9d0e1_service_admin.py | 34 + backend/ten31portal/auth.py | 53 +- backend/ten31portal/main.py | 36 +- backend/ten31portal/models.py | 20 + backend/ten31portal/routers/auth_router.py | 34 +- .../routers/capital_account_router.py | 129 ++++ .../routers/capital_import_router.py | 348 +++++++++ backend/ten31portal/routers/entity_router.py | 178 ++++- backend/ten31portal/routers/holding_router.py | 4 +- backend/ten31portal/routers/import_router.py | 676 +++++++++--------- .../ten31portal/routers/position_router.py | 4 +- backend/ten31portal/routers/round_router.py | 6 +- backend/ten31portal/routers/user_router.py | 375 ++++++++++ backend/ten31portal/schemas.py | 217 +++++- backend/tests/test_investor_view.py | 40 ++ backend/tests/test_stakes.py | 41 ++ deploy/Dockerfile | 8 +- deploy/LICENSE | 21 + deploy/assets/.gitkeep | 0 deploy/icon.png | Bin 856 -> 23833 bytes deploy/instructions.md | 39 + deploy/package-lock.json | 4 +- deploy/package.json | 2 +- deploy/startos/actions/index.ts | 363 ++++++++-- deploy/startos/install/versions/index.ts | 5 +- deploy/startos/install/versions/v_0_2_22.ts | 13 + deploy/startos/main.ts | 1 + frontend/index.html | 11 +- frontend/package-lock.json | 551 +++++++------- frontend/public/favicon.svg | 1 - frontend/public/icon-192.png | Bin 0 -> 15170 bytes frontend/public/icon-512.png | Bin 0 -> 70382 bytes frontend/public/manifest.webmanifest | 16 + frontend/public/sw.js | 56 ++ frontend/public/ten31-logo.png | Bin 0 -> 11422 bytes frontend/src/App.tsx | 4 + frontend/src/api.ts | 31 + frontend/src/components/CapitalChart.tsx | 89 +++ .../src/components/ChangePasswordModal.tsx | 74 ++ frontend/src/components/EntityHeader.tsx | 59 ++ frontend/src/components/Layout.tsx | 90 ++- frontend/src/components/PasswordInput.tsx | 51 ++ frontend/src/format.ts | 33 + frontend/src/main.tsx | 6 + frontend/src/pages/AccessGrid.tsx | 136 ++++ frontend/src/pages/CapitalAccounts.tsx | 222 ++++++ frontend/src/pages/Documents.tsx | 265 +++++++ frontend/src/pages/EntityAssets.tsx | 237 ++++++ frontend/src/pages/EntityDocuments.tsx | 91 +++ frontend/src/pages/EntityOverview.tsx | 156 +++- frontend/src/pages/EntityPartners.tsx | 109 +++ frontend/src/pages/InvestorView.tsx | 80 +++ frontend/src/pages/Login.tsx | 27 +- frontend/src/pages/Users.tsx | 432 +++++++++++ frontend/src/portal/FundAdminHome.tsx | 180 +++++ frontend/src/portal/InvestorHome.tsx | 35 + frontend/src/portal/InvestorPortalView.tsx | 188 +++++ frontend/src/portal/PortalLayout.tsx | 34 + frontend/src/version.ts | 4 + ten31-favicon-180.png | Bin 0 -> 11422 bytes 69 files changed, 5492 insertions(+), 740 deletions(-) create mode 100644 .claude/launch.json create mode 100644 backend/alembic/versions/a1b2c3d4e5f6_investor_access.py create mode 100644 backend/alembic/versions/b2c3d4e5f6a7_external_investor_id.py create mode 100644 backend/alembic/versions/b8c9d0e1f2a3_entity_stakes.py create mode 100644 backend/alembic/versions/c3d4e5f6a7b8_login_enabled.py create mode 100644 backend/alembic/versions/d4e5f6a7b8c9_commitment_cents.py create mode 100644 backend/alembic/versions/e5f6a7b8c9d0_primary_account_id.py create mode 100644 backend/alembic/versions/f6a7b8c9d0e1_service_admin.py create mode 100644 backend/ten31portal/routers/capital_account_router.py create mode 100644 backend/ten31portal/routers/capital_import_router.py create mode 100644 backend/ten31portal/routers/user_router.py create mode 100644 backend/tests/test_investor_view.py create mode 100644 backend/tests/test_stakes.py create mode 100644 deploy/LICENSE create mode 100644 deploy/assets/.gitkeep create mode 100644 deploy/instructions.md create mode 100644 deploy/startos/install/versions/v_0_2_22.ts delete mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icon-192.png create mode 100644 frontend/public/icon-512.png create mode 100644 frontend/public/manifest.webmanifest create mode 100644 frontend/public/sw.js create mode 100644 frontend/public/ten31-logo.png create mode 100644 frontend/src/components/CapitalChart.tsx create mode 100644 frontend/src/components/ChangePasswordModal.tsx create mode 100644 frontend/src/components/EntityHeader.tsx create mode 100644 frontend/src/components/PasswordInput.tsx create mode 100644 frontend/src/pages/AccessGrid.tsx create mode 100644 frontend/src/pages/CapitalAccounts.tsx create mode 100644 frontend/src/pages/Documents.tsx create mode 100644 frontend/src/pages/EntityAssets.tsx create mode 100644 frontend/src/pages/EntityDocuments.tsx create mode 100644 frontend/src/pages/EntityPartners.tsx create mode 100644 frontend/src/pages/InvestorView.tsx create mode 100644 frontend/src/pages/Users.tsx create mode 100644 frontend/src/portal/FundAdminHome.tsx create mode 100644 frontend/src/portal/InvestorHome.tsx create mode 100644 frontend/src/portal/InvestorPortalView.tsx create mode 100644 frontend/src/portal/PortalLayout.tsx create mode 100644 frontend/src/version.ts create mode 100644 ten31-favicon-180.png diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..68f75ac --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "frontend", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "cwd": "frontend", + "port": 5173 + } + ] +} diff --git a/.gitignore b/.gitignore index 399e9e1..ba5e8ca 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ dist/ node_modules/ frontend/dist/ +# Built frontend served by backend (produced by the deploy build) +backend/static/ + # DB *.db @@ -17,6 +20,16 @@ frontend/dist/ .vscode/ .idea/ +# macOS +.DS_Store + +# Packaged service artifacts +*.s9pk + # Deploy deploy/node_modules/ deploy/javascript/ + +# Confidential fund-admin spreadsheets (never commit) +*.xlsx +*.xls diff --git a/README.md b/README.md index 3c8d5f6..9a98312 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,31 @@ Internal system of record for Ten31 entities, holdings, positions, and quarterly valuation sign-off. +## Accounts and access + +Two kinds of accounts: + +- **Internal staff** (`approver`, `cfo`, `fund_admin`, `viewer`) — the full back-office app + (entities, holdings, valuations, import, audit). `approver` and `cfo` also get the + admin screens below. +- **External accounts** (`investor`, `fund_administrator`) — a separate, entity-scoped + portal. An external account only sees the entities granted to it. + - **Investor** — sees, per fund, their latest capital-account value and history, plus + documents shared to the fund or addressed privately to them (e.g. their K-1). + - **Fund administrator** — sees assigned entities and can upload documents for them + (shared or addressed to a specific investor). + +Admin screens (Users / Documents / Capital Accounts, visible to `approver` and `cfo`) +let you create an account with a username and password, check off which entities it can +view, upload documents, and enter each investor's capital-account figures. + +Login accepts a username **or** an email. The first admin is created from the CLI: + +```bash +ten31portal-cli create-user --name "You" --username admin --role cfo --password '...' +# --email is optional; external accounts are normally created from the Users screen. +``` + ## Prerequisites - Python 3.11+ diff --git a/backend/alembic/versions/a1b2c3d4e5f6_investor_access.py b/backend/alembic/versions/a1b2c3d4e5f6_investor_access.py new file mode 100644 index 0000000..41d2ea7 --- /dev/null +++ b/backend/alembic/versions/a1b2c3d4e5f6_investor_access.py @@ -0,0 +1,101 @@ +"""investor access: username, entity access, documents, capital accounts + +Revision ID: a1b2c3d4e5f6 +Revises: 2792c4ff4612 +Create Date: 2026-06-26 14:30:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = 'a1b2c3d4e5f6' +down_revision: Union[str, None] = '2792c4ff4612' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # users: add username (login handle), make email optional. + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column( + sa.Column('username', sqlmodel.sql.sqltypes.AutoString(), nullable=True) + ) + batch_op.alter_column('email', existing_type=sa.String(), nullable=True) + + # Backfill username for any existing internal accounts so the NOT NULL holds. + op.execute("UPDATE users SET username = email WHERE username IS NULL") + + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.alter_column( + 'username', + existing_type=sqlmodel.sql.sqltypes.AutoString(), + nullable=False, + ) + batch_op.create_unique_constraint('uq_users_username', ['username']) + + op.create_table( + 'entity_access', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('entity_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['entity_id'], ['entities.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'entity_id', name='uq_access_user_entity'), + ) + + op.create_table( + 'documents', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('entity_id', sa.Integer(), nullable=False), + sa.Column('investor_user_id', sa.Integer(), nullable=True), + sa.Column('category', sa.Enum('capital_account', 'k1', 'statement', 'tax', 'other', name='documentcategory'), nullable=False), + sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('original_filename', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('content_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('size_bytes', sa.Integer(), nullable=False), + sa.Column('storage_path', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('uploaded_by', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['entity_id'], ['entities.id'], ), + sa.ForeignKeyConstraint(['investor_user_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + ) + + op.create_table( + 'capital_account_statements', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('entity_id', sa.Integer(), nullable=False), + sa.Column('investor_user_id', sa.Integer(), nullable=False), + sa.Column('as_of_date', sa.Date(), nullable=False), + sa.Column('beginning_balance_cents', sa.Integer(), nullable=False), + sa.Column('contributions_cents', sa.Integer(), nullable=False), + sa.Column('distributions_cents', sa.Integer(), nullable=False), + sa.Column('ending_balance_cents', sa.Integer(), nullable=False), + sa.Column('document_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['document_id'], ['documents.id'], ), + sa.ForeignKeyConstraint(['entity_id'], ['entities.id'], ), + sa.ForeignKeyConstraint(['investor_user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('entity_id', 'investor_user_id', 'as_of_date', name='uq_capacct_entity_investor_date'), + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_table('capital_account_statements') + op.drop_table('documents') + op.drop_table('entity_access') + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_constraint('uq_users_username', type_='unique') + batch_op.alter_column('email', existing_type=sa.String(), nullable=False) + batch_op.drop_column('username') diff --git a/backend/alembic/versions/b2c3d4e5f6a7_external_investor_id.py b/backend/alembic/versions/b2c3d4e5f6a7_external_investor_id.py new file mode 100644 index 0000000..9f9e69d --- /dev/null +++ b/backend/alembic/versions/b2c3d4e5f6a7_external_investor_id.py @@ -0,0 +1,30 @@ +"""add users.external_investor_id + +Revision ID: b2c3d4e5f6a7 +Revises: a1b2c3d4e5f6 +Create Date: 2026-06-28 11:10:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +revision: str = 'b2c3d4e5f6a7' +down_revision: Union[str, None] = 'a1b2c3d4e5f6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column( + sa.Column('external_investor_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True) + ) + + +def downgrade() -> None: + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_column('external_investor_id') diff --git a/backend/alembic/versions/b8c9d0e1f2a3_entity_stakes.py b/backend/alembic/versions/b8c9d0e1f2a3_entity_stakes.py new file mode 100644 index 0000000..df7d087 --- /dev/null +++ b/backend/alembic/versions/b8c9d0e1f2a3_entity_stakes.py @@ -0,0 +1,41 @@ +"""add entity_stakes (a holder entity's stake in the funds it manages) + +Revision ID: b8c9d0e1f2a3 +Revises: a7b8c9d0e1f2 +Create Date: 2026-07-01 10:15:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'b8c9d0e1f2a3' +down_revision: Union[str, None] = 'a7b8c9d0e1f2' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'entity_stakes', + sa.Column('id', sa.Integer(), primary_key=True, nullable=False), + sa.Column('holder_entity_id', sa.Integer(), nullable=False), + sa.Column('fund_entity_id', sa.Integer(), nullable=False), + sa.Column('ownership_pct', sa.Float(), nullable=True), + sa.Column('value_cents', sa.Integer(), nullable=True), + sa.Column('note', sa.String(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['holder_entity_id'], ['entities.id']), + sa.ForeignKeyConstraint(['fund_entity_id'], ['entities.id']), + sa.UniqueConstraint('holder_entity_id', 'fund_entity_id', name='uq_stake_holder_fund'), + ) + op.create_index('ix_entity_stakes_holder_entity_id', 'entity_stakes', ['holder_entity_id']) + op.create_index('ix_entity_stakes_fund_entity_id', 'entity_stakes', ['fund_entity_id']) + + +def downgrade() -> None: + op.drop_index('ix_entity_stakes_fund_entity_id', table_name='entity_stakes') + op.drop_index('ix_entity_stakes_holder_entity_id', table_name='entity_stakes') + op.drop_table('entity_stakes') diff --git a/backend/alembic/versions/c3d4e5f6a7b8_login_enabled.py b/backend/alembic/versions/c3d4e5f6a7b8_login_enabled.py new file mode 100644 index 0000000..a4911d8 --- /dev/null +++ b/backend/alembic/versions/c3d4e5f6a7b8_login_enabled.py @@ -0,0 +1,29 @@ +"""add users.login_enabled + +Revision ID: c3d4e5f6a7b8 +Revises: b2c3d4e5f6a7 +Create Date: 2026-06-28 11:45:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'c3d4e5f6a7b8' +down_revision: Union[str, None] = 'b2c3d4e5f6a7' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column( + sa.Column('login_enabled', sa.Boolean(), nullable=False, server_default=sa.true()) + ) + + +def downgrade() -> None: + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_column('login_enabled') diff --git a/backend/alembic/versions/d4e5f6a7b8c9_commitment_cents.py b/backend/alembic/versions/d4e5f6a7b8c9_commitment_cents.py new file mode 100644 index 0000000..9add644 --- /dev/null +++ b/backend/alembic/versions/d4e5f6a7b8c9_commitment_cents.py @@ -0,0 +1,29 @@ +"""add capital_account_statements.commitment_cents + +Revision ID: d4e5f6a7b8c9 +Revises: c3d4e5f6a7b8 +Create Date: 2026-06-28 16:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'd4e5f6a7b8c9' +down_revision: Union[str, None] = 'c3d4e5f6a7b8' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('capital_account_statements', schema=None) as batch_op: + batch_op.add_column( + sa.Column('commitment_cents', sa.Integer(), nullable=False, server_default='0') + ) + + +def downgrade() -> None: + with op.batch_alter_table('capital_account_statements', schema=None) as batch_op: + batch_op.drop_column('commitment_cents') diff --git a/backend/alembic/versions/e5f6a7b8c9d0_primary_account_id.py b/backend/alembic/versions/e5f6a7b8c9d0_primary_account_id.py new file mode 100644 index 0000000..50a414a --- /dev/null +++ b/backend/alembic/versions/e5f6a7b8c9d0_primary_account_id.py @@ -0,0 +1,33 @@ +"""add users.primary_account_id (linked investor logins) + +Revision ID: e5f6a7b8c9d0 +Revises: d4e5f6a7b8c9 +Create Date: 2026-06-28 17:10:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'e5f6a7b8c9d0' +down_revision: Union[str, None] = 'd4e5f6a7b8c9' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column( + sa.Column('primary_account_id', sa.Integer(), nullable=True) + ) + batch_op.create_foreign_key( + 'fk_users_primary_account_id', 'users', ['primary_account_id'], ['id'] + ) + + +def downgrade() -> None: + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_constraint('fk_users_primary_account_id', type_='foreignkey') + batch_op.drop_column('primary_account_id') diff --git a/backend/alembic/versions/f6a7b8c9d0e1_service_admin.py b/backend/alembic/versions/f6a7b8c9d0e1_service_admin.py new file mode 100644 index 0000000..05d30d7 --- /dev/null +++ b/backend/alembic/versions/f6a7b8c9d0e1_service_admin.py @@ -0,0 +1,34 @@ +"""add users.is_service_admin and flag the bootstrap admin + +Revision ID: f6a7b8c9d0e1 +Revises: e5f6a7b8c9d0 +Create Date: 2026-06-29 09:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'f6a7b8c9d0e1' +down_revision: Union[str, None] = 'e5f6a7b8c9d0' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column( + sa.Column('is_service_admin', sa.Boolean(), nullable=False, server_default=sa.false()) + ) + # The first account created (the first-boot bootstrap admin) is the Service Admin. + op.execute( + "UPDATE users SET is_service_admin = 1 " + "WHERE id = (SELECT MIN(id) FROM users)" + ) + + +def downgrade() -> None: + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_column('is_service_admin') diff --git a/backend/ten31portal/auth.py b/backend/ten31portal/auth.py index 59c5598..0f365b0 100644 --- a/backend/ten31portal/auth.py +++ b/backend/ten31portal/auth.py @@ -5,10 +5,10 @@ from typing import Annotated from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError from fastapi import Depends, HTTPException, Request -from sqlmodel import Session, select +from sqlmodel import Session, select, col from ten31portal.database import get_session -from ten31portal.models import User, UserRole +from ten31portal.models import EntityAccess, User, UserRole, EXTERNAL_ROLES ph = PasswordHasher() @@ -44,8 +44,51 @@ def require_role(*roles: UserRole): return checker +def require_internal(user: User = Depends(get_current_user)) -> User: + """Block external (entity-scoped) accounts from internal staff endpoints.""" + if user.role in EXTERNAL_ROLES: + raise HTTPException(status_code=403, detail="Insufficient permissions") + return user + + +def household_user_ids(user: User, session: Session) -> list[int]: + """All account ids that share this user's login. + + An investor who invests under several legal names has one "primary" account (the login) + and one or more secondary accounts linked to it via ``primary_account_id``. Signing in as + the primary should surface every linked name's entities, statements, and documents. For a + standalone account this is just ``[user.id]``. + """ + root_id = user.primary_account_id or user.id + linked = session.exec( + select(User.id).where(User.primary_account_id == root_id) + ).all() + return list({root_id, user.id, *linked}) + + +def accessible_entity_ids(user: User, session: Session) -> set[int] | None: + """Entity ids an external account may view. None means unrestricted (internal staff).""" + if user.role not in EXTERNAL_ROLES: + return None + rows = session.exec( + select(EntityAccess.entity_id).where( + col(EntityAccess.user_id).in_(household_user_ids(user, session)) + ) + ).all() + return set(rows) + + +def can_access_entity(user: User, entity_id: int, session: Session) -> bool: + allowed = accessible_entity_ids(user, session) + return allowed is None or entity_id in allowed + + # Convenience aliases require_user = get_current_user -require_writer = require_role(UserRole.fund_admin, UserRole.cfo, UserRole.approver) -require_approver = require_role(UserRole.approver) -require_audit_reader = require_role(UserRole.approver, UserRole.cfo) +require_writer = require_role( + UserRole.fund_admin, UserRole.cfo, UserRole.approver, UserRole.operations +) +require_approver = require_role(UserRole.approver) # final sign-off — Managing Partners only +require_audit_reader = require_role(UserRole.approver, UserRole.cfo, UserRole.operations) +# Account administration (create/manage users, documents, capital accounts) +require_internal_admin = require_role(UserRole.approver, UserRole.cfo, UserRole.operations) diff --git a/backend/ten31portal/main.py b/backend/ten31portal/main.py index 70f59cf..7d82653 100644 --- a/backend/ten31portal/main.py +++ b/backend/ten31portal/main.py @@ -6,6 +6,7 @@ from pathlib import Path from fastapi import FastAPI from fastapi.staticfiles import StaticFiles +from starlette.middleware.gzip import GZipMiddleware from starlette.middleware.sessions import SessionMiddleware from starlette.responses import FileResponse @@ -18,16 +19,26 @@ from ten31portal.routers.holding_router import router as holding_router from ten31portal.routers.position_router import router as position_router from ten31portal.routers.round_router import router as round_router from ten31portal.routers.import_router import router as import_router +from ten31portal.routers.user_router import router as user_router +from ten31portal.routers.document_router import router as document_router +from ten31portal.routers.capital_account_router import router as capital_account_router +from ten31portal.routers.capital_import_router import router as capital_import_router @asynccontextmanager async def lifespan(app: FastAPI): run_migrations() + from ten31portal.storage import ensure_docs_dir + ensure_docs_dir() yield app = FastAPI(title="Ten31Portal", version="0.1.0", lifespan=lifespan) app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET) +# Compress HTML/JS/CSS/JSON over the wire (the 320KB JS bundle gzips to ~80KB). Added after +# SessionMiddleware so it sits outermost and compresses the final response. minimum_size skips +# tiny payloads (health checks, small JSON) where compression isn't worth it. +app.add_middleware(GZipMiddleware, minimum_size=500) app.include_router(auth_router) app.include_router(audit_router) app.include_router(entity_router) @@ -35,6 +46,10 @@ app.include_router(holding_router) app.include_router(position_router) app.include_router(round_router) app.include_router(import_router) +app.include_router(user_router) +app.include_router(document_router) +app.include_router(capital_account_router) +app.include_router(capital_import_router) @app.get("/api/health") @@ -45,12 +60,29 @@ def health() -> dict[str, str]: # Serve built frontend in production (when static/ dir exists next to the app) _static_dir = Path(__file__).resolve().parent.parent / "static" if _static_dir.is_dir(): - @app.get("/{path:path}") + # index.html must always be revalidated so a new build is picked up right after an + # upgrade (its hashed asset references change). Hashed assets themselves are immutable. + _NO_CACHE = {"Cache-Control": "no-cache, must-revalidate"} + # Vite content-hashes asset filenames (index-UPbvqVP1.js), so they can cache forever. + _IMMUTABLE = {"Cache-Control": "public, max-age=31536000, immutable"} + _ONE_DAY = {"Cache-Control": "public, max-age=86400"} + + def _index() -> FileResponse: + return FileResponse(_static_dir / "index.html", headers=_NO_CACHE) + + @app.api_route("/{path:path}", methods=["GET", "HEAD"]) async def serve_spa(path: str): file = _static_dir / path if file.is_file(): + # Don't let the HTML entrypoint get cached; fingerprinted assets can cache. + if file.name == "index.html": + return _index() + if path.startswith("assets/"): + return FileResponse(file, headers=_IMMUTABLE) + if path in ("ten31-logo.png", "favicon.svg", "favicon.ico"): + return FileResponse(file, headers=_ONE_DAY) return FileResponse(file) - return FileResponse(_static_dir / "index.html") + return _index() def cli() -> None: diff --git a/backend/ten31portal/models.py b/backend/ten31portal/models.py index 6177f68..7ca2732 100644 --- a/backend/ten31portal/models.py +++ b/backend/ten31portal/models.py @@ -207,3 +207,23 @@ class CapitalAccountStatement(SQLModel, table=True): ending_balance_cents: int = 0 # current capital value document_id: int | None = Field(default=None, foreign_key="documents.id", index=True) created_at: datetime = Field(default_factory=datetime.utcnow) + + +class EntityStake(SQLModel, table=True): + """A holder entity's ownership stake in a fund/SPV it manages. + + Models the assets of a GP or management company: its interest in each fund it manages, + which are entities in their own right rather than portfolio-company holdings. + """ + __tablename__ = "entity_stakes" + __table_args__ = ( + UniqueConstraint("holder_entity_id", "fund_entity_id", name="uq_stake_holder_fund"), + ) + + id: int | None = Field(default=None, primary_key=True) + holder_entity_id: int = Field(foreign_key="entities.id", index=True) # the GP / mgmt co + fund_entity_id: int = Field(foreign_key="entities.id", index=True) # the fund/SPV held + ownership_pct: float | None = None # e.g. 20.0 for a 20% interest + value_cents: int | None = None # optional current value of the stake + note: str | None = None + created_at: datetime = Field(default_factory=datetime.utcnow) diff --git a/backend/ten31portal/routers/auth_router.py b/backend/ten31portal/routers/auth_router.py index 161d02d..f6c08a5 100644 --- a/backend/ten31portal/routers/auth_router.py +++ b/backend/ten31portal/routers/auth_router.py @@ -6,7 +6,7 @@ from sqlmodel import Session, select from ten31portal.auth import get_current_user, hash_password, verify_password from ten31portal.database import get_session from ten31portal.models import User -from ten31portal.schemas import LoginRequest, UserResponse +from ten31portal.schemas import ChangePasswordRequest, LoginRequest, UserResponse router = APIRouter(prefix="/api/auth", tags=["auth"]) @@ -17,9 +17,20 @@ def login( request: Request, session: Session = Depends(get_session), ) -> UserResponse: - user = session.exec(select(User).where(User.email == body.email)).first() + # Accept either a username or an email in the login field. + handle = body.login.strip() + user = session.exec(select(User).where(User.username == handle)).first() + if user is None: + user = session.exec(select(User).where(User.email == handle)).first() if user is None or not verify_password(body.password, user.password_hash): - raise HTTPException(status_code=401, detail="Invalid email or password") + raise HTTPException(status_code=401, detail="Invalid username or password") + if user.primary_account_id is not None: + raise HTTPException( + status_code=401, + detail="This account signs in under another login. Use that account's credentials.", + ) + if not user.login_enabled: + raise HTTPException(status_code=401, detail="This account does not have a login yet.") if not user.is_active: raise HTTPException(status_code=401, detail="Account disabled") request.session["user_id"] = user.id @@ -35,3 +46,20 @@ def logout(request: Request) -> dict[str, str]: @router.get("/me") def me(user: User = Depends(get_current_user)) -> UserResponse: return UserResponse.model_validate(user, from_attributes=True) + + +@router.post("/change-password") +def change_password( + body: ChangePasswordRequest, + user: User = Depends(get_current_user), + session: Session = Depends(get_session), +) -> dict[str, str]: + """Let the signed-in user set their own password (after confirming the current one).""" + if not verify_password(body.current_password, user.password_hash): + raise HTTPException(status_code=400, detail="Current password is incorrect.") + if len(body.new_password) < 4: + raise HTTPException(status_code=400, detail="New password must be at least 4 characters.") + user.password_hash = hash_password(body.new_password) + session.add(user) + session.commit() + return {"status": "ok"} diff --git a/backend/ten31portal/routers/capital_account_router.py b/backend/ten31portal/routers/capital_account_router.py new file mode 100644 index 0000000..f37472a --- /dev/null +++ b/backend/ten31portal/routers/capital_account_router.py @@ -0,0 +1,129 @@ +"""Capital account statements: admin entry, investor read of their own figures.""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, select, col + +from ten31portal.audit import record_audit +from ten31portal.auth import ( + accessible_entity_ids, get_current_user, household_user_ids, + require_internal_admin, +) +from ten31portal.database import get_session +from ten31portal.models import ( + CapitalAccountStatement, Entity, User, UserRole, +) +from ten31portal.schemas import CapitalAccountCreate, CapitalAccountResponse + +router = APIRouter(prefix="/api/capital-accounts", tags=["capital-accounts"]) + + +def _dollars_to_cents(dollars: float) -> int: + return round(dollars * 100) + + +@router.get("") +def list_statements( + entity_id: int | None = None, + investor_user_id: int | None = None, + user: User = Depends(get_current_user), + session: Session = Depends(get_session), +) -> list[CapitalAccountResponse]: + query = select(CapitalAccountStatement) + allowed = accessible_entity_ids(user, session) + + if allowed is not None: + # External accounts see statements for every legal name linked to their login. + query = query.where( + col(CapitalAccountStatement.investor_user_id).in_(household_user_ids(user, session)) + ) + if allowed: + query = query.where(col(CapitalAccountStatement.entity_id).in_(allowed)) + else: + return [] + else: + if investor_user_id is not None: + query = query.where(CapitalAccountStatement.investor_user_id == investor_user_id) + + if entity_id is not None: + query = query.where(CapitalAccountStatement.entity_id == entity_id) + + rows = session.exec( + query.order_by(col(CapitalAccountStatement.as_of_date).desc()) + ).all() + # Attach each statement's legal name so the portal can label/group accounts held under + # different names without an admin-only user lookup. + names = dict(session.exec( + select(User.id, User.name).where( + col(User.id).in_({r.investor_user_id for r in rows}) + ) + ).all()) if rows else {} + out: list[CapitalAccountResponse] = [] + for r in rows: + data = CapitalAccountResponse.model_validate(r, from_attributes=True) + data.investor_name = names.get(r.investor_user_id) + out.append(data) + return out + + +@router.post("", status_code=201) +def create_statement( + body: CapitalAccountCreate, + admin: User = Depends(require_internal_admin), + session: Session = Depends(get_session), +) -> CapitalAccountResponse: + if session.get(Entity, body.entity_id) is None: + raise HTTPException(status_code=404, detail="Entity not found") + investor = session.get(User, body.investor_user_id) + if investor is None or investor.role != UserRole.investor: + raise HTTPException(status_code=400, detail="investor_user_id must be an investor account") + + existing = session.exec( + select(CapitalAccountStatement).where( + CapitalAccountStatement.entity_id == body.entity_id, + CapitalAccountStatement.investor_user_id == body.investor_user_id, + CapitalAccountStatement.as_of_date == body.as_of_date, + ) + ).first() + if existing: + raise HTTPException( + status_code=409, + detail="A statement for this investor, fund, and date already exists.", + ) + + stmt = CapitalAccountStatement( + entity_id=body.entity_id, + investor_user_id=body.investor_user_id, + as_of_date=body.as_of_date, + commitment_cents=_dollars_to_cents(body.commitment_dollars), + beginning_balance_cents=_dollars_to_cents(body.beginning_balance_dollars), + contributions_cents=_dollars_to_cents(body.contributions_dollars), + distributions_cents=_dollars_to_cents(body.distributions_dollars), + ending_balance_cents=_dollars_to_cents(body.ending_balance_dollars), + document_id=body.document_id, + ) + session.add(stmt) + session.flush() + record_audit(session, admin.id, "create", "capital_account", stmt.id, { + "entity_id": body.entity_id, + "investor_user_id": body.investor_user_id, + "as_of_date": str(body.as_of_date), + "ending_balance_cents": stmt.ending_balance_cents, + }) + session.commit() + session.refresh(stmt) + return CapitalAccountResponse.model_validate(stmt, from_attributes=True) + + +@router.delete("/{statement_id}") +def delete_statement( + statement_id: int, + admin: User = Depends(require_internal_admin), + session: Session = Depends(get_session), +) -> dict[str, str]: + stmt = session.get(CapitalAccountStatement, statement_id) + if stmt is None: + raise HTTPException(status_code=404, detail="Statement not found") + record_audit(session, admin.id, "delete", "capital_account", statement_id, None) + session.delete(stmt) + session.commit() + return {"status": "deleted"} diff --git a/backend/ten31portal/routers/capital_import_router.py b/backend/ten31portal/routers/capital_import_router.py new file mode 100644 index 0000000..25e48a5 --- /dev/null +++ b/backend/ten31portal/routers/capital_import_router.py @@ -0,0 +1,348 @@ +"""Import fund members and their capital figures from a fund-administrator workbook. + +Primary path: an eNAV workbook's "ALLOC SI" tab (one row per investor with INVESTOR ID, +INVESTOR NAME, COMMITTED CAPITAL, CONTRIBUTIONS, (DISTRIBUTIONS), ENDING BALANCE). +Fallback: a subaccounts sheet (investor names across columns, per-vehicle value rows). + +Encrypted workbooks are decrypted with the open password. Nothing is written on preview. +Commit matches existing members (by fund-admin investor ID, else name), creates new ones +(without a login unless a password is given), grants entity access, and loads each member's +capital-account statement (commitment, contributions, distributions, current value). +""" + +import io +import re +import secrets +from datetime import date, datetime + +import openpyxl +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile +from sqlmodel import Session, select + +from ten31portal.audit import record_audit +from ten31portal.auth import hash_password, require_internal_admin +from ten31portal.database import get_session +from ten31portal.models import ( + CapitalAccountStatement, Entity, EntityAccess, User, UserRole, +) +from ten31portal.routers.import_router import _open_workbook, _enav_as_of +from ten31portal.schemas import ( + CapitalImportCommit, CapitalImportPreview, ImportInvestorPreview, ImportValueRow, +) + +router = APIRouter(prefix="/api/import/capital-accounts", tags=["import"]) + +MAX_ROWS = 400 +MAX_COLS = 90 + + +def _slug_username(name: str) -> str: + base = re.sub(r"[^a-z0-9]+", "", name.lower()) + return base or "investor" + + +def _as_float(v) -> float | None: + if v is None: + return None + if isinstance(v, (int, float)): + return float(v) + try: + return float(str(v).replace(",", "").replace("$", "").strip()) + except ValueError: + return None + + +# --- ALLOC SI (eNAV investor roster) --- + +def _parse_alloc_si(wb): + """Return (as_of, investors) from an eNAV ALLOC SI sheet. + + investors: [{name, external_id, commitment, contributions, distributions, ending}]. + """ + ws = wb["ALLOC SI"] + rows = list(ws.iter_rows(min_row=1, max_row=MAX_ROWS, max_col=MAX_COLS, values_only=True)) + + header_idx = None + header: list[str] = [] + for i, row in enumerate(rows): + cells = [str(c).strip().upper() if isinstance(c, str) else "" for c in row] + if "INVESTOR ID" in cells and "INVESTOR NAME" in cells: + header_idx, header = i, cells + break + if header_idx is None: + raise HTTPException(status_code=422, detail="Could not find the ALLOC SI header row.") + + def col(*names: str) -> int | None: + for n in names: + if n in header: + return header.index(n) + return None + + c_id = col("INVESTOR ID") + c_type = col("INVESTOR TYPE") + c_name = col("INVESTOR NAME") + c_commit = col("COMMITTED CAPITAL") + c_contrib = col("CONTRIBUTIONS") + c_distrib = col("(DISTRIBUTIONS)", "DISTRIBUTIONS") + c_ending = col("ENDING BALANCE") + if c_ending is None: + raise HTTPException(status_code=422, detail="ALLOC SI is missing an ENDING BALANCE column.") + + def amount(row, ci): + if ci is None or ci >= len(row): + return 0.0 + return _as_float(row[ci]) or 0.0 + + investors: list[dict] = [] + for r in range(header_idx + 1, len(rows)): + row = rows[r] + name_raw = row[c_name] if c_name is not None and c_name < len(row) else None + if not (isinstance(name_raw, str) and name_raw.strip()): + continue + nm = name_raw.strip() + if nm.upper() in ("GP", "LP", "TOTAL"): + continue + itype = row[c_type] if c_type is not None and c_type < len(row) else None + if itype is not None and str(itype).strip().upper() not in ("LP", ""): + continue # investors only (skip the GP entity) + + display = None + if c_name is not None and c_name + 1 < len(row) and isinstance(row[c_name + 1], str) and row[c_name + 1].strip(): + display = row[c_name + 1].strip() + name = display or nm.title() + + inv_id = row[c_id] if c_id is not None and c_id < len(row) else None + external_id = str(inv_id).strip() if inv_id not in (None, "") else None + + investors.append({ + "name": name, + "external_id": external_id, + "commitment": amount(row, c_commit), + "contributions": amount(row, c_contrib), + "distributions": abs(amount(row, c_distrib)), # sheet may show as a credit + "ending": amount(row, c_ending), + }) + + return _enav_as_of(wb), investors + + +# --- Subaccounts (names-across-columns) --- + +def _parse_grid(wb): + ws = wb[wb.sheetnames[0]] + grid: list[list] = [] + for row in ws.iter_rows(min_row=1, max_row=MAX_ROWS, max_col=MAX_COLS, values_only=True): + grid.append(list(row)) + + header_idx = None + total_col = None + for i, row in enumerate(grid): + for j, cell in enumerate(row): + if isinstance(cell, str) and cell.strip().lower() == "total": + header_idx, total_col = i, j + break + if header_idx is not None: + break + if header_idx is None: + raise HTTPException( + status_code=422, + detail='Could not find a header row with a "Total" column. Check the spreadsheet layout.', + ) + + header = grid[header_idx] + investor_cols = [ + j for j in range(len(header)) + if j < total_col + and isinstance(header[j], str) + and header[j].strip().lower() not in ("", "total") + ] + if not investor_cols: + raise HTTPException(status_code=422, detail="No investor name columns found left of the Total column.") + + as_of: date | None = None + for row in grid[: header_idx + 2]: + for cell in row: + if isinstance(cell, (datetime, date)): + as_of = cell.date() if isinstance(cell, datetime) else cell + break + if as_of: + break + + value_rows: list[ImportValueRow] = [] + for i in range(header_idx + 1, len(grid)): + row = grid[i] + label = row[0] + if not (isinstance(label, str) and label.strip()): + continue + if any(_as_float(row[j]) is not None for j in investor_cols if j < len(row)): + value_rows.append(ImportValueRow(row_index=i, label=label.strip())) + if not value_rows: + raise HTTPException(status_code=422, detail="No value rows found under the header.") + + return header_idx, total_col, investor_cols, as_of, value_rows, grid + + +@router.post("/preview") +def preview_import( + file: UploadFile = File(...), + entity_id: int | None = Form(None), + row_index: int | None = Form(None), + password: str | None = Form(None), + admin: User = Depends(require_internal_admin), + session: Session = Depends(get_session), +) -> CapitalImportPreview: + if entity_id is not None and session.get(Entity, entity_id) is None: + raise HTTPException(status_code=404, detail="Entity not found") + + wb = _open_workbook(file.file.read(), password) + + investors = session.exec(select(User).where(User.role == UserRole.investor)).all() + by_name = {u.name.strip().lower(): u for u in investors} + by_username = {u.username.strip().lower(): u for u in investors} + by_extid = {u.external_investor_id: u for u in investors if u.external_investor_id} + + def match_for(name: str, external_id: str | None): + if external_id and external_id in by_extid: + return by_extid[external_id] + return by_name.get(name.strip().lower()) or by_username.get(name.strip().lower()) + + if "ALLOC SI" in wb.sheetnames: + as_of, roster = _parse_alloc_si(wb) + previews: list[ImportInvestorPreview] = [] + for inv in roster: + m = match_for(inv["name"], inv["external_id"]) + previews.append(ImportInvestorPreview( + source_name=inv["name"], + column_index=0, + value_dollars=inv["ending"], + commitment_dollars=inv["commitment"], + contributions_dollars=inv["contributions"], + distributions_dollars=inv["distributions"], + external_id=inv["external_id"], + matched_user_id=m.id if m else None, + matched_username=m.username if m else None, + suggested_username=None if m else _slug_username(inv["name"]), + )) + # Roster mode: no per-row value picker needed. + return CapitalImportPreview(as_of_date=as_of, value_rows=[], chosen_row_index=0, investors=previews) + + # Fallback: subaccounts layout (single value figure per investor). + header_idx, total_col, investor_cols, as_of, value_rows, grid = _parse_grid(wb) + valid_indexes = {vr.row_index for vr in value_rows} + chosen = row_index if row_index in valid_indexes else value_rows[0].row_index + chosen_row = grid[chosen] + + previews = [] + for col_i in investor_cols: + source_name = str(grid[header_idx][col_i]).strip() + value = _as_float(chosen_row[col_i]) if col_i < len(chosen_row) else None + m = match_for(source_name, None) + previews.append(ImportInvestorPreview( + source_name=source_name, + column_index=col_i, + value_dollars=value or 0.0, + matched_user_id=m.id if m else None, + matched_username=m.username if m else None, + suggested_username=None if m else _slug_username(source_name), + )) + return CapitalImportPreview(as_of_date=as_of, value_rows=value_rows, chosen_row_index=chosen, investors=previews) + + +@router.post("/commit") +def commit_import( + body: CapitalImportCommit, + admin: User = Depends(require_internal_admin), + session: Session = Depends(get_session), +) -> dict: + entity = session.get(Entity, body.entity_id) + if entity is None: + raise HTTPException(status_code=404, detail="Entity not found") + + created_accounts = 0 + updated_accounts = 0 + statements = 0 + + for inv in body.investors: + if inv.action == "skip": + continue + + if inv.action == "create": + if not inv.username or not inv.name: + raise HTTPException( + status_code=400, + detail=f"New member '{inv.name or inv.username}' needs a name and username.", + ) + if session.exec(select(User).where(User.username == inv.username)).first(): + raise HTTPException(status_code=409, detail=f"Username '{inv.username}' already taken.") + if inv.email and session.exec(select(User).where(User.email == inv.email)).first(): + raise HTTPException(status_code=409, detail=f"Email '{inv.email}' already in use.") + pw = inv.password or secrets.token_urlsafe(32) + user = User( + name=inv.name, + username=inv.username, + email=inv.email or None, + password_hash=hash_password(pw), + role=UserRole.investor, + login_enabled=bool(inv.password), + external_investor_id=inv.external_id, + ) + session.add(user) + session.flush() + created_accounts += 1 + elif inv.action == "match": + user = session.get(User, inv.user_id) if inv.user_id else None + if user is None or user.role != UserRole.investor: + raise HTTPException(status_code=400, detail="match requires a valid investor user_id.") + if inv.external_id and not user.external_investor_id: + user.external_investor_id = inv.external_id + session.add(user) + else: + raise HTTPException(status_code=400, detail=f"Unknown action '{inv.action}'.") + + has_access = session.exec( + select(EntityAccess).where( + EntityAccess.user_id == user.id, EntityAccess.entity_id == body.entity_id + ) + ).first() + if has_access is None: + session.add(EntityAccess(user_id=user.id, entity_id=body.entity_id)) + + cents = lambda d: round(d * 100) + existing = session.exec( + select(CapitalAccountStatement).where( + CapitalAccountStatement.entity_id == body.entity_id, + CapitalAccountStatement.investor_user_id == user.id, + CapitalAccountStatement.as_of_date == body.as_of_date, + ) + ).first() + if existing: + existing.commitment_cents = cents(inv.commitment_dollars) + existing.contributions_cents = cents(inv.contributions_dollars) + existing.distributions_cents = cents(inv.distributions_dollars) + existing.ending_balance_cents = cents(inv.value_dollars) + session.add(existing) + updated_accounts += 1 + else: + session.add(CapitalAccountStatement( + entity_id=body.entity_id, + investor_user_id=user.id, + as_of_date=body.as_of_date, + commitment_cents=cents(inv.commitment_dollars), + contributions_cents=cents(inv.contributions_dollars), + distributions_cents=cents(inv.distributions_dollars), + ending_balance_cents=cents(inv.value_dollars), + )) + statements += 1 + + record_audit(session, admin.id, "import", "capital_account", body.entity_id, { + "as_of_date": str(body.as_of_date), + "created_accounts": created_accounts, + "statements": statements, + }) + session.commit() + return { + "status": "ok", + "created_accounts": created_accounts, + "matched_accounts_updated": updated_accounts, + "statements_written": statements, + } diff --git a/backend/ten31portal/routers/entity_router.py b/backend/ten31portal/routers/entity_router.py index 0270ce0..e82480c 100644 --- a/backend/ten31portal/routers/entity_router.py +++ b/backend/ten31portal/routers/entity_router.py @@ -3,16 +3,21 @@ from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy import func, literal -from sqlmodel import Session, select +from sqlmodel import Session, col, select from ten31portal.audit import record_audit -from ten31portal.auth import get_current_user, require_writer +from ten31portal.auth import ( + accessible_entity_ids, get_current_user, require_internal, require_writer, +) from ten31portal.database import get_session from ten31portal.models import ( - Entity, EntityStatus, Holding, Position, - Valuation, ValuationRound, RoundStatus, User, + CapitalAccountStatement, Entity, EntityAccess, EntityStake, EntityStatus, Holding, + Position, UserRole, Valuation, ValuationRound, RoundStatus, User, +) +from ten31portal.schemas import ( + EntityCreate, EntityResponse, EntityStakeCreate, EntityStakeResponse, EntityUpdate, + PartnerResponse, ) -from ten31portal.schemas import EntityCreate, EntityResponse, EntityUpdate router = APIRouter(prefix="/api/entities", tags=["entities"]) @@ -24,6 +29,7 @@ class EntityRollupItem(BaseModel): vintage_year: int | None fund_size_cents: int | None status: str + committed_cents: int # total LP commitments (latest per investor) invested_cents: int last_signed_value_cents: int @@ -34,7 +40,10 @@ def entity_rollup( session: Session = Depends(get_session), ) -> list[EntityRollupItem]: """Per-entity invested and last-signed-value in a single pass.""" + allowed = accessible_entity_ids(user, session) entities = session.exec(select(Entity)).all() + if allowed is not None: + entities = [e for e in entities if e.id in allowed] result: list[EntityRollupItem] = [] for ent in entities: @@ -64,6 +73,20 @@ def entity_rollup( ).one() last_signed_value_cents = int(val_sum) + # Total committed capital = each investor's most recent commitment for this entity. + stmts = session.exec( + select(CapitalAccountStatement) + .where(CapitalAccountStatement.entity_id == ent.id) + .order_by(CapitalAccountStatement.as_of_date.desc()) # type: ignore[union-attr] + ).all() + committed_cents = 0 + seen_investors: set[int] = set() + for st in stmts: + if st.investor_user_id in seen_investors: + continue + seen_investors.add(st.investor_user_id) + committed_cents += st.commitment_cents + result.append(EntityRollupItem( id=ent.id, name=ent.name, @@ -71,6 +94,7 @@ def entity_rollup( vintage_year=ent.vintage_year, fund_size_cents=ent.fund_size_cents, status=ent.status.value, + committed_cents=committed_cents, invested_cents=invested_cents, last_signed_value_cents=last_signed_value_cents, )) @@ -78,12 +102,60 @@ def entity_rollup( return result +@router.get("/{entity_id}/partners") +def list_partners( + entity_id: int, + user: User = Depends(require_internal), + session: Session = Depends(get_session), +) -> list[PartnerResponse]: + """Members (investors) granted access to this entity, with their latest capital value.""" + if session.get(Entity, entity_id) is None: + raise HTTPException(status_code=404, detail="Entity not found") + + members = session.exec( + select(User) + .join(EntityAccess, EntityAccess.user_id == User.id) + .where(EntityAccess.entity_id == entity_id, User.role == UserRole.investor) + .order_by(User.name) # type: ignore[arg-type] + ).all() + + result: list[PartnerResponse] = [] + for m in members: + stmts = session.exec( + select(CapitalAccountStatement) + .where( + CapitalAccountStatement.entity_id == entity_id, + CapitalAccountStatement.investor_user_id == m.id, + ) + .order_by(CapitalAccountStatement.as_of_date.desc()) # type: ignore[union-attr] + ).all() + latest = stmts[0] if stmts else None + result.append(PartnerResponse( + user_id=m.id, + name=m.name, + username=m.username, + external_investor_id=m.external_investor_id, + is_active=m.is_active, + login_enabled=m.login_enabled, + latest_commitment_cents=latest.commitment_cents if latest else None, + latest_contributions_cents=latest.contributions_cents if latest else None, + latest_distributions_cents=latest.distributions_cents if latest else None, + latest_value_cents=latest.ending_balance_cents if latest else None, + latest_as_of=latest.as_of_date if latest else None, + statements_count=len(stmts), + )) + return result + + @router.get("") def list_entities( user: User = Depends(get_current_user), session: Session = Depends(get_session), ) -> list[EntityResponse]: + allowed = accessible_entity_ids(user, session) rows = session.exec(select(Entity)).all() + if allowed is not None: + rows = [r for r in rows if r.id in allowed] return [EntityResponse.model_validate(r, from_attributes=True) for r in rows] @@ -93,6 +165,9 @@ def get_entity( user: User = Depends(get_current_user), session: Session = Depends(get_session), ) -> EntityResponse: + allowed = accessible_entity_ids(user, session) + if allowed is not None and entity_id not in allowed: + raise HTTPException(status_code=404, detail="Entity not found") entity = session.get(Entity, entity_id) if entity is None: raise HTTPException(status_code=404, detail="Entity not found") @@ -133,3 +208,96 @@ def update_entity( session.commit() session.refresh(entity) return EntityResponse.model_validate(entity, from_attributes=True) + + +# --- Entity stakes: a GP/mgmt entity's interest in the funds it manages --- + +def _stake_response(stake: EntityStake, funds: dict[int, Entity]) -> EntityStakeResponse: + data = EntityStakeResponse.model_validate(stake, from_attributes=True) + fund = funds.get(stake.fund_entity_id) + if fund is not None: + data.fund_name = fund.name + data.fund_type = fund.type + return data + + +@router.get("/{entity_id}/stakes") +def list_stakes( + entity_id: int, + user: User = Depends(get_current_user), + session: Session = Depends(get_session), +) -> list[EntityStakeResponse]: + """The funds this entity holds a stake in (e.g. a GP's interest in its funds).""" + allowed = accessible_entity_ids(user, session) + if allowed is not None and entity_id not in allowed: + raise HTTPException(status_code=404, detail="Entity not found") + if session.get(Entity, entity_id) is None: + raise HTTPException(status_code=404, detail="Entity not found") + rows = session.exec( + select(EntityStake).where(EntityStake.holder_entity_id == entity_id) + ).all() + funds = { + f.id: f for f in session.exec( + select(Entity).where(col(Entity.id).in_({r.fund_entity_id for r in rows})) + ).all() + } if rows else {} + return [_stake_response(r, funds) for r in rows] + + +@router.post("/{entity_id}/stakes", status_code=201) +def create_stake( + entity_id: int, + body: EntityStakeCreate, + user: User = Depends(require_writer), + session: Session = Depends(get_session), +) -> EntityStakeResponse: + if session.get(Entity, entity_id) is None: + raise HTTPException(status_code=404, detail="Entity not found") + if body.fund_entity_id == entity_id: + raise HTTPException(status_code=400, detail="An entity cannot hold a stake in itself.") + fund = session.get(Entity, body.fund_entity_id) + if fund is None: + raise HTTPException(status_code=404, detail="Fund not found") + if session.exec( + select(EntityStake).where( + EntityStake.holder_entity_id == entity_id, + EntityStake.fund_entity_id == body.fund_entity_id, + ) + ).first(): + raise HTTPException(status_code=409, detail="A stake in this fund already exists.") + + stake = EntityStake( + holder_entity_id=entity_id, + fund_entity_id=body.fund_entity_id, + ownership_pct=body.ownership_pct, + value_cents=round(body.value_dollars * 100) if body.value_dollars is not None else None, + note=body.note, + ) + session.add(stake) + session.flush() + record_audit(session, user.id, "create", "entity_stake", stake.id, { + "holder_entity_id": entity_id, + "fund_entity_id": body.fund_entity_id, + }) + session.commit() + session.refresh(stake) + return _stake_response(stake, {fund.id: fund}) + + +@router.delete("/{entity_id}/stakes/{stake_id}") +def delete_stake( + entity_id: int, + stake_id: int, + user: User = Depends(require_writer), + session: Session = Depends(get_session), +) -> dict[str, str]: + stake = session.get(EntityStake, stake_id) + if stake is None or stake.holder_entity_id != entity_id: + raise HTTPException(status_code=404, detail="Stake not found") + record_audit(session, user.id, "delete", "entity_stake", stake_id, { + "holder_entity_id": entity_id, + "fund_entity_id": stake.fund_entity_id, + }) + session.delete(stake) + session.commit() + return {"status": "deleted"} diff --git a/backend/ten31portal/routers/holding_router.py b/backend/ten31portal/routers/holding_router.py index c86aee7..9e313b8 100644 --- a/backend/ten31portal/routers/holding_router.py +++ b/backend/ten31portal/routers/holding_router.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException from sqlmodel import Session, select from ten31portal.audit import record_audit -from ten31portal.auth import get_current_user, require_writer +from ten31portal.auth import require_internal, require_writer from ten31portal.database import get_session from ten31portal.models import Entity, Holding, Position, User from ten31portal.schemas import HoldingCreate, HoldingResponse, HoldingUpdate @@ -15,7 +15,7 @@ router = APIRouter(tags=["holdings"]) @router.get("/api/entities/{entity_id}/holdings") def list_holdings( entity_id: int, - user: User = Depends(get_current_user), + user: User = Depends(require_internal), session: Session = Depends(get_session), ) -> list[HoldingResponse]: entity = session.get(Entity, entity_id) diff --git a/backend/ten31portal/routers/import_router.py b/backend/ten31portal/routers/import_router.py index bf23c4b..9f6326a 100644 --- a/backend/ten31portal/routers/import_router.py +++ b/backend/ten31portal/routers/import_router.py @@ -1,88 +1,32 @@ -"""CSV/XLSX import endpoints for entities and schedule of investments.""" +"""XLSX/CSV import of a fund's holdings and NAV from a fund-administrator eNAV pack. + +Reads the "HLD" (Holdings Report) sheet of an administrator eNAV workbook: each +security row becomes a holding + position, and its market value (book) becomes the +valuation for the quarter. Encrypted workbooks are decrypted with the open password. +A plain holdings CSV is also accepted. +""" import csv import io -import re from datetime import date, datetime from typing import Any +import msoffcrypto import openpyxl -from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile -from sqlmodel import Session, select +from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile +from sqlmodel import Session, select, col from ten31portal.audit import record_audit from ten31portal.auth import require_role from ten31portal.database import get_session from ten31portal.models import ( - Entity, EntityStatus, EntityType, Holding, Position, + Entity, EntityType, Holding, Position, User, UserRole, Valuation, ValuationRound, RoundStatus, ) router = APIRouter(prefix="/api/import", tags=["import"]) -# --- Column maps --- -# Confirmed against real Carta exports 2026-06-07. - -# Entity CSV import (Issue 9) — still needs a real entity-level export to confirm. -ENTITY_COLUMN_MAP: dict[str, str] = { - # UNCONFIRMED: update after inspecting a real Carta entities export - "Entity Name": "name", - "Entity Type": "type", - "Vintage Year": "vintage_year", - "Fund Size": "fund_size_cents", -} - -ENTITY_TYPE_MAP: dict[str, EntityType] = { - "Fund": EntityType.fund, - "fund": EntityType.fund, - "SPV": EntityType.spv, - "spv": EntityType.spv, - "GP": EntityType.gp, - "gp": EntityType.gp, - "Mgmt Co": EntityType.mgmt_co, - "mgmt_co": EntityType.mgmt_co, - "Management Company": EntityType.mgmt_co, -} - -# Schedule of Investments XLSX import (Issue 10) -# CONFIRMED against Carta export: "Low Time Preference Fund I, LLC" 2026-06-07 -# -# Carta XLSX layout: -# Row 1: Entity name (e.g. "Low Time Preference Fund I, LLC") -# Row 2: Metadata line ("As of MM/DD/YYYY • Generated by ...") -# Row 3: Empty -# Row 4: Headers -# Row 5: Empty -# Rows 6+: Data (company rows alternate with position rows, separated by empty rows) -# Last data row: "Total" summary -# -# Column mapping (0-indexed from Row 4 headers): -# A (0): "Investment" — company name on company/subtotal rows -# B (1): "Asset" — security name on position rows -# C (2): "Investment date" — datetime on position rows -# D (3): "Shares" — number (0 for SAFEs/membership interests) -# E (4): "Cost" — dollar amount (float) -# F (5): "Value" — dollar amount (float) -# G (6): "Last Valuation date" — datetime on position rows -# H (7): "Gain/Loss" — derived, skip -# I (8): "Cost per share" — derived, skip -# J (9): "FMV per share" — derived, skip -# K (10): "Percent of partners' capital" — skip (phase 2) -# -# Company rows: A has name, B is empty, D/E/F have subtotals -# Position rows: A is empty, B has security name, all columns populated -# SAFEs: shares = 0, cost per share = 0, FMV per share = 0 - -SCHEDULE_COLUMNS = { - "investment": 0, # A — company name - "asset": 1, # B — security name - "inv_date": 2, # C — investment date - "shares": 3, # D — share count - "cost": 4, # E — cost in dollars - "value": 5, # F — value in dollars - "val_date": 6, # G — last valuation date -} def _parse_money(raw: str) -> int | None: @@ -121,268 +65,304 @@ def _dollars_to_cents(val: float | int) -> int: return round(float(val) * 100) -# --- Entity import (CSV) --- +# --- eNAV holdings import (XLSX) --- -@router.post("/entities") -def import_entities( - file: UploadFile = File(...), - commit: bool = Query(default=True), - user: User = Depends(require_role(UserRole.approver, UserRole.cfo)), - session: Session = Depends(get_session), -) -> dict[str, Any]: - content = file.file.read().decode("utf-8-sig") - reader = csv.DictReader(io.StringIO(content)) - - results: list[dict] = [] - errors: list[dict] = [] - created = 0 - updated = 0 - - for i, row in enumerate(reader, start=2): - mapped: dict[str, Any] = {} - - for csv_col, model_field in ENTITY_COLUMN_MAP.items(): - val = row.get(csv_col) - if val is None: - for k, v in row.items(): - if k.strip().lower() == csv_col.lower(): - val = v - break - if val is not None: - mapped[model_field] = val.strip() - - parsed: dict[str, Any] = {} - row_errors: list[str] = [] - - name = mapped.get("name") - if not name: - row_errors.append("Missing entity name") - else: - parsed["name"] = name - - type_raw = mapped.get("type", "") - entity_type = ENTITY_TYPE_MAP.get(type_raw) - if entity_type is None and type_raw: - row_errors.append(f"Unknown entity type: {type_raw}") - elif entity_type: - parsed["type"] = entity_type - - vy = mapped.get("vintage_year") - if vy: - try: - parsed["vintage_year"] = int(vy) - except ValueError: - row_errors.append(f"Invalid vintage year: {vy}") - - fs = mapped.get("fund_size_cents") - if fs: - cents = _parse_money(fs) - if cents is None: - row_errors.append(f"Cannot parse fund size: {fs}") - else: - parsed["fund_size_cents"] = cents - - if row_errors: - errors.append({"row": i, "errors": row_errors, "raw": dict(row)}) - continue - - if not parsed.get("name"): - continue - - existing = session.exec(select(Entity).where(Entity.name == parsed["name"])).first() - action = "update" if existing else "create" - - results.append({ - "row": i, - "action": action, - "name": parsed["name"], - "type": parsed.get("type", EntityType.fund).value if parsed.get("type") else None, - "vintage_year": parsed.get("vintage_year"), - "fund_size_cents": parsed.get("fund_size_cents"), - }) - - if commit: - if existing: - if "type" in parsed: - existing.type = parsed["type"] - if "vintage_year" in parsed: - existing.vintage_year = parsed["vintage_year"] - if "fund_size_cents" in parsed: - existing.fund_size_cents = parsed["fund_size_cents"] - session.add(existing) - updated += 1 - else: - entity = Entity( - name=parsed["name"], - type=parsed.get("type", EntityType.fund), - vintage_year=parsed.get("vintage_year"), - fund_size_cents=parsed.get("fund_size_cents"), - ) - session.add(entity) - created += 1 - - if commit: - record_audit(session, user.id, "import_entities", "entity", None, { - "created": created, "updated": updated, "errors": len(errors), - }) - session.commit() - - return { - "committed": commit, - "preview": results, - "errors": errors, - "summary": {"created": created, "updated": updated, "error_rows": len(errors)}, - } +def _open_workbook(file_bytes: bytes, password: str | None): + """Load an xlsx workbook, decrypting an encrypted (password-protected) file if needed.""" + # A normal .xlsx is a zip ("PK"); an encrypted Office file is an OLE2 container. + if file_bytes[:2] == b"PK": + return openpyxl.load_workbook(io.BytesIO(file_bytes), data_only=True) + try: + office = msoffcrypto.OfficeFile(io.BytesIO(file_bytes)) + office.load_key(password=password or "VelvetSweatshop") + out = io.BytesIO() + office.decrypt(out) + out.seek(0) + return openpyxl.load_workbook(out, data_only=True) + except Exception: + if not password: + raise HTTPException( + status_code=400, + detail="This spreadsheet is password-protected. Enter the open password and try again.", + ) + raise HTTPException( + status_code=400, + detail="Could not open the spreadsheet. The password may be incorrect.", + ) -# --- Schedule of investments import (XLSX) --- +def _enav_as_of(wb) -> date | None: + """Find the report date, e.g. a 'DECEMBER 31, 2025' / 'AS OF ...' cell near the top.""" + for sheet in (["MENU", "HLD"] if "MENU" in wb.sheetnames else wb.sheetnames): + ws = wb[sheet] + for row in ws.iter_rows(min_row=1, max_row=6, max_col=2, values_only=True): + for cell in row: + if isinstance(cell, datetime): + return cell.date() + if isinstance(cell, date): + return cell + if isinstance(cell, str): + text = cell.replace("AS OF", "").strip() + for fmt in ("%B %d, %Y", "%b %d, %Y", "%m/%d/%Y"): + try: + return datetime.strptime(text, fmt).date() + except ValueError: + continue + return None -def _parse_schedule_xlsx(file_bytes: bytes) -> tuple[str | None, list[dict], list[dict], list[dict]]: + +def _parse_schedule_xlsx(file_bytes: bytes, password: str | None = None) -> tuple[str | None, date | None, list[dict], list[dict], list[dict]]: """ - Parse a Carta Schedule of Investments XLSX export. + Parse the HLD (Holdings Report) sheet of a fund-administrator eNAV workbook. - Returns: (entity_name, holdings_preview, positions_preview, errors) + Each security row becomes a holding (issuer) + position (security), with cost basis + and market value (book). Returns: (entity_name, as_of, holdings, positions, errors). """ - wb = openpyxl.load_workbook(io.BytesIO(file_bytes), data_only=True) - ws = wb.active + wb = _open_workbook(file_bytes, password) entity_name: str | None = None holdings_preview: list[dict] = [] positions_preview: list[dict] = [] errors: list[dict] = [] - # Row 1: entity name - row1_val = ws.cell(row=1, column=1).value - if row1_val: - entity_name = str(row1_val).strip() + # Fund name from the MENU cover sheet (row 2) when present. + if "MENU" in wb.sheetnames: + v = wb["MENU"].cell(row=2, column=1).value + if v: + entity_name = str(v).strip() - # Row 4: headers — validate - expected_headers = {0: "Investment", 1: "Asset", 4: "Cost", 5: "Value"} - for col_idx, expected in expected_headers.items(): - actual = ws.cell(row=4, column=col_idx + 1).value - if actual and str(actual).strip() != expected: - errors.append({ - "row": 4, - "errors": [f"Expected header '{expected}' in column {chr(65 + col_idx)}, got '{actual}'"], - }) + as_of = _enav_as_of(wb) + + if "HLD" not in wb.sheetnames: + errors.append({"row": 0, "errors": [ + "No 'HLD' (Holdings Report) sheet found. This does not look like an eNAV workbook." + ]}) + return entity_name, as_of, holdings_preview, positions_preview, errors + + ws = wb["HLD"] + rows = list(ws.iter_rows(min_row=1, max_row=ws.max_row, max_col=20, values_only=True)) + + # Locate the header row (the one whose first cell is "SECURITY NAME"). + header_idx = None + for i, row in enumerate(rows): + if row and isinstance(row[0], str) and row[0].strip().upper() == "SECURITY NAME": + header_idx = i + break + if header_idx is None: + errors.append({"row": 0, "errors": ["Could not find the holdings header row in the HLD sheet."]}) + return entity_name, as_of, holdings_preview, positions_preview, errors + + header = [str(c).strip().upper() if isinstance(c, str) else "" for c in rows[header_idx]] + + def col(*names: str) -> int | None: + for n in names: + if n in header: + return header.index(n) + return None + + c_name = col("SECURITY NAME") + c_qty = col("QUANTITY") + c_cost = col("COST BASIS - BOOK", "COST BASIS - LOCAL") + c_value = col("MARKET VALUE (BOOK)", "MARKET VALUE (LOCAL)", "MARKET VALUE - BOOK") + + if c_value is None or c_name is None: + errors.append({"row": header_idx + 1, "errors": [ + "HLD sheet is missing a SECURITY NAME or MARKET VALUE column." + ]}) + return entity_name, as_of, holdings_preview, positions_preview, errors - # Parse data rows (starting at row 6) - current_company: str | None = None seen_companies: set[str] = set() - # Track (company, security) occurrences to disambiguate duplicate tranches - security_counts: dict[tuple[str, str], int] = {} - - for row_idx in range(6, ws.max_row + 1): - col_a = ws.cell(row=row_idx, column=1).value # Investment (company) - col_b = ws.cell(row=row_idx, column=2).value # Asset (security) - col_c = ws.cell(row=row_idx, column=3).value # Investment date - col_d = ws.cell(row=row_idx, column=4).value # Shares - col_e = ws.cell(row=row_idx, column=5).value # Cost - col_f = ws.cell(row=row_idx, column=6).value # Value - col_g = ws.cell(row=row_idx, column=7).value # Last valuation date - - # Skip empty rows - if col_a is None and col_b is None: + for r in range(header_idx + 1, len(rows)): + row = rows[r] + raw_name = row[c_name] if c_name < len(row) else None + if raw_name is None or not str(raw_name).strip(): + continue + name = str(raw_name).strip() + # Stop at total / report-total summary rows. + if name.upper().startswith("TOTAL") or name.upper().startswith("REPORT TOTAL"): continue - # Skip total row - if col_a and str(col_a).strip().lower() == "total": + # Group tranches of the same issuer: holding = issuer (before " - "); position = full name. + company = name.split(" - ")[0].strip() or name + security = name + + if company not in seen_companies: + holdings_preview.append({"row": r + 1, "company_name": company}) + seen_companies.add(company) + + # Quantity -> shares (skip non-numeric like "N/A"). + shares: str | None = None + if c_qty is not None and c_qty < len(row) and row[c_qty] is not None: + qv = row[c_qty] + if isinstance(qv, (int, float)) and qv != 0: + shares = f"{qv:g}" + + cost_cents = None + if c_cost is not None and c_cost < len(row) and isinstance(row[c_cost], (int, float)): + cost_cents = _dollars_to_cents(row[c_cost]) + + value_cents = None + if isinstance(row[c_value], (int, float)): + value_cents = _dollars_to_cents(row[c_value]) + + positions_preview.append({ + "row": r + 1, + "company_name": company, + "security_name": security, + "investment_date": None, # eNAV holdings report has no acquisition date + "shares": shares, + "cost_cents": cost_cents, + "value_cents": value_cents, + "valuation_date": str(as_of) if as_of else None, + }) + + return entity_name, as_of, holdings_preview, positions_preview, errors + + + + +def reset_entity_holdings(entity_id: int, session: Session) -> dict[str, int]: + """Delete all holdings, positions, valuations, and rounds for one entity. + + For a clean restart — e.g. after switching the source workbook (Carta → eNAV) renamed every + position, so the old and new rows can't be matched and both get counted. Capital-account + statements (investor data) are left untouched. The caller should re-import afterwards. + """ + rounds = session.exec( + select(ValuationRound).where(ValuationRound.entity_id == entity_id) + ).all() + round_ids = [r.id for r in rounds] + valuations = 0 + if round_ids: + for v in session.exec( + select(Valuation).where(col(Valuation.round_id).in_(round_ids)) + ).all(): + session.delete(v) + valuations += 1 + + holdings = session.exec(select(Holding).where(Holding.entity_id == entity_id)).all() + holding_ids = [h.id for h in holdings] + positions = 0 + if holding_ids: + for p in session.exec( + select(Position).where(col(Position.holding_id).in_(holding_ids)) + ).all(): + session.delete(p) + positions += 1 + + for r in rounds: + session.delete(r) + for h in holdings: + session.delete(h) + session.flush() + return { + "rounds": len(rounds), + "holdings": len(holdings), + "positions": positions, + "valuations": valuations, + } + + +def dedupe_entity(entity_id: int, session: Session) -> dict[str, int]: + """Collapse exact-duplicate holdings and positions for one entity. + + Repeated imports on older builds created a second copy of each holding/position, which + inflated the entity's "Invested" total (the rollup sums cost across every position). This + keeps the lowest-id copy, re-points its positions/valuations, removes duplicate valuations + in the same round, and deletes the leftovers. Safe to run repeatedly (a no-op once clean). + """ + removed_holdings = 0 + removed_positions = 0 + + # 1) Merge holdings with the same name (case-insensitive) into the lowest-id one. + holdings = session.exec( + select(Holding).where(Holding.entity_id == entity_id).order_by(Holding.id) # type: ignore[arg-type] + ).all() + keep_by_name: dict[str, Holding] = {} + for h in holdings: + key = h.company_name.strip().lower() + keeper = keep_by_name.get(key) + if keeper is None: + keep_by_name[key] = h continue + for p in session.exec(select(Position).where(Position.holding_id == h.id)).all(): + p.holding_id = keeper.id + session.add(p) + session.flush() + session.delete(h) + removed_holdings += 1 + session.flush() - # Company subtotal row: col A has name, col B is empty - if col_a and not col_b: - company_name = str(col_a).strip() - current_company = company_name - if company_name not in seen_companies: - holdings_preview.append({ - "row": row_idx, - "company_name": company_name, - }) - seen_companies.add(company_name) - continue + # 2) Within each surviving holding, merge positions with the same security name. + for keeper_holding in keep_by_name.values(): + positions = session.exec( + select(Position) + .where(Position.holding_id == keeper_holding.id) + .order_by(Position.id) # type: ignore[arg-type] + ).all() + keep_by_sec: dict[str, Position] = {} + for p in positions: + key = p.security_name.strip().lower() + keeper = keep_by_sec.get(key) + if keeper is None: + keep_by_sec[key] = p + continue + # Move this duplicate's valuations onto the keeper, dropping any that would collide + # with an existing valuation for the same round (that collision IS the double-count). + for v in session.exec(select(Valuation).where(Valuation.position_id == p.id)).all(): + clash = session.exec( + select(Valuation).where( + Valuation.round_id == v.round_id, + Valuation.position_id == keeper.id, + ) + ).first() + if clash is not None: + session.delete(v) + else: + v.position_id = keeper.id + session.add(v) + session.flush() + session.delete(p) + removed_positions += 1 + session.flush() - # Position row: col B has security name - if col_b: - raw_security_name = str(col_b).strip() - company = current_company or "(unknown)" - - # Disambiguate duplicate (company, security) pairs - # e.g. two "Warrants" under BIP21 become "Warrants" and "Warrants (2)" - pair_key = (company, raw_security_name) - security_counts[pair_key] = security_counts.get(pair_key, 0) + 1 - if security_counts[pair_key] == 1: - security_name = raw_security_name - else: - security_name = f"{raw_security_name} ({security_counts[pair_key]})" - - # Parse investment date - inv_date: date | None = None - if isinstance(col_c, datetime): - inv_date = col_c.date() - elif col_c: - inv_date = _parse_date(str(col_c)) - - # Parse shares (could be 0 for SAFEs) - shares: str | None = None - if col_d is not None: - shares_val = float(col_d) - if shares_val != 0: - # Preserve precision: use string repr - shares = str(col_d) if not isinstance(col_d, float) else f"{col_d:g}" - # shares stays None for zero (SAFEs) - - # Parse cost (dollars) - cost_cents: int | None = None - if col_e is not None: - cost_cents = _dollars_to_cents(col_e) - - # Parse value (dollars) - value_cents: int | None = None - if col_f is not None: - value_cents = _dollars_to_cents(col_f) - - # Parse valuation date - val_date: date | None = None - if isinstance(col_g, datetime): - val_date = col_g.date() - elif col_g: - val_date = _parse_date(str(col_g)) - - positions_preview.append({ - "row": row_idx, - "company_name": company, - "security_name": security_name, - "investment_date": str(inv_date) if inv_date else None, - "shares": shares, - "cost_cents": cost_cents, - "value_cents": value_cents, - "valuation_date": str(val_date) if val_date else None, - }) - - return entity_name, holdings_preview, positions_preview, errors + return {"removed_holdings": removed_holdings, "removed_positions": removed_positions} @router.post("/schedule") def import_schedule( file: UploadFile = File(...), - as_of: date = Query(...), + as_of: date | None = Query(default=None), entity_id: int | None = Query(default=None), create_entity_type: EntityType = Query(default=EntityType.fund), create_vintage_year: int | None = Query(default=None), commit: bool = Query(default=True), - user: User = Depends(require_role(UserRole.approver, UserRole.cfo)), + replace_existing: bool = Query(default=False), + password: str | None = Form(default=None), + user: User = Depends(require_role(UserRole.approver, UserRole.cfo, UserRole.operations)), session: Session = Depends(get_session), ) -> dict[str, Any]: file_bytes = file.file.read() filename = file.filename or "" - # Parse file first to get source_entity_name - if filename.endswith(".xlsx") or filename.endswith(".xls"): - source_entity_name, holdings_preview, positions_preview, errors = _parse_schedule_xlsx(file_bytes) + # Parse the file into holdings/positions previews. + if filename.lower().endswith((".xlsx", ".xls")): + source_entity_name, parsed_as_of, holdings_preview, positions_preview, errors = ( + _parse_schedule_xlsx(file_bytes, password) + ) else: source_entity_name = None + parsed_as_of = None holdings_preview, positions_preview, errors = _parse_schedule_csv(file_bytes) + # As-of date: use the explicit value, else the date read from the sheet. + as_of = as_of or parsed_as_of + if as_of is None: + raise HTTPException( + status_code=400, + detail="Could not determine the quarter-end date. Set the as-of date and try again.", + ) + # Resolve entity entity: Entity | None = None entity_resolution: str = "existing" # "existing" | "matched" | "will_create" @@ -405,7 +385,7 @@ def import_schedule( else: raise HTTPException( status_code=400, - detail="No entity_id provided and the file has no entity name in row 1. Provide entity_id or upload a Carta XLSX with the fund name.", + detail="No entity selected and no fund name found in the file. Choose the fund to import into.", ) # Dry-run: report resolution without writing @@ -447,19 +427,36 @@ def import_schedule( assert entity is not None resolved_entity_id = entity.id - # Check for any existing round at this quarter + # Replace mode: wipe the fund's existing holdings/positions/rounds first so the file becomes + # the single source of truth. Use this after a source change (e.g. Carta → eNAV) renamed the + # positions, leaving un-matchable rows that inflate the totals. + if replace_existing: + reset_entity_holdings(resolved_entity_id, session) + + # Self-heal any duplicate holdings/positions left by imports on older builds, so this + # import's upsert lands on a single clean copy and "Invested" stops double-counting. + dedupe_entity(resolved_entity_id, session) + + # An existing round at this quarter: a NAV re-import should UPDATE it in place (refresh + # to the latest file) instead of stacking a second round and doubling the totals. Only + # import-created seed rounds are refreshable; a manually-signed valuation round is left + # protected. existing_round = session.exec( select(ValuationRound).where( ValuationRound.entity_id == resolved_entity_id, ValuationRound.quarter_end == as_of, ) ).first() - if existing_round: - kind = "seed" if existing_round.is_seed else "valuation" - raise HTTPException( - status_code=409, - detail=f"A {kind} round already exists for this entity and quarter ({as_of}). Cannot overwrite signed history.", - ) + reused_round = False + seed_round: ValuationRound | None = None + if existing_round is not None: + if not existing_round.is_seed: + raise HTTPException( + status_code=409, + detail=f"A signed valuation round already exists for this entity and quarter ({as_of}). Cannot overwrite signed history.", + ) + seed_round = existing_round + reused_round = True # Commit: create holdings, positions, seed round holding_map: dict[str, Holding] = {} @@ -480,20 +477,22 @@ def import_schedule( session.flush() holding_map[name] = h - # Create seed round - seed_round = ValuationRound( - entity_id=resolved_entity_id, - quarter_end=as_of, - status=RoundStatus.approved, - is_seed=True, - approved_by=user.id, - approved_at=datetime.utcnow(), - ) - session.add(seed_round) - session.flush() + # Create the seed round, or reuse the existing one for this quarter (re-import). + if seed_round is None: + seed_round = ValuationRound( + entity_id=resolved_entity_id, + quarter_end=as_of, + status=RoundStatus.approved, + is_seed=True, + approved_by=user.id, + approved_at=datetime.utcnow(), + ) + session.add(seed_round) + session.flush() positions_created = 0 positions_updated = 0 + seen_position_ids: set[int] = set() for pp in positions_preview: company = pp["company_name"] holding = holding_map.get(company) @@ -541,17 +540,39 @@ def import_schedule( session.flush() positions_updated += 1 - # Attach valuation to seed round - val = Valuation( - round_id=seed_round.id, - position_id=pos.id, - value_cents=pp["value_cents"] or 0, - ) - session.add(val) + # Upsert this position's valuation in the round, so a re-import refreshes the value + # in place instead of adding a second one (which would double the quarter's NAV). + val = session.exec( + select(Valuation).where( + Valuation.round_id == seed_round.id, + Valuation.position_id == pos.id, + ) + ).first() + if val is None: + session.add(Valuation( + round_id=seed_round.id, + position_id=pos.id, + value_cents=pp["value_cents"] or 0, + )) + else: + val.value_cents = pp["value_cents"] or 0 + session.add(val) + seen_position_ids.add(pos.id) + + # On re-import, drop valuations for holdings no longer in the file so the quarter's NAV + # equals the new file's total (no leftovers from the prior import). + if reused_round: + stale_q = select(Valuation).where(Valuation.round_id == seed_round.id) + if seen_position_ids: + stale_q = stale_q.where(col(Valuation.position_id).not_in(seen_position_ids)) + for stale_val in session.exec(stale_q).all(): + session.delete(stale_val) record_audit(session, user.id, "import_schedule", "entity", resolved_entity_id, { "source_entity_name": source_entity_name, "entity_resolution": entity_resolution, + "replaced_existing": replace_existing, + "round_updated": reused_round, "holdings": len(holding_map), "positions_created": positions_created, "positions_updated": positions_updated, @@ -573,25 +594,35 @@ def import_schedule( "positions_created": positions_created, "positions_updated": positions_updated, "seed_round_id": seed_round.id, + "round_updated": reused_round, + "replaced_existing": replace_existing, "errors": errors, } def _parse_schedule_csv(file_bytes: bytes) -> tuple[list[dict], list[dict], list[dict]]: - """Legacy CSV parser fallback.""" + """Parse a plain holdings CSV (generic columns or an eNAV HLD export).""" content = file_bytes.decode("utf-8-sig") reader = csv.DictReader(io.StringIO(content)) CSV_COLUMN_MAP = { "Company": "company_name", "Investment": "company_name", + "Issuer": "company_name", "Security": "security_name", "Asset": "security_name", + "Security Name": "security_name", "Investment Date": "investment_date", "Investment date": "investment_date", "Shares": "shares", + "Quantity": "shares", "Cost": "cost_cents", + "Cost Basis - Book": "cost_cents", + "Cost Basis - Local": "cost_cents", "Value": "value_cents", + "Market Value (Book)": "value_cents", + "Market Value (Local)": "value_cents", + "Market Value - Book": "value_cents", } holdings_preview: list[dict] = [] @@ -608,11 +639,18 @@ def _parse_schedule_csv(file_bytes: bytes) -> tuple[list[dict], list[dict], list if k.strip().lower() == csv_col.lower(): val = v break - if val is not None: - mapped[model_field] = val.strip() + if val is not None and model_field not in mapped: + mapped[model_field] = (val or "").strip() company = mapped.get("company_name", "").strip() security = mapped.get("security_name", "").strip() + # Skip total / summary rows. + if security.upper().startswith("TOTAL") or company.upper().startswith("TOTAL"): + continue + # eNAV-style rows have only a security name; derive the issuer from its prefix. + if security and not company: + company = security.split(" - ")[0].strip() + mapped["company_name"] = company if company and not security: current_company = company diff --git a/backend/ten31portal/routers/position_router.py b/backend/ten31portal/routers/position_router.py index 66513e8..208cb60 100644 --- a/backend/ten31portal/routers/position_router.py +++ b/backend/ten31portal/routers/position_router.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException from sqlmodel import Session, select from ten31portal.audit import record_audit -from ten31portal.auth import get_current_user, require_writer +from ten31portal.auth import require_internal, require_writer from ten31portal.database import get_session from ten31portal.models import Holding, Position, Valuation, ValuationRound, RoundStatus, User from ten31portal.schemas import PositionCreate, PositionResponse, PositionUpdate @@ -22,7 +22,7 @@ def _dollars_to_cents(dollars: float) -> int: @router.get("/api/holdings/{holding_id}/positions") def list_positions( holding_id: int, - user: User = Depends(get_current_user), + user: User = Depends(require_internal), session: Session = Depends(get_session), ) -> list[PositionResponse]: holding = session.get(Holding, holding_id) diff --git a/backend/ten31portal/routers/round_router.py b/backend/ten31portal/routers/round_router.py index 2d51350..b0fa25d 100644 --- a/backend/ten31portal/routers/round_router.py +++ b/backend/ten31portal/routers/round_router.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException from sqlmodel import Session, select, col from ten31portal.audit import record_audit -from ten31portal.auth import get_current_user, require_writer, require_approver +from ten31portal.auth import require_internal, require_writer, require_approver from ten31portal.database import get_session from ten31portal.models import ( Entity, Holding, Position, Valuation, ValuationRound, @@ -30,7 +30,7 @@ def _round_response(round: ValuationRound, session: Session) -> RoundResponse: @router.get("/api/entities/{entity_id}/rounds") def list_rounds( entity_id: int, - user: User = Depends(get_current_user), + user: User = Depends(require_internal), session: Session = Depends(get_session), ) -> list[RoundResponse]: entity = session.get(Entity, entity_id) @@ -47,7 +47,7 @@ def list_rounds( @router.get("/api/rounds/{round_id}") def get_round( round_id: int, - user: User = Depends(get_current_user), + user: User = Depends(require_internal), session: Session = Depends(get_session), ) -> RoundResponse: round = session.get(ValuationRound, round_id) diff --git a/backend/ten31portal/routers/user_router.py b/backend/ten31portal/routers/user_router.py new file mode 100644 index 0000000..9c9c89a --- /dev/null +++ b/backend/ten31portal/routers/user_router.py @@ -0,0 +1,375 @@ +"""User administration: create and manage accounts and their entity access.""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, select, col + +from ten31portal.audit import record_audit +from ten31portal.auth import ( + accessible_entity_ids, can_access_entity, get_current_user, hash_password, + household_user_ids, require_internal_admin, +) +from ten31portal.database import get_session +from ten31portal.models import ( + CapitalAccountStatement, Document, Entity, EntityAccess, EXTERNAL_ROLES, User, UserRole, +) +from ten31portal.schemas import ( + AccessGrant, AccessMatrixResponse, AccountLink, CapitalAccountResponse, + DocumentResponse, EntityResponse, InvestorViewResponse, LinkedAccount, PasswordReset, + UserCreate, UserDetailResponse, UserResponse, UserUpdate, +) + +router = APIRouter(prefix="/api/users", tags=["users"]) + + +@router.get("/access-matrix") +def access_matrix( + admin: User = Depends(require_internal_admin), + session: Session = Depends(get_session), +) -> AccessMatrixResponse: + """External accounts, all entities, and the grants linking them.""" + users = session.exec( + select(User).where(col(User.role).in_(EXTERNAL_ROLES)).order_by(User.name) # type: ignore[arg-type] + ).all() + entities = session.exec(select(Entity).order_by(Entity.name)).all() # type: ignore[arg-type] + grants = session.exec(select(EntityAccess)).all() + return AccessMatrixResponse( + users=[UserResponse.model_validate(u, from_attributes=True) for u in users], + entities=[EntityResponse.model_validate(e, from_attributes=True) for e in entities], + grants=[AccessGrant(user_id=g.user_id, entity_id=g.entity_id) for g in grants], + ) + + +@router.put("/{user_id}/access/{entity_id}", status_code=200) +def grant_access( + user_id: int, + entity_id: int, + admin: User = Depends(require_internal_admin), + session: Session = Depends(get_session), +) -> dict[str, str]: + user = session.get(User, user_id) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + if session.get(Entity, entity_id) is None: + raise HTTPException(status_code=404, detail="Entity not found") + existing = session.exec( + select(EntityAccess).where( + EntityAccess.user_id == user_id, EntityAccess.entity_id == entity_id + ) + ).first() + if existing is None: + session.add(EntityAccess(user_id=user_id, entity_id=entity_id)) + record_audit(session, admin.id, "grant_access", "user", user_id, {"entity_id": entity_id}) + session.commit() + return {"status": "ok"} + + +@router.delete("/{user_id}/access/{entity_id}") +def revoke_access( + user_id: int, + entity_id: int, + admin: User = Depends(require_internal_admin), + session: Session = Depends(get_session), +) -> dict[str, str]: + existing = session.exec( + select(EntityAccess).where( + EntityAccess.user_id == user_id, EntityAccess.entity_id == entity_id + ) + ).first() + if existing is not None: + session.delete(existing) + record_audit(session, admin.id, "revoke_access", "user", user_id, {"entity_id": entity_id}) + session.commit() + return {"status": "ok"} + + +@router.get("/investors-for-entity/{entity_id}") +def investors_for_entity( + entity_id: int, + user: User = Depends(get_current_user), + session: Session = Depends(get_session), +) -> list[UserResponse]: + """Investor accounts with access to an entity. For internal staff and the entity's fund admins.""" + if user.role not in (UserRole.approver, UserRole.cfo, UserRole.operations) and not ( + user.role == UserRole.fund_administrator + and can_access_entity(user, entity_id, session) + ): + raise HTTPException(status_code=403, detail="Insufficient permissions") + rows = session.exec( + select(User) + .join(EntityAccess, EntityAccess.user_id == User.id) + .where(EntityAccess.entity_id == entity_id, User.role == UserRole.investor) + .order_by(User.name) # type: ignore[arg-type] + ).all() + return [UserResponse.model_validate(r, from_attributes=True) for r in rows] + + +@router.get("/{user_id}/investor-view") +def investor_view( + user_id: int, + admin: User = Depends(require_internal_admin), + session: Session = Depends(get_session), +) -> InvestorViewResponse: + """Reconstruct exactly what an investor sees in their portal — read-only, for admins. + + No session impersonation: this returns the same data the investor's own portal would load + (their accessible entities, their capital statements, and the documents visible to them), + scoped with the same access helpers. + """ + target = session.get(User, user_id) + if target is None: + raise HTTPException(status_code=404, detail="User not found") + if target.role != UserRole.investor: + raise HTTPException(status_code=400, detail="Investor View is for investor accounts.") + + allowed = accessible_entity_ids(target, session) or set() + household = household_user_ids(target, session) + + entities = session.exec( + select(Entity).where(col(Entity.id).in_(allowed)).order_by(Entity.name) # type: ignore[arg-type] + ).all() if allowed else [] + + caps: list[CapitalAccountResponse] = [] + docs: list[DocumentResponse] = [] + if allowed: + cap_rows = session.exec( + select(CapitalAccountStatement) + .where( + col(CapitalAccountStatement.investor_user_id).in_(household), + col(CapitalAccountStatement.entity_id).in_(allowed), + ) + .order_by(col(CapitalAccountStatement.as_of_date).desc()) + ).all() + names = dict(session.exec( + select(User.id, User.name).where( + col(User.id).in_({r.investor_user_id for r in cap_rows}) + ) + ).all()) if cap_rows else {} + for r in cap_rows: + d = CapitalAccountResponse.model_validate(r, from_attributes=True) + d.investor_name = names.get(r.investor_user_id) + caps.append(d) + + doc_rows = session.exec( + select(Document) + .where(col(Document.entity_id).in_(allowed)) + .order_by(col(Document.created_at).desc()) + ).all() + # Investor sees shared docs and those addressed to any of their linked names. + docs = [ + DocumentResponse.model_validate(d, from_attributes=True) + for d in doc_rows + if d.investor_user_id is None or d.investor_user_id in household + ] + + return InvestorViewResponse( + user=UserResponse.model_validate(target, from_attributes=True), + entities=[EntityResponse.model_validate(e, from_attributes=True) for e in entities], + capital_accounts=caps, + documents=docs, + ) + + +def _entity_ids_for(user_id: int, session: Session) -> list[int]: + return list(session.exec( + select(EntityAccess.entity_id).where(EntityAccess.user_id == user_id) + ).all()) + + +def _user_detail(user: User, session: Session) -> UserDetailResponse: + """Build a full user detail, including the linked-account relationships.""" + data = UserResponse.model_validate(user, from_attributes=True).model_dump() + primary_name = None + if user.primary_account_id: + primary = session.get(User, user.primary_account_id) + primary_name = primary.name if primary else None + linked = session.exec( + select(User).where(User.primary_account_id == user.id).order_by(User.name) # type: ignore[arg-type] + ).all() + return UserDetailResponse( + **data, + primary_account_name=primary_name, + linked_accounts=[ + LinkedAccount(id=u.id, name=u.name, username=u.username) for u in linked + ], + entity_ids=_entity_ids_for(user.id, session), + ) + + +def _set_entity_access(user_id: int, entity_ids: list[int], session: Session) -> None: + """Replace a user's entity grants with the given set, ignoring unknown ids.""" + valid = set(session.exec( + select(Entity.id).where(Entity.id.in_(entity_ids)) # type: ignore[union-attr] + ).all()) if entity_ids else set() + existing = session.exec( + select(EntityAccess).where(EntityAccess.user_id == user_id) + ).all() + current = {a.entity_id: a for a in existing} + # Remove grants no longer wanted. + for eid, access in current.items(): + if eid not in valid: + session.delete(access) + # Add new grants. + for eid in valid: + if eid not in current: + session.add(EntityAccess(user_id=user_id, entity_id=eid)) + + +@router.get("") +def list_users( + admin: User = Depends(require_internal_admin), + session: Session = Depends(get_session), +) -> list[UserResponse]: + rows = session.exec(select(User).order_by(User.name)).all() # type: ignore[arg-type] + return [UserResponse.model_validate(r, from_attributes=True) for r in rows] + + +@router.get("/{user_id}") +def get_user( + user_id: int, + admin: User = Depends(require_internal_admin), + session: Session = Depends(get_session), +) -> UserDetailResponse: + user = session.get(User, user_id) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + return _user_detail(user, session) + + +@router.post("", status_code=201) +def create_user( + body: UserCreate, + admin: User = Depends(require_internal_admin), + session: Session = Depends(get_session), +) -> UserDetailResponse: + if session.exec(select(User).where(User.username == body.username)).first(): + raise HTTPException(status_code=409, detail="Username already taken") + if body.email and session.exec(select(User).where(User.email == body.email)).first(): + raise HTTPException(status_code=409, detail="Email already in use") + + user = User( + name=body.name, + username=body.username, + email=body.email or None, + password_hash=hash_password(body.password), + role=body.role, + ) + session.add(user) + session.flush() + _set_entity_access(user.id, body.entity_ids, session) + record_audit(session, admin.id, "create", "user", user.id, + {"username": body.username, "role": body.role.value, + "entity_ids": body.entity_ids}) + session.commit() + session.refresh(user) + return _user_detail(user, session) + + +@router.patch("/{user_id}") +def update_user( + user_id: int, + body: UserUpdate, + admin: User = Depends(require_internal_admin), + session: Session = Depends(get_session), +) -> UserDetailResponse: + user = session.get(User, user_id) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + + changes = body.model_dump(exclude_unset=True) + entity_ids = changes.pop("entity_ids", None) + + if "username" in changes: + new_username = (changes["username"] or "").strip() + if not new_username: + raise HTTPException(status_code=400, detail="Username cannot be blank") + clash = session.exec(select(User).where(User.username == new_username)).first() + if clash and clash.id != user_id: + raise HTTPException(status_code=409, detail="Username already taken") + changes["username"] = new_username + + if "email" in changes and changes["email"]: + clash = session.exec(select(User).where(User.email == changes["email"])).first() + if clash and clash.id != user_id: + raise HTTPException(status_code=409, detail="Email already in use") + + for key, val in changes.items(): + setattr(user, key, val) + session.add(user) + session.flush() + + if entity_ids is not None: + _set_entity_access(user_id, entity_ids, session) + + record_audit(session, admin.id, "update", "user", user_id, + {**changes, **({"entity_ids": entity_ids} if entity_ids is not None else {})}) + session.commit() + session.refresh(user) + return _user_detail(user, session) + + +@router.put("/{user_id}/primary-account") +def link_account( + user_id: int, + body: AccountLink, + admin: User = Depends(require_internal_admin), + session: Session = Depends(get_session), +) -> UserDetailResponse: + """Link an investor account to a primary login (or detach it when null). + + The primary becomes the single sign-on that sees every linked name's investments. The + linked account's own login is disabled so there is one set of credentials per person. + """ + user = session.get(User, user_id) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + if user.role != UserRole.investor: + raise HTTPException(status_code=400, detail="Only investor accounts can be linked.") + + primary_id = body.primary_account_id + if primary_id is not None: + if primary_id == user_id: + raise HTTPException(status_code=400, detail="An account cannot link to itself.") + primary = session.get(User, primary_id) + if primary is None or primary.role != UserRole.investor: + raise HTTPException(status_code=400, detail="Primary must be an investor account.") + if primary.primary_account_id is not None: + raise HTTPException( + status_code=400, + detail="That account is itself linked to another login. Link to a primary instead.", + ) + # Prevent chains: an account that other names log in under can't become a secondary. + if session.exec(select(User).where(User.primary_account_id == user_id)).first(): + raise HTTPException( + status_code=400, + detail="This account is a primary for other names. Detach those first.", + ) + # Login is blocked while primary_account_id is set (see auth_router.login), so we leave + # login_enabled untouched — unlinking then restores the account's own sign-in cleanly. + user.primary_account_id = primary_id + else: + user.primary_account_id = None + + session.add(user) + record_audit(session, admin.id, "link_account", "user", user_id, + {"primary_account_id": primary_id}) + session.commit() + session.refresh(user) + return _user_detail(user, session) + + +@router.post("/{user_id}/reset-password") +def reset_password( + user_id: int, + body: PasswordReset, + admin: User = Depends(require_internal_admin), + session: Session = Depends(get_session), +) -> dict[str, str]: + user = session.get(User, user_id) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + user.password_hash = hash_password(body.password) + user.login_enabled = True # setting a password enables login + session.add(user) + record_audit(session, admin.id, "reset_password", "user", user_id, None) + session.commit() + return {"status": "ok"} diff --git a/backend/ten31portal/schemas.py b/backend/ten31portal/schemas.py index a5a58c9..1c2de97 100644 --- a/backend/ten31portal/schemas.py +++ b/backend/ten31portal/schemas.py @@ -5,25 +5,85 @@ from typing import Optional from pydantic import BaseModel -from ten31portal.models import UserRole, EntityType, EntityStatus, RoundStatus +from ten31portal.models import ( + UserRole, EntityType, EntityStatus, RoundStatus, DocumentCategory, +) # --- Auth --- class LoginRequest(BaseModel): - email: str + login: str # username or email password: str class UserResponse(BaseModel): id: int name: str - email: str + username: str + email: str | None role: UserRole is_active: bool + is_service_admin: bool = False + primary_account_id: int | None = None # set when this account logs in under another created_at: datetime +# --- User administration --- + +class UserCreate(BaseModel): + name: str + username: str + password: str + role: UserRole + email: str | None = None + entity_ids: list[int] = [] + + +class UserUpdate(BaseModel): + name: str | None = None + username: str | None = None + email: str | None = None + role: UserRole | None = None + is_active: bool | None = None + entity_ids: list[int] | None = None # full replacement of grants when provided + + +class PasswordReset(BaseModel): + password: str + + +class ChangePasswordRequest(BaseModel): + current_password: str + new_password: str + + +class LinkedAccount(BaseModel): + id: int + name: str + username: str + + +class UserDetailResponse(BaseModel): + id: int + name: str + username: str + email: str | None + role: UserRole + is_active: bool + is_service_admin: bool = False + primary_account_id: int | None = None + primary_account_name: str | None = None # the login this account is linked under, if any + linked_accounts: list[LinkedAccount] = [] # secondary names that log in under this account + created_at: datetime + entity_ids: list[int] = [] + + +class AccountLink(BaseModel): + # null detaches the account so it logs in on its own again + primary_account_id: int | None = None + + # --- Entity --- class EntityCreate(BaseModel): @@ -136,6 +196,157 @@ class RoundResponse(BaseModel): valuations: list[ValuationResponse] = [] +# --- Document --- + +class DocumentResponse(BaseModel): + id: int + entity_id: int + investor_user_id: int | None + category: DocumentCategory + title: str + original_filename: str + content_type: str + size_bytes: int + uploaded_by: int | None + created_at: datetime + + +# --- Capital account --- + +class CapitalAccountCreate(BaseModel): + entity_id: int + investor_user_id: int + as_of_date: date + commitment_dollars: float = 0 + beginning_balance_dollars: float = 0 + contributions_dollars: float = 0 + distributions_dollars: float = 0 + ending_balance_dollars: float = 0 + document_id: int | None = None + + +class CapitalAccountResponse(BaseModel): + id: int + entity_id: int + investor_user_id: int + investor_name: str | None = None # legal name of the account this statement belongs to + as_of_date: date + commitment_cents: int + beginning_balance_cents: int + contributions_cents: int + distributions_cents: int + ending_balance_cents: int + document_id: int | None + created_at: datetime + + +# --- Partners (members of an entity) --- + +class PartnerResponse(BaseModel): + user_id: int + name: str + username: str + external_investor_id: str | None + is_active: bool + login_enabled: bool + latest_commitment_cents: int | None = None + latest_contributions_cents: int | None = None + latest_distributions_cents: int | None = None + latest_value_cents: int | None + latest_as_of: date | None + statements_count: int + + +# --- Access matrix --- + +class AccessGrant(BaseModel): + user_id: int + entity_id: int + + +class AccessMatrixResponse(BaseModel): + users: list[UserResponse] + entities: list[EntityResponse] + grants: list[AccessGrant] + + +# --- Capital account import (review-and-confirm) --- + +class ImportInvestorPreview(BaseModel): + source_name: str # name as it appears in the spreadsheet + column_index: int # 0-based column it was read from + value_dollars: float # current capital value (ending balance) + commitment_dollars: float = 0 + contributions_dollars: float = 0 + distributions_dollars: float = 0 + external_id: str | None = None # fund-admin INVESTOR ID, when present + matched_user_id: int | None = None + matched_username: str | None = None + suggested_username: str | None = None # for unmatched: a safe default + + +class ImportValueRow(BaseModel): + row_index: int # 0-based sheet row + label: str # col A label (e.g. "LTPF1") + + +class CapitalImportPreview(BaseModel): + as_of_date: date | None + value_rows: list[ImportValueRow] # candidate rows holding per-investor balances + chosen_row_index: int # the row used for the values below + investors: list[ImportInvestorPreview] + + +class ImportCommitInvestor(BaseModel): + action: str # "match" | "create" | "skip" + value_dollars: float # current capital value (ending balance) + commitment_dollars: float = 0 + contributions_dollars: float = 0 + distributions_dollars: float = 0 + user_id: int | None = None # for action=match + name: str | None = None # for action=create + username: str | None = None # for action=create + email: str | None = None + password: str | None = None # for action=create; omit to create without a login + external_id: str | None = None # fund-admin INVESTOR ID, stored for re-import matching + + +class CapitalImportCommit(BaseModel): + entity_id: int + as_of_date: date + investors: list[ImportCommitInvestor] + + +# --- Entity stakes (a GP/mgmt entity's interest in the funds it manages) --- + +class EntityStakeCreate(BaseModel): + fund_entity_id: int + ownership_pct: float | None = None + value_dollars: float | None = None + note: str | None = None + + +class EntityStakeResponse(BaseModel): + id: int + holder_entity_id: int + fund_entity_id: int + fund_name: str | None = None + fund_type: EntityType | None = None + ownership_pct: float | None + value_cents: int | None + note: str | None + created_at: datetime + + +# --- Investor View (admin reconstruction of what one investor sees) --- + +class InvestorViewResponse(BaseModel): + user: UserResponse + entities: list[EntityResponse] = [] + capital_accounts: list[CapitalAccountResponse] = [] + documents: list[DocumentResponse] = [] + + # --- Audit --- class AuditLogResponse(BaseModel): diff --git a/backend/tests/test_investor_view.py b/backend/tests/test_investor_view.py new file mode 100644 index 0000000..a4dc9c5 --- /dev/null +++ b/backend/tests/test_investor_view.py @@ -0,0 +1,40 @@ +"""Admin Investor View reconstructs what one investor sees, read-only.""" + +from datetime import date + +from tests.conftest import make_user +from ten31portal.models import ( + CapitalAccountStatement, Entity, EntityAccess, EntityType, UserRole, +) + + +def test_investor_view_reconstructs(auth_client, session): + inv = make_user(session, username="lp1", role=UserRole.investor) + ent = Entity(name="LTPF I", type=EntityType.fund) + other = Entity(name="Not Theirs", type=EntityType.fund) + session.add(ent) + session.add(other) + session.commit() + session.refresh(ent) + session.refresh(other) + + session.add(EntityAccess(user_id=inv.id, entity_id=ent.id)) + session.add(CapitalAccountStatement( + entity_id=ent.id, investor_user_id=inv.id, as_of_date=date(2026, 3, 31), + commitment_cents=500_000, ending_balance_cents=600_000, + )) + session.commit() + + resp = auth_client.get(f"/api/users/{inv.id}/investor-view") + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["user"]["username"] == "lp1" + # Only the granted entity is visible, not the other one. + assert [e["id"] for e in body["entities"]] == [ent.id] + assert len(body["capital_accounts"]) == 1 + assert body["capital_accounts"][0]["ending_balance_cents"] == 600_000 + + +def test_investor_view_rejects_non_investor(auth_client, session): + staff = make_user(session, username="ops", role=UserRole.operations) + assert auth_client.get(f"/api/users/{staff.id}/investor-view").status_code == 400 diff --git a/backend/tests/test_stakes.py b/backend/tests/test_stakes.py new file mode 100644 index 0000000..9ccfa24 --- /dev/null +++ b/backend/tests/test_stakes.py @@ -0,0 +1,41 @@ +"""Entity stakes: a GP entity's interest in the funds it manages.""" + +from ten31portal.models import Entity, EntityType + + +def test_stake_crud(auth_client, session): + gp = Entity(name="Ten31 LLC", type=EntityType.gp) + fund = Entity(name="LTPF I", type=EntityType.fund) + session.add(gp) + session.add(fund) + session.commit() + session.refresh(gp) + session.refresh(fund) + + created = auth_client.post( + f"/api/entities/{gp.id}/stakes", + json={"fund_entity_id": fund.id, "ownership_pct": 20, "value_dollars": 1000}, + ) + assert created.status_code == 201, created.text + body = created.json() + assert body["fund_name"] == "LTPF I" + assert body["fund_type"] == "fund" + assert body["value_cents"] == 100_000 # dollars -> cents + stake_id = body["id"] + + listed = auth_client.get(f"/api/entities/{gp.id}/stakes") + assert listed.status_code == 200 + assert len(listed.json()) == 1 + + # An entity cannot hold a stake in itself. + assert auth_client.post( + f"/api/entities/{gp.id}/stakes", json={"fund_entity_id": gp.id} + ).status_code == 400 + + # Duplicate stake rejected. + assert auth_client.post( + f"/api/entities/{gp.id}/stakes", json={"fund_entity_id": fund.id} + ).status_code == 409 + + assert auth_client.delete(f"/api/entities/{gp.id}/stakes/{stake_id}").status_code == 200 + assert auth_client.get(f"/api/entities/{gp.id}/stakes").json() == [] diff --git a/deploy/Dockerfile b/deploy/Dockerfile index d8f475b..f99ae05 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -1,7 +1,10 @@ # Build context is the repo root (docker build -f deploy/Dockerfile .) # --- Frontend build stage --- -FROM node:20-slim AS frontend-build +# Build on the native builder arch ($BUILDPLATFORM) so the JS bundler runs without +# cross-arch emulation. The output is static assets (HTML/CSS/JS), which are +# architecture-independent and copied into the target-arch runtime stage below. +FROM --platform=$BUILDPLATFORM node:20-slim AS frontend-build WORKDIR /build COPY frontend/package.json frontend/package-lock.json ./ @@ -32,7 +35,8 @@ RUN chmod +x ./start.sh RUN mkdir -p /data ENV TEN31_DB_PATH=/data/portal.db -ENV TEN31_SESSION_SECRET=*** +ENV TEN31_DOCS_DIR=/data/documents +ENV TEN31_SESSION_SECRET=change-me EXPOSE 8000 diff --git a/deploy/LICENSE b/deploy/LICENSE new file mode 100644 index 0000000..5fbef5c --- /dev/null +++ b/deploy/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ten31 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/deploy/assets/.gitkeep b/deploy/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/deploy/icon.png b/deploy/icon.png index 396d542bb3984e92926c325523282a08256ffd1a..3f7d2f2a02d2557c70a9e950fac6e64728a55aa2 100644 GIT binary patch literal 23833 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 G2~7Yk?tza0 diff --git a/deploy/instructions.md b/deploy/instructions.md new file mode 100644 index 0000000..d0e9dd8 --- /dev/null +++ b/deploy/instructions.md @@ -0,0 +1,39 @@ +# Ten31Portal + +Internal system of record for Ten31 entities, holdings, positions, and quarterly +valuation sign-off, plus an investor / fund-administrator portal. + +## First login + +On first boot a default administrator (approver) account is created. Unless you set +the environment variables below, the defaults are: + +- **Username:** `admin` +- **Password:** `Ten31` + +Open the web interface and sign in. Change this password immediately from the Users +screen, or create a new admin and disable the default one. + +The login field accepts a username **or** an email address. + +## Accounts and access + +- **Internal staff** (`approver`, `cfo`, `fund_admin`, `viewer`) use the back-office + app: entities, holdings, positions, valuation rounds, import, and audit log. + Approvers and the CFO also get the admin screens below. +- **External accounts** (`investor`, `fund_administrator`) get an entity-scoped + portal and only ever see the entities granted to them. + - **Investors** see, per fund, their latest capital-account value and history, plus + documents shared to the fund or addressed privately to them (e.g. their K-1). + - **Fund administrators** see their assigned entities and can upload documents. + +From the **Users** screen, create an account with a username and password and check +off which entities it can view. Use **Documents** to upload statements and K-1s +(shared to a fund or private to one investor), and **Capital Accounts** to enter each +investor's figures. + +## Data and backups + +All data — the database, uploaded documents, and the generated session secret — lives +on the service's data volume and is included in StartOS platform backups. Create a +backup before uninstalling; uninstalling removes all fund data. diff --git a/deploy/package-lock.json b/deploy/package-lock.json index 66005fd..4b6589d 100644 --- a/deploy/package-lock.json +++ b/deploy/package-lock.json @@ -1,12 +1,12 @@ { "name": "ten31portal-startos", - "version": "0.1.0", + "version": "0.2.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ten31portal-startos", - "version": "0.1.0", + "version": "0.2.21", "dependencies": { "@start9labs/start-sdk": "^0.4.0-beta.58" }, diff --git a/deploy/package.json b/deploy/package.json index 886dbfe..ed68705 100644 --- a/deploy/package.json +++ b/deploy/package.json @@ -1,6 +1,6 @@ { "name": "ten31portal-startos", - "version": "0.2.21", + "version": "0.2.22", "private": true, "scripts": { "build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript", diff --git a/deploy/startos/actions/index.ts b/deploy/startos/actions/index.ts index 9057760..2d80130 100644 --- a/deploy/startos/actions/index.ts +++ b/deploy/startos/actions/index.ts @@ -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) diff --git a/deploy/startos/install/versions/index.ts b/deploy/startos/install/versions/index.ts index 4059bcf..4ec7624 100644 --- a/deploy/startos/install/versions/index.ts +++ b/deploy/startos/install/versions/index.ts @@ -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] diff --git a/deploy/startos/install/versions/v_0_2_22.ts b/deploy/startos/install/versions/v_0_2_22.ts new file mode 100644 index 0000000..33ed407 --- /dev/null +++ b/deploy/startos/install/versions/v_0_2_22.ts @@ -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 }) => {}, + }, +}) diff --git a/deploy/startos/main.ts b/deploy/startos/main.ts index 9957a44..aa1a0b2 100644 --- a/deploy/startos/main.ts +++ b/deploy/startos/main.ts @@ -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', }, }, diff --git a/frontend/index.html b/frontend/index.html index 0fca6f0..afaf458 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,16 @@ - + + + - frontend + + + + + + Ten31 Portal

diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bc44071..70ea06d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -270,21 +270,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, "license": "MIT", "optional": true, @@ -293,9 +293,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -548,14 +548,14 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", @@ -567,9 +567,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", "dev": true, "license": "MIT", "funding": { @@ -577,9 +577,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", "cpu": [ "arm64" ], @@ -594,9 +594,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", + "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", "cpu": [ "arm64" ], @@ -611,9 +611,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", + "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", "cpu": [ "x64" ], @@ -628,9 +628,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", + "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", "cpu": [ "x64" ], @@ -645,9 +645,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", + "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", "cpu": [ "arm" ], @@ -662,9 +662,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", + "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", "cpu": [ "arm64" ], @@ -679,9 +679,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", + "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", "cpu": [ "arm64" ], @@ -696,9 +696,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", + "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", "cpu": [ "ppc64" ], @@ -713,9 +713,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", + "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", "cpu": [ "s390x" ], @@ -730,9 +730,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", + "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", "cpu": [ "x64" ], @@ -747,9 +747,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", + "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", "cpu": [ "x64" ], @@ -764,9 +764,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", + "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", "cpu": [ "arm64" ], @@ -781,9 +781,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", + "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", "cpu": [ "wasm32" ], @@ -791,18 +791,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", + "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", "cpu": [ "arm64" ], @@ -817,9 +817,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", + "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", "cpu": [ "x64" ], @@ -841,49 +841,49 @@ "license": "MIT" }, "node_modules/@tailwindcss/node": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", - "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", + "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.21.0", - "jiti": "^2.6.1", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.3.0" + "tailwindcss": "4.3.1" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", - "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.0", - "@tailwindcss/oxide-darwin-arm64": "4.3.0", - "@tailwindcss/oxide-darwin-x64": "4.3.0", - "@tailwindcss/oxide-freebsd-x64": "4.3.0", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", - "@tailwindcss/oxide-linux-x64-musl": "4.3.0", - "@tailwindcss/oxide-wasm32-wasi": "4.3.0", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", - "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", "cpu": [ "arm64" ], @@ -898,9 +898,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", - "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", "cpu": [ "arm64" ], @@ -915,9 +915,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", - "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", "cpu": [ "x64" ], @@ -932,9 +932,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", - "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", "cpu": [ "x64" ], @@ -949,9 +949,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", - "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", "cpu": [ "arm" ], @@ -966,9 +966,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", - "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", "cpu": [ "arm64" ], @@ -983,9 +983,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", - "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", "cpu": [ "arm64" ], @@ -1000,9 +1000,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", - "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", "cpu": [ "x64" ], @@ -1017,9 +1017,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", - "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", "cpu": [ "x64" ], @@ -1034,9 +1034,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", - "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -1056,7 +1056,7 @@ "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", - "@tybys/wasm-util": "^0.10.1", + "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "engines": { @@ -1064,9 +1064,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", - "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", "cpu": [ "arm64" ], @@ -1081,9 +1081,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", - "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", "cpu": [ "x64" ], @@ -1098,24 +1098,24 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", - "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz", + "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.3.0", - "@tailwindcss/oxide": "4.3.0", - "tailwindcss": "4.3.0" + "@tailwindcss/node": "4.3.1", + "@tailwindcss/oxide": "4.3.1", + "tailwindcss": "4.3.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -1145,9 +1145,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.13.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.1.tgz", - "integrity": "sha512-RSpUJGmvsJ1ZeBehQZFhIdpsz+bIpES0nIQXko4Ybq+N+kX6XvOq3Jo+iJ82FWLdblFq85AsMikd3m35jgezYg==", + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", "dev": true, "license": "MIT", "dependencies": { @@ -1175,17 +1175,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz", - "integrity": "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.0.tgz", + "integrity": "sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.60.1", - "@typescript-eslint/type-utils": "8.60.1", - "@typescript-eslint/utils": "8.60.1", - "@typescript-eslint/visitor-keys": "8.60.1", + "@typescript-eslint/scope-manager": "8.62.0", + "@typescript-eslint/type-utils": "8.62.0", + "@typescript-eslint/utils": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1198,7 +1198,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.60.1", + "@typescript-eslint/parser": "^8.62.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -1214,16 +1214,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz", - "integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.0.tgz", + "integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.60.1", - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/typescript-estree": "8.60.1", - "@typescript-eslint/visitor-keys": "8.60.1", + "@typescript-eslint/scope-manager": "8.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0", "debug": "^4.4.3" }, "engines": { @@ -1239,14 +1239,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz", - "integrity": "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.0.tgz", + "integrity": "sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.60.1", - "@typescript-eslint/types": "^8.60.1", + "@typescript-eslint/tsconfig-utils": "^8.62.0", + "@typescript-eslint/types": "^8.62.0", "debug": "^4.4.3" }, "engines": { @@ -1261,14 +1261,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz", - "integrity": "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.0.tgz", + "integrity": "sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/visitor-keys": "8.60.1" + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1279,9 +1279,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz", - "integrity": "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.0.tgz", + "integrity": "sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==", "dev": true, "license": "MIT", "engines": { @@ -1296,15 +1296,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz", - "integrity": "sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.0.tgz", + "integrity": "sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/typescript-estree": "8.60.1", - "@typescript-eslint/utils": "8.60.1", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0", + "@typescript-eslint/utils": "8.62.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -1321,9 +1321,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz", - "integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.0.tgz", + "integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==", "dev": true, "license": "MIT", "engines": { @@ -1335,16 +1335,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz", - "integrity": "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.0.tgz", + "integrity": "sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.60.1", - "@typescript-eslint/tsconfig-utils": "8.60.1", - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/visitor-keys": "8.60.1", + "@typescript-eslint/project-service": "8.62.0", + "@typescript-eslint/tsconfig-utils": "8.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1363,9 +1363,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", - "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -1376,16 +1376,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.1.tgz", - "integrity": "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.0.tgz", + "integrity": "sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.60.1", - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/typescript-estree": "8.60.1" + "@typescript-eslint/scope-manager": "8.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1400,13 +1400,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz", - "integrity": "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.0.tgz", + "integrity": "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/types": "8.62.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -1418,13 +1418,13 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", - "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "^1.0.0" + "@rolldown/pluginutils": "^1.0.1" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -1444,9 +1444,9 @@ } }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", "bin": { @@ -1494,9 +1494,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.34", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz", - "integrity": "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==", + "version": "2.10.40", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", + "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1520,9 +1520,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", "dev": true, "funding": [ { @@ -1540,10 +1540,10 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", "update-browserslist-db": "^1.2.3" }, "bin": { @@ -1554,9 +1554,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001797", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", - "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", "dev": true, "funding": [ { @@ -1652,16 +1652,16 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.368", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz", - "integrity": "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==", + "version": "1.5.379", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.379.tgz", + "integrity": "sha512-v/qV5aV5EUA2pGilzUCq5/eyOloZAqDZBu9UMBIzgPpLlprjSR6zswsWBTv0KpqxLGUAZEwhO95ZCt7srymNVA==", "dev": true, "license": "ISC" }, "node_modules/enhanced-resolve": { - "version": "5.23.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz", - "integrity": "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA==", + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1696,11 +1696,14 @@ } }, "node_modules/eslint": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.1.tgz", - "integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.5.0.tgz", + "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -1772,9 +1775,9 @@ } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", - "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.3.tgz", + "integrity": "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2006,9 +2009,9 @@ } }, "node_modules/globals": { - "version": "17.6.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", - "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", "dev": true, "license": "MIT", "engines": { @@ -2501,9 +2504,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "dev": true, "funding": [ { @@ -2527,9 +2530,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.47", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", - "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", "dev": true, "license": "MIT", "engines": { @@ -2697,9 +2700,9 @@ } }, "node_modules/react-router": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz", - "integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz", + "integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -2719,12 +2722,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz", - "integrity": "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz", + "integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==", "license": "MIT", "dependencies": { - "react-router": "7.17.0" + "react-router": "7.18.0" }, "engines": { "node": ">=20.0.0" @@ -2735,13 +2738,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", + "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", + "@oxc-project/types": "=0.137.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -2751,21 +2754,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" + "@rolldown/binding-android-arm64": "1.1.3", + "@rolldown/binding-darwin-arm64": "1.1.3", + "@rolldown/binding-darwin-x64": "1.1.3", + "@rolldown/binding-freebsd-x64": "1.1.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", + "@rolldown/binding-linux-arm64-gnu": "1.1.3", + "@rolldown/binding-linux-arm64-musl": "1.1.3", + "@rolldown/binding-linux-ppc64-gnu": "1.1.3", + "@rolldown/binding-linux-s390x-gnu": "1.1.3", + "@rolldown/binding-linux-x64-gnu": "1.1.3", + "@rolldown/binding-linux-x64-musl": "1.1.3", + "@rolldown/binding-openharmony-arm64": "1.1.3", + "@rolldown/binding-wasm32-wasi": "1.1.3", + "@rolldown/binding-win32-arm64-msvc": "1.1.3", + "@rolldown/binding-win32-x64-msvc": "1.1.3" } }, "node_modules/scheduler": { @@ -2824,9 +2827,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", - "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", "dev": true, "license": "MIT" }, @@ -2910,16 +2913,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.1.tgz", - "integrity": "sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.0.tgz", + "integrity": "sha512-8QxXi+ZACKX0kaqO4gY8kn0RSD9gFfaHDWwjqtEN48aWCBkX4MJaufWN+c3BzlrXLOxfywDL8CaoqUwcRq4j4Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.60.1", - "@typescript-eslint/parser": "8.60.1", - "@typescript-eslint/typescript-estree": "8.60.1", - "@typescript-eslint/utils": "8.60.1" + "@typescript-eslint/eslint-plugin": "8.62.0", + "@typescript-eslint/parser": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0", + "@typescript-eslint/utils": "8.62.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2982,16 +2985,16 @@ } }, "node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", + "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", - "rolldown": "1.0.3", + "rolldown": "~1.1.2", "tinyglobby": "^0.2.17" }, "bin": { @@ -3008,7 +3011,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg deleted file mode 100644 index 6893eb1..0000000 --- a/frontend/public/favicon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/public/icon-192.png b/frontend/public/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..88a705b3a9288adead057be196351e141f882985 GIT binary patch literal 15170 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 literal 0 HcmV?d00001 diff --git a/frontend/public/icon-512.png b/frontend/public/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..2aec1aefb48706a0a7d1ca758096dae4c81f33cf GIT binary patch literal 70382 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 literal 0 HcmV?d00001 diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest new file mode 100644 index 0000000..c489c3d --- /dev/null +++ b/frontend/public/manifest.webmanifest @@ -0,0 +1,16 @@ +{ + "name": "Ten31 Portal", + "short_name": "Ten31", + "description": "Ten31 fund portal — entities, valuations, and investor capital accounts.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "portrait-primary", + "background_color": "#16243A", + "theme_color": "#16243A", + "icons": [ + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..01ffb68 --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,56 @@ +// Ten31 Portal service worker. Deliberately conservative so it never serves a stale app: +// - navigations are network-first (always get the latest index.html), cache only as offline fallback +// - content-hashed /assets/* are cache-first (immutable, safe forever) +// - /api/* is never cached +// Bump CACHE on each release so old entries are purged. +const CACHE = 'ten31-portal-0.2.22' + +self.addEventListener('install', () => self.skipWaiting()) + +self.addEventListener('activate', (event) => { + event.waitUntil( + (async () => { + const keys = await caches.keys() + await Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))) + await self.clients.claim() + })(), + ) +}) + +self.addEventListener('fetch', (event) => { + const req = event.request + if (req.method !== 'GET') return + const url = new URL(req.url) + if (url.origin !== self.location.origin) return + if (url.pathname.startsWith('/api/')) return + + if (req.mode === 'navigate') { + event.respondWith( + (async () => { + try { + const fresh = await fetch(req) + const cache = await caches.open(CACHE) + cache.put('/', fresh.clone()) + return fresh + } catch { + const cache = await caches.open(CACHE) + return (await cache.match('/')) || Response.error() + } + })(), + ) + return + } + + if (url.pathname.startsWith('/assets/')) { + event.respondWith( + (async () => { + const cache = await caches.open(CACHE) + const hit = await cache.match(req) + if (hit) return hit + const res = await fetch(req) + if (res.ok) cache.put(req, res.clone()) + return res + })(), + ) + } +}) diff --git a/frontend/public/ten31-logo.png b/frontend/public/ten31-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..c11f94680b3c890c1518ba4c151ab3d7a8ede2fd GIT binary patch literal 11422 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 literal 0 HcmV?d00001 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cbde348..39a8034 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import EntityOverview from "./pages/EntityOverview"; import EntityPartners from "./pages/EntityPartners"; import EntityDocuments from "./pages/EntityDocuments"; import Investments from "./pages/Investments"; +import EntityAssets from "./pages/EntityAssets"; import ValuationWorkflow from "./pages/ValuationWorkflow"; import Import from "./pages/Import"; import AuditLog from "./pages/AuditLog"; @@ -15,6 +16,7 @@ import Users from "./pages/Users"; import Documents from "./pages/Documents"; import CapitalAccounts from "./pages/CapitalAccounts"; import AccessGrid from "./pages/AccessGrid"; +import InvestorView from "./pages/InvestorView"; import PortalLayout from "./portal/PortalLayout"; import InvestorHome from "./portal/InvestorHome"; import FundAdminHome from "./portal/FundAdminHome"; @@ -28,10 +30,12 @@ function InternalApp() { } /> } /> } /> + } /> } /> } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index c2eebb1..fc3ec7b 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -205,6 +205,25 @@ export interface AuditEntry { created_at: string; } +export interface EntityStake { + id: number; + holder_entity_id: number; + fund_entity_id: number; + fund_name: string | null; + fund_type: EntityType | null; + ownership_pct: number | null; + value_cents: number | null; + note: string | null; + created_at: string; +} + +export interface InvestorView { + user: User; + entities: Entity[]; + capital_accounts: CapitalAccount[]; + documents: PortalDocument[]; +} + // --- API helpers --- export class ApiError extends Error { @@ -270,6 +289,18 @@ export const api = { updateEntity: (id: number, data: Partial) => request(`/api/entities/${id}`, { method: "PATCH", body: JSON.stringify(data) }), + // Entity stakes (a GP/mgmt entity's interest in the funds it manages) + listStakes: (entityId: number) => request(`/api/entities/${entityId}/stakes`), + createStake: ( + entityId: number, + data: { fund_entity_id: number; ownership_pct?: number | null; value_dollars?: number | null; note?: string | null }, + ) => request(`/api/entities/${entityId}/stakes`, { method: "POST", body: JSON.stringify(data) }), + deleteStake: (entityId: number, stakeId: number) => + request<{ status: string }>(`/api/entities/${entityId}/stakes/${stakeId}`, { method: "DELETE" }), + + // Investor View (admin read-only reconstruction of an investor's portal) + investorView: (userId: number) => request(`/api/users/${userId}/investor-view`), + // Holdings listHoldings: (entityId: number) => request(`/api/entities/${entityId}/holdings`), diff --git a/frontend/src/components/CapitalChart.tsx b/frontend/src/components/CapitalChart.tsx new file mode 100644 index 0000000..7579b2f --- /dev/null +++ b/frontend/src/components/CapitalChart.tsx @@ -0,0 +1,89 @@ +import { useMemo } from "react"; +import { formatMoney, formatQuarter } from "../format"; + +export interface CapitalPoint { + date: string; // as-of date (quarter end) + value: number; // ending balance, cents + paidIn: number; // cumulative contributions, cents + distributions: number; // cumulative distributions, cents +} + +const SERIES = [ + { key: "value" as const, label: "Capital value", color: "#111827" }, + { key: "paidIn" as const, label: "Paid-in", color: "#2563eb" }, + { key: "distributions" as const, label: "Distributions", color: "#16a34a" }, +]; + +// A compact, dependency-free multi-line SVG chart of an investor's capital over time. +export default function CapitalChart({ points }: { points: CapitalPoint[] }) { + const data = useMemo( + () => [...points].sort((a, b) => a.date.localeCompare(b.date)), + [points], + ); + + if (data.length < 2) return null; + + const W = 640; + const H = 240; + const padL = 64; + const padR = 16; + const padT = 16; + const padB = 32; + const innerW = W - padL - padR; + const innerH = H - padT - padB; + + const maxVal = Math.max(1, ...data.flatMap((d) => [d.value, d.paidIn, d.distributions])); + const x = (i: number) => padL + (data.length === 1 ? innerW / 2 : (innerW * i) / (data.length - 1)); + const y = (v: number) => padT + innerH - (innerH * v) / maxVal; + + // 4 horizontal gridlines with dollar labels. + const ticks = [0, 0.25, 0.5, 0.75, 1].map((f) => Math.round(maxVal * f)); + + return ( +
+ + {ticks.map((t) => ( + + + + {formatMoney(t)} + + + ))} + + {SERIES.map((s) => ( + `${x(i)},${y(d[s.key])}`).join(" ")} + /> + ))} + + {SERIES.map((s) => + data.map((d, i) => ( + + {`${formatQuarter(d.date)} · ${s.label}: ${formatMoney(d[s.key])}`} + + )), + )} + + {data.map((d, i) => ( + + {formatQuarter(d.date)} + + ))} + + +
+ {SERIES.map((s) => ( + + + {s.label} + + ))} +
+
+ ); +} diff --git a/frontend/src/components/ChangePasswordModal.tsx b/frontend/src/components/ChangePasswordModal.tsx new file mode 100644 index 0000000..7ce3e0e --- /dev/null +++ b/frontend/src/components/ChangePasswordModal.tsx @@ -0,0 +1,74 @@ +import { useState } from "react"; +import { api } from "../api"; +import PasswordInput from "./PasswordInput"; + +/** Self-service password change for the signed-in user. */ +export default function ChangePasswordModal({ onClose }: { onClose: () => void }) { + const [current, setCurrent] = useState(""); + const [next, setNext] = useState(""); + const [confirm, setConfirm] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + const [done, setDone] = useState(false); + + const save = async () => { + setError(""); + if (next.length < 4) return setError("New password must be at least 4 characters."); + if (next !== confirm) return setError("New passwords don't match."); + setBusy(true); + try { + await api.changePassword(current, next); + setDone(true); + } catch (e: any) { + setError(e.message || "Could not change password"); + } finally { + setBusy(false); + } + }; + + return ( +
+
e.stopPropagation()}> +

Change password

+ {done ? ( +
+

Your password has been updated.

+
+ +
+
+ ) : ( +
+
+ + +
+
+ + +
+
+ + +
+ {error &&

{error}

} +
+ + +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/EntityHeader.tsx b/frontend/src/components/EntityHeader.tsx new file mode 100644 index 0000000..1342ef9 --- /dev/null +++ b/frontend/src/components/EntityHeader.tsx @@ -0,0 +1,59 @@ +import { Link } from "react-router-dom"; +import type { Entity } from "../api"; + +const TYPE_LABELS: Record = { + fund: "Fund", + spv: "SPV", + gp: "GP", + mgmt_co: "Mgmt Co", +}; + +const TABS: { key: string; label: string; path: (id: number) => string }[] = [ + { key: "overview", label: "Overview", path: (id) => `/entities/${id}` }, + { key: "investments", label: "Investments", path: (id) => `/entities/${id}/investments` }, + { key: "partners", label: "Partners", path: (id) => `/entities/${id}/partners` }, + { key: "documents", label: "Documents", path: (id) => `/entities/${id}/documents` }, +]; + +// GP entities and management companies hold interests in the funds they manage; that's their +// "Assets" tab. Funds/SPVs use the Investments tab instead. +const ASSETS_TAB = { + key: "assets", + label: "Assets", + path: (id: number) => `/entities/${id}/assets`, +}; + +export default function EntityHeader({ entity, active }: { entity: Entity; active: string }) { + const tabs = + entity.type === "gp" || entity.type === "mgmt_co" ? [...TABS, ASSETS_TAB] : TABS; + return ( +
+
+

{entity.name}

+ + {TYPE_LABELS[entity.type] || entity.type} + +
+
+ {tabs.map((t) => + active === t.key ? ( + + {t.label} + + ) : ( + + {t.label} + + ), + )} +
+
+ ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index cf1861f..90658e4 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,25 +1,61 @@ +import { useState } from "react"; import { Link, useLocation } from "react-router-dom"; import { useAuth } from "../context/AuthContext"; +import { isAdmin } from "../api"; +import { roleLabel } from "../format"; +import { APP_VERSION } from "../version"; +import ChangePasswordModal from "./ChangePasswordModal"; -const NAV_ITEMS = [ +const TOP_NAV = [ { label: "Entities", path: "/" }, { label: "Import", path: "/import" }, - { label: "Audit Log", path: "/audit" }, ]; +const ADMIN_NAV = [ + { label: "Users", path: "/users" }, + { label: "Investor View", path: "/investor-view" }, + { label: "Access Grid", path: "/access" }, + { label: "Documents", path: "/documents" }, + { label: "Capital Accounts", path: "/capital-accounts" }, +]; + +const BOTTOM_NAV = [{ label: "Audit Log", path: "/audit" }]; + export default function Layout({ children }: { children: React.ReactNode }) { const { user, logout } = useAuth(); const location = useLocation(); + const [navOpen, setNavOpen] = useState(false); + const [changingPw, setChangingPw] = useState(false); + const navItems = user && isAdmin(user.role) + ? [...TOP_NAV, ...ADMIN_NAV, ...BOTTOM_NAV] + : [...TOP_NAV, ...BOTTOM_NAV]; return (
- {/* Left nav */} -