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 396d542..3f7d2f2 100644 Binary files a/deploy/icon.png and b/deploy/icon.png differ 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 0000000..88a705b Binary files /dev/null and b/frontend/public/icon-192.png differ diff --git a/frontend/public/icon-512.png b/frontend/public/icon-512.png new file mode 100644 index 0000000..2aec1ae Binary files /dev/null and b/frontend/public/icon-512.png differ 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 0000000..c11f946 Binary files /dev/null and b/frontend/public/ten31-logo.png differ 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 */} -