Implement adjudicated DO items across backend, frontend, deploy
From the ROADMAP adjudication (12 of 13 DO items; D2 is a commit action). Backend: - B3: pytest suite (auth, entity CRUD, rollup) + dev deps + pytest config - B4: cap document uploads at TEN31_MAX_UPLOAD_SIZE (default 50MB), stream- checked with partial-file cleanup, 413 on overflow - B7: type AuditLog.detail as dict|list|str|None to match the JSON column - B10: index foreign-key columns (migration a7b8c9d0e1f2 + index=True) - B11: cli delete-user logs file-removal errors instead of swallowing them Frontend: - F2: distinguish "server unreachable" from "logged out"; retry prompt - F4: confirm before destructive holdings-replace on import; step progress - F6: expandable audit-log detail with full JSON - F7: empty-state on the Investments page - F8: shared role helpers (WRITER_ROLES/canEditRound/isApprover), used by EntitiesList, AuditLog, Import, ValuationWorkflow Deploy: - D5: run tsc --noEmit before packaging (build script) - D6: TEN31_LOG_LEVEL env var (defaults to info) Verified: 8/8 backend tests pass; alembic upgrades to head with 13 FK indexes; upload limit rejects oversized + cleans up; frontend tsc + vite build clean; dev server serves and proxies to the API.
This commit is contained in:
+294
-5
@@ -4,17 +4,144 @@
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export type UserRole = "approver" | "cfo" | "fund_admin" | "viewer";
|
||||
export type UserRole =
|
||||
| "approver"
|
||||
| "operations"
|
||||
| "cfo"
|
||||
| "fund_admin"
|
||||
| "viewer"
|
||||
| "investor"
|
||||
| "fund_administrator";
|
||||
export type EntityType = "fund" | "spv" | "gp" | "mgmt_co";
|
||||
export type EntityStatus = "active" | "closed";
|
||||
export type RoundStatus = "draft" | "submitted" | "approved" | "returned";
|
||||
export type DocumentCategory =
|
||||
| "capital_account"
|
||||
| "k1"
|
||||
| "statement"
|
||||
| "tax"
|
||||
| "other";
|
||||
|
||||
export const INTERNAL_ROLES: UserRole[] = ["approver", "operations", "cfo", "fund_admin", "viewer"];
|
||||
export const EXTERNAL_ROLES: UserRole[] = ["investor", "fund_administrator"];
|
||||
// Roles that can administer users, documents, and capital accounts (everything except sign-off).
|
||||
export const ADMIN_ROLES: UserRole[] = ["approver", "operations", "cfo"];
|
||||
|
||||
// Roles that can create/edit entities and valuation rounds (staff, excluding read-only viewer).
|
||||
export const WRITER_ROLES: UserRole[] = ["fund_admin", "operations", "cfo", "approver"];
|
||||
|
||||
export const isInternal = (role: UserRole) => INTERNAL_ROLES.includes(role);
|
||||
export const isAdmin = (role: UserRole) => ADMIN_ROLES.includes(role);
|
||||
export const canEditRound = (role: UserRole) => WRITER_ROLES.includes(role);
|
||||
export const isApprover = (role: UserRole) => role === "approver";
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
username: string;
|
||||
email: string | null;
|
||||
role: UserRole;
|
||||
is_active: boolean;
|
||||
is_service_admin: boolean;
|
||||
primary_account_id: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface LinkedAccount {
|
||||
id: number;
|
||||
name: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface UserDetail extends User {
|
||||
primary_account_name: string | null;
|
||||
linked_accounts: LinkedAccount[];
|
||||
entity_ids: number[];
|
||||
}
|
||||
|
||||
export interface PortalDocument {
|
||||
id: number;
|
||||
entity_id: number;
|
||||
investor_user_id: number | null;
|
||||
category: DocumentCategory;
|
||||
title: string;
|
||||
original_filename: string;
|
||||
content_type: string;
|
||||
size_bytes: number;
|
||||
uploaded_by: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Partner {
|
||||
user_id: number;
|
||||
name: string;
|
||||
username: string;
|
||||
external_investor_id: string | null;
|
||||
is_active: boolean;
|
||||
login_enabled: boolean;
|
||||
latest_commitment_cents: number | null;
|
||||
latest_contributions_cents: number | null;
|
||||
latest_distributions_cents: number | null;
|
||||
latest_value_cents: number | null;
|
||||
latest_as_of: string | null;
|
||||
statements_count: number;
|
||||
}
|
||||
|
||||
export interface AccessGrant {
|
||||
user_id: number;
|
||||
entity_id: number;
|
||||
}
|
||||
|
||||
export interface AccessMatrix {
|
||||
users: User[];
|
||||
entities: Entity[];
|
||||
grants: AccessGrant[];
|
||||
}
|
||||
|
||||
export interface ImportValueRow {
|
||||
row_index: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ImportInvestorPreview {
|
||||
source_name: string;
|
||||
column_index: number;
|
||||
value_dollars: number;
|
||||
commitment_dollars: number;
|
||||
contributions_dollars: number;
|
||||
distributions_dollars: number;
|
||||
external_id: string | null;
|
||||
matched_user_id: number | null;
|
||||
matched_username: string | null;
|
||||
suggested_username: string | null;
|
||||
}
|
||||
|
||||
export interface CapitalImportPreview {
|
||||
as_of_date: string | null;
|
||||
value_rows: ImportValueRow[];
|
||||
chosen_row_index: number;
|
||||
investors: ImportInvestorPreview[];
|
||||
}
|
||||
|
||||
export interface CapitalImportResult {
|
||||
status: string;
|
||||
created_accounts: number;
|
||||
matched_accounts_updated: number;
|
||||
statements_written: number;
|
||||
}
|
||||
|
||||
export interface CapitalAccount {
|
||||
id: number;
|
||||
entity_id: number;
|
||||
investor_user_id: number;
|
||||
investor_name: string | null;
|
||||
as_of_date: string;
|
||||
commitment_cents: number;
|
||||
beginning_balance_cents: number;
|
||||
contributions_cents: number;
|
||||
distributions_cents: number;
|
||||
ending_balance_cents: number;
|
||||
document_id: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -80,7 +207,7 @@ export interface AuditEntry {
|
||||
|
||||
// --- API helpers ---
|
||||
|
||||
class ApiError extends Error {
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
@@ -106,16 +233,22 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
// --- Auth ---
|
||||
|
||||
export const api = {
|
||||
login: (email: string, password: string) =>
|
||||
login: (login: string, password: string) =>
|
||||
request<User>("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password }),
|
||||
body: JSON.stringify({ login, password }),
|
||||
}),
|
||||
|
||||
logout: () => request<{ status: string }>("/api/auth/logout", { method: "POST" }),
|
||||
|
||||
me: () => request<User>("/api/auth/me"),
|
||||
|
||||
changePassword: (current_password: string, new_password: string) =>
|
||||
request<{ status: string }>("/api/auth/change-password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ current_password, new_password }),
|
||||
}),
|
||||
|
||||
// Entities
|
||||
listEntities: () => request<Entity[]>("/api/entities"),
|
||||
listEntityRollup: () =>
|
||||
@@ -126,10 +259,12 @@ export const api = {
|
||||
vintage_year: number | null;
|
||||
fund_size_cents: number | null;
|
||||
status: EntityStatus;
|
||||
committed_cents: number;
|
||||
invested_cents: number;
|
||||
last_signed_value_cents: number;
|
||||
}[]>("/api/entities/rollup"),
|
||||
getEntity: (id: number) => request<Entity>(`/api/entities/${id}`),
|
||||
listPartners: (entityId: number) => request<Partner[]>(`/api/entities/${entityId}/partners`),
|
||||
createEntity: (data: Partial<Entity>) =>
|
||||
request<Entity>("/api/entities", { method: "POST", body: JSON.stringify(data) }),
|
||||
updateEntity: (id: number, data: Partial<Entity>) =>
|
||||
@@ -180,4 +315,158 @@ export const api = {
|
||||
if (params?.page) q.set("page", String(params.page));
|
||||
return request<AuditEntry[]>(`/api/audit?${q}`);
|
||||
},
|
||||
|
||||
// Users (admin)
|
||||
listUsers: () => request<User[]>("/api/users"),
|
||||
investorsForEntity: (entityId: number) =>
|
||||
request<User[]>(`/api/users/investors-for-entity/${entityId}`),
|
||||
accessMatrix: () => request<AccessMatrix>("/api/users/access-matrix"),
|
||||
grantAccess: (userId: number, entityId: number) =>
|
||||
request<{ status: string }>(`/api/users/${userId}/access/${entityId}`, { method: "PUT" }),
|
||||
revokeAccess: (userId: number, entityId: number) =>
|
||||
request<{ status: string }>(`/api/users/${userId}/access/${entityId}`, { method: "DELETE" }),
|
||||
|
||||
// Holdings / NAV import (eNAV HLD tab)
|
||||
scheduleImport: async (
|
||||
file: File,
|
||||
opts: {
|
||||
commit: boolean;
|
||||
entityId?: number | null;
|
||||
password?: string;
|
||||
asOf?: string;
|
||||
createType?: EntityType;
|
||||
vintageYear?: string;
|
||||
replaceExisting?: boolean;
|
||||
},
|
||||
): Promise<any> => {
|
||||
const form = new FormData();
|
||||
form.set("file", file);
|
||||
if (opts.password) form.set("password", opts.password);
|
||||
const p = new URLSearchParams();
|
||||
p.set("commit", String(opts.commit));
|
||||
if (opts.replaceExisting) p.set("replace_existing", "true");
|
||||
if (opts.asOf) p.set("as_of", opts.asOf);
|
||||
if (opts.entityId != null) {
|
||||
p.set("entity_id", String(opts.entityId));
|
||||
} else {
|
||||
p.set("create_entity_type", opts.createType || "fund");
|
||||
if (opts.vintageYear) p.set("create_vintage_year", opts.vintageYear);
|
||||
}
|
||||
const res = await fetch(`/api/import/schedule?${p}`, { method: "POST", body: form });
|
||||
const data = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
if (!res.ok) throw new ApiError(res.status, data.detail || "Import failed");
|
||||
return data;
|
||||
},
|
||||
|
||||
// Capital-account import (fund-admin spreadsheet)
|
||||
capitalImportPreview: async (
|
||||
file: File,
|
||||
entityId: number,
|
||||
rowIndex?: number,
|
||||
password?: string,
|
||||
): Promise<CapitalImportPreview> => {
|
||||
const form = new FormData();
|
||||
form.set("file", file);
|
||||
form.set("entity_id", String(entityId));
|
||||
if (rowIndex != null) form.set("row_index", String(rowIndex));
|
||||
if (password) form.set("password", password);
|
||||
const res = await fetch("/api/import/capital-accounts/preview", { method: "POST", body: form });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new ApiError(res.status, body.detail || res.statusText);
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
capitalImportCommit: (data: {
|
||||
entity_id: number;
|
||||
as_of_date: string;
|
||||
investors: {
|
||||
action: "match" | "create" | "skip";
|
||||
value_dollars: number;
|
||||
commitment_dollars?: number;
|
||||
contributions_dollars?: number;
|
||||
distributions_dollars?: number;
|
||||
user_id?: number | null;
|
||||
name?: string | null;
|
||||
username?: string | null;
|
||||
email?: string | null;
|
||||
password?: string | null;
|
||||
external_id?: string | null;
|
||||
}[];
|
||||
}) =>
|
||||
request<CapitalImportResult>("/api/import/capital-accounts/commit", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
getUser: (id: number) => request<UserDetail>(`/api/users/${id}`),
|
||||
createUser: (data: {
|
||||
name: string;
|
||||
username: string;
|
||||
password: string;
|
||||
role: UserRole;
|
||||
email?: string | null;
|
||||
entity_ids?: number[];
|
||||
}) => request<UserDetail>("/api/users", { method: "POST", body: JSON.stringify(data) }),
|
||||
updateUser: (
|
||||
id: number,
|
||||
data: Partial<{
|
||||
name: string;
|
||||
username: string;
|
||||
email: string | null;
|
||||
role: UserRole;
|
||||
is_active: boolean;
|
||||
entity_ids: number[];
|
||||
}>,
|
||||
) => request<UserDetail>(`/api/users/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||
resetPassword: (id: number, password: string) =>
|
||||
request<{ status: string }>(`/api/users/${id}/reset-password`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password }),
|
||||
}),
|
||||
linkAccount: (id: number, primary_account_id: number | null) =>
|
||||
request<UserDetail>(`/api/users/${id}/primary-account`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ primary_account_id }),
|
||||
}),
|
||||
|
||||
// Documents
|
||||
listDocuments: (params?: { entity_id?: number; investor_user_id?: number }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.entity_id != null) q.set("entity_id", String(params.entity_id));
|
||||
if (params?.investor_user_id != null) q.set("investor_user_id", String(params.investor_user_id));
|
||||
const qs = q.toString();
|
||||
return request<PortalDocument[]>(`/api/documents${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
uploadDocument: async (form: FormData): Promise<PortalDocument> => {
|
||||
const res = await fetch("/api/documents", { method: "POST", body: form });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new ApiError(res.status, body.detail || res.statusText);
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
downloadUrl: (id: number) => `/api/documents/${id}/download`,
|
||||
deleteDocument: (id: number) =>
|
||||
request<{ status: string }>(`/api/documents/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Capital accounts
|
||||
listCapitalAccounts: (params?: { entity_id?: number; investor_user_id?: number }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.entity_id != null) q.set("entity_id", String(params.entity_id));
|
||||
if (params?.investor_user_id != null) q.set("investor_user_id", String(params.investor_user_id));
|
||||
const qs = q.toString();
|
||||
return request<CapitalAccount[]>(`/api/capital-accounts${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
createCapitalAccount: (data: {
|
||||
entity_id: number;
|
||||
investor_user_id: number;
|
||||
as_of_date: string;
|
||||
beginning_balance_dollars: number;
|
||||
contributions_dollars: number;
|
||||
distributions_dollars: number;
|
||||
ending_balance_dollars: number;
|
||||
document_id?: number | null;
|
||||
}) => request<CapitalAccount>("/api/capital-accounts", { method: "POST", body: JSON.stringify(data) }),
|
||||
deleteCapitalAccount: (id: number) =>
|
||||
request<{ status: string }>(`/api/capital-accounts/${id}`, { method: "DELETE" }),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user