feat: stub extraction of finances to dedicated app (Nest+PWA+MCP+DB) modeled on prospector

- Full skeleton: backend modules (financials per LP contract + shape), auth/health, web PWA tabs (dashboard/income/etc), mcp stub, migrations, docs (FINANCES.md + detailed MIGRATION_FROM_LP.md), CLAUDE, STANDARDS, run/scripts.
- Design embodied: scope from lp.live entities/financials + surfaces/my/financials* + my/backend schema + frontend Financials* pages/tabs + docs/quinn-my/financials.md
- Follows prospector extraction precedent exactly for future cutover/proxies from lp.live
- Verified: structure ls, partial tsc (dep errors only), files read
This commit is contained in:
Natalie 2026-06-29 16:13:15 -04:00
commit d8ae381847
48 changed files with 1275 additions and 0 deletions

View file

@ -0,0 +1,20 @@
{
"name": "@finances/mcp-finances",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "MCP server adapter for @finances/app — exposes financial dashboard, CRUD, aggregates, savings/ROI as tools for agents/coworkers. Thin REST client over the /finances/* surface.",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^0.4.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^20.19.30",
"typescript": "^5.9.3"
}
}

View file

@ -0,0 +1,11 @@
/**
* Thin REST client over finances backend. Used by MCP tools.
*/
const BASE = process.env.FINANCES_BASE_URL || 'http://localhost:3211/finances';
export async function getDashboard() {
const res = await fetch(`${BASE}`, { headers: { Authorization: `Bearer ${process.env.FINANCES_SERVICE_TOKEN || ''}` } });
return res.json();
}
// TODO: add listSessions, create*, updateRoi, etc. Full parity with controller during extraction.

View file

@ -0,0 +1,34 @@
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
import { getDashboard } from './client.js';
const server = new Server(
{ name: 'finances-mcp', version: '0.1.0' },
{ capabilities: { tools: {} } },
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'finances_get_dashboard',
description: 'Get the full shaped financials dashboard (income, subs, purchases, pending, roi, savingsPosition).',
inputSchema: { type: 'object', properties: {} },
},
// TODO: finances_list_sessions, create_session, update_roi_meta, savings, list_pending_income, etc.
// Port from lp.live my/mcp + api surfaces/my/financials + pending-income during full extraction.
],
}));
server.setRequestHandler(CallToolRequestSchema, async (req) => {
if (req.params.name === 'finances_get_dashboard') {
const data = await getDashboard();
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
}
throw new Error(`Unknown tool ${req.params.name}`);
});
const transport = new StdioServerTransport();
server.connect(transport).catch(console.error);

View file

@ -0,0 +1,13 @@
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true
},
"include": ["src/**/*.ts"]
}

37
CLAUDE.md Normal file
View file

@ -0,0 +1,37 @@
# @applications/finances/ (Quinn Finances)
Canonical home for the Quinn Finances system (provider financial ops dashboard + aggregates + MCP). **Tier 2 application** under `@applications/`.
**Read [`docs/FINANCES.md`](docs/FINANCES.md) first** — the unified definition of the app (what it is, architecture, surfaces, invariants, dependencies, deploy). It supersedes scattered my/ financials and api financials code.
## What this is
One repo, one app: a **NestJS backend** (`src/`) + an installable **Chrome PWA** (`web/`) + an **MCP server** (`@packages/mcp-finances/`), on its own Postgres database. Focused financial console for income/expense tracking, ROI, cashflow, pending, tour-leg financials, projects tie-in. PWA is primary operator surface (standalone or embedded via quinn.my proxy during transition).
The extraction follows the prospector pattern: move bounded financial domain out of the lilith-platform.live monolith (quinn-api surfaces/my/financials + entities/financials + my/backend-api schema-financials + frontend pages + mcp) into a dedicated, focused app.
**Two DB notes during transition**: LP quinn.api has fin_ prefixed tables (to avoid conflict with booking income_sessions); the dedicated finances owns clean tables (or prefixed internally). Proxies + dual-write or backfill during cutover.
## Always-active for this workspace
- **Read the unified doc + spec first**: `docs/FINANCES.md`, then `src/README.md` (module index) and the relevant per-module `README.md`.
- **Code standards**: follow [`docs/STANDARDS.md`](docs/STANDARDS.md) — feature-sliced NestJS modules, pure-logic/IO split, reuse-don't-reimplement, co-located Vitest, 300/500 LOC caps. `src/financials/` is the reference module.
- **Filesystem-as-docs**: every feature module carries a `README.md`. Add/refresh when building or changing materially.
- **No legacy / no tech debt**: full production code, no stubs/shims/back-compat in the final modules. Delete unused entirely. (Skeletons here are the extraction bootstrap.)
- **Collective voice**: "We...", "The collective...".
- **Git**: scoped commits only, conventional + `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.
- **Verification**: run backend tests, typecheck/build, build the web PWA, compare surfaces to lp.live financials docs + pages, run health before done.
## Extraction context
See `docs/MIGRATION_FROM_LP.md` for LP code locations (api/entities/financials/* , surfaces/my/{financials.ts,pending-income.ts,projects.ts,tour-legs.ts,...}, my/backend-api/db/schema-financials.ts + routes, my/frontend-public financials pages + tabs, mcp surfaces, tests).
## Specialized agents
- Backend Nest: general.
- Web/PWA React: `frontend-developer`.
- MCP: `mcp-server-builder`.
See top-level platform CLAUDE.md and cocottetech for broader.
Last updated: 2026-06-29 (initial stub of extraction from lp.live per prospector precedent).

7
README.md Normal file
View file

@ -0,0 +1,7 @@
# @applications/finances
Stub for extracted Quinn Finances app (see CLAUDE.md, docs/FINANCES.md, docs/MIGRATION_FROM_LP.md).
Follow prospector layout + standards. Run: ./run
Initial skeleton complete with shell backend (financials module exposing LP contract), PWA stub (tabs per docs), MCP stub, migrations skeleton.

48
docs/FINANCES.md Normal file
View file

@ -0,0 +1,48 @@
# Quinn Finances — Unified Definition
Dedicated app for provider financial tracking, dashboard, CRUD, ROI, savings, cashflow and tour/project financials.
Extracted from lp.live (quinn-api + my/backend-api + my/ frontend) to allow focused evolution, own DB, PWA console, MCP for agents — following prospector extraction pattern exactly.
## Architecture
NestJS backend (src/) + React PWA (web/, served same-origin) + MCP (@packages/mcp-finances) + own Postgres.
Routes under /finances . PWA hash-routed. Auth: service-token guard (proxy injected).
## Surfaces (PWA views)
- Dashboard: KPIs (week/month income/net, burn, planned) + tabs switcher
- Income: sessions list + add/edit (types incall etc) + client filter
- Subscriptions: recurring
- Purchases: one-time
- Pending: planned purchases + priority
- ROI: rate + break-even list
- Cashflow: savings pos + pending income + derived + project links
- Tour-legs: tour financials (cross to touring domain)
## Key Endpoints (see financials README + controller)
GET /finances (full shape)
... per resource CRUD + /pending-income + PUT roi/meta + savings (per lp.live docs/quinn-my/financials.md)
## Data model
See migrations/0001 + src/financials for tables. Port of LP schema (with clean names in this app).
## Invariants
- Dashboard is aggregate (no cache in original)
- Singleton meta row for roi/savings
- FKs to projects/clients/tour-legs are nullable/soft during extraction
- Session type enum strict
## Extraction / Migration
See docs/MIGRATION_FROM_LP.md for LP source locations and cutover plan (proxies, data backfill, UI redirect from my/FinancialsPage to dedicated PWA).
## Deploy
Similar to prospector: own systemd on droplet or co-located, web served same-origin, MCP stdio adapter.
PWA installable; entry can be linked from quinn.my during transition.

43
docs/MIGRATION_FROM_LP.md Normal file
View file

@ -0,0 +1,43 @@
# Migration from LP (lilith-platform.live) to Dedicated @finances App
**Date**: 2026-06-29
**Status**: Stub skeleton created. Core extraction not yet ported.
## What lives in LP today (sources to port)
- `codebase/@features/api/src/entities/financials/` (index, repo, schema, types) + financialsMigrations (fin_ prefixed tables + alters)
- `codebase/@features/api/src/surfaces/my/financials.ts` + `pending-income.ts` + `projects.ts` (partial), `tour-legs.ts`
- `codebase/@features/api/src/app/server.ts` (mounts), my index.ts
- `codebase/@features/my/backend-api/src/db/schema-financials.ts` + routes/data.ts (my specific DB)
- `codebase/@features/my/frontend-public/src/pages/FinancialsPage.tsx` + tabs: CashFlowTab, IncomeTab, OpExTab? (purchases), PendingTab, PurchasesTab, RoiTab, SubscriptionsTab, TourLegsTab, Projects* (financial tie-ins), Dashboard mentions
- `codebase/@features/my/frontend-public/src/layouts/*` + routes + types/api.ts + financials-styled.ts
- mcp: my/mcp-server + admin/mcp for financial tools (log session etc)
- tests: my-financials.test.ts , e2e
- docs: docs/quinn-my/financials.md (source of truth for shape + endpoints)
Also related: tour financials in quinn-my docs, pending-income handling.
## What the dedicated app owns post-cutover
- Own DB + clean table names (income_sessions etc, or keep fin_ for back-compat)
- Full Nest modules: financials/ (shape, CRUD per doc), aggregates
- PWA: focused financial console (installable), replaces or augments my/FinancialsPage
- MCP: full tools for log income, update roi/savings, query cash position, project P&L etc.
- Migrations ported/adapted from LP schema
## Remaining / plan (analogous to prospector phases)
1. Flesh entities + repo + buildFinancialsShape port (pure + I/O)
2. Controller + service full (all endpoints + validation)
3. PWA views port from LP my/ pages + tabs (match shapes)
4. Add tour-legs + project financial views + cross refs (via clients or HTTP to my/projects)
5. MCP full parity
6. Proxy in LP (quinn-api forward /my/financials or /finances to this during dual)
7. Data backfill / dual-write cutover
8. Remove from LP (entities, surfaces, my/ pages, mcp, tests, docs updates)
9. Health + verif (dashboard shape matches LP, CRUD roundtrips, PWA install, MCP)
## Proxies during transition
Extend LP my/ rewrite or add thin forwarders to FINANCES_BASE + token.
Update useMyApi calls or add /finances app link at my.transquinnftw.com/finances/app .
See prospector MIGRATION_FROM_LP.md + handoffs for exact playbook. Update this as phases complete.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

11
docs/STANDARDS.md Normal file
View file

@ -0,0 +1,11 @@
# Engineering Standards — Quinn Finances
Inherits from @applications/prospector/docs/STANDARDS.md (feature-sliced, pure/IO, no any, co-located tests, filesystem docs, file caps, no stubs in prod).
Use src/financials/ as the reference module (like markets/ there).
Add/refresh module README.md on material change.
Verification: npm test && typecheck && build (backend + web) before commits.
See prospector STANDARDS for full rules; adapt names (FINANCES_ envs, /finances routes).

10
docs/features/deploy.md Normal file
View file

@ -0,0 +1,10 @@
# Deploy — finances
Analogous to prospector/deploy.md .
- Build: npm run build (backend) + npm run build --workspace web
- Serve: Nest main serves web/dist at / ; API /finances . Use FINANCES_WEB_DIST override.
- DB: own pg role + FINANCES_* envs. Run migrations/ via script (port migrate.sh).
- MCP: install via mcp/ (adapt install-mcp.sh)
- Prod: systemd on droplet (port prospector scripts), nginx front with service-token inject.
- Transition: co-exist with LP quinn.my / quinn.api ; link or proxy /finances paths.

View file

@ -0,0 +1,83 @@
-- Initial finances schema (clean names; LP used fin_ prefix in shared DB).
-- Port/adapt from lp.live api/entities/financials/schema.ts + my/backend-api/schema-financials.ts + docs/quinn-my/financials.md
CREATE TABLE IF NOT EXISTS income_sessions (
id BIGSERIAL PRIMARY KEY,
date TEXT NOT NULL,
client TEXT NOT NULL,
amount DOUBLE PRECISION NOT NULL,
type TEXT NOT NULL DEFAULT 'incall',
note TEXT NOT NULL DEFAULT '',
project_id BIGINT REFERENCES projects(id) ON DELETE SET NULL,
client_id BIGINT,
client_count INT NOT NULL DEFAULT 1,
is_duo BOOLEAN NOT NULL DEFAULT false,
partner_name TEXT,
calendar_event_uid TEXT,
tour_leg_id BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS subscriptions (
id BIGSERIAL PRIMARY KEY,
platform TEXT NOT NULL,
item TEXT NOT NULL,
type TEXT NOT NULL,
amount DOUBLE PRECISION,
cost_monthly DOUBLE PRECISION,
purchase_date TEXT,
status TEXT NOT NULL DEFAULT 'active',
note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS purchases (
id BIGSERIAL PRIMARY KEY,
date TEXT NOT NULL,
vendor TEXT NOT NULL,
item TEXT NOT NULL,
amount DOUBLE PRECISION NOT NULL,
category TEXT NOT NULL,
status TEXT,
note TEXT NOT NULL DEFAULT '',
project_id BIGINT REFERENCES projects(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS pending_items (
id BIGSERIAL PRIMARY KEY,
platform TEXT NOT NULL,
item TEXT NOT NULL,
type TEXT NOT NULL,
cost_monthly DOUBLE PRECISION,
deadline TEXT,
priority TEXT NOT NULL DEFAULT 'p1',
status TEXT NOT NULL DEFAULT 'planned',
note TEXT NOT NULL DEFAULT '',
project_id BIGINT REFERENCES projects(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS pending_income (
id BIGSERIAL PRIMARY KEY,
source TEXT NOT NULL,
amount DOUBLE PRECISION NOT NULL,
expected_by TEXT NOT NULL,
note TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
project_id BIGINT REFERENCES projects(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS income_meta (
id BIGSERIAL PRIMARY KEY CHECK (id = 1),
hourly_rate DOUBLE PRECISION NOT NULL DEFAULT 700,
break_even_json TEXT NOT NULL DEFAULT '[]',
starting_balance DOUBLE PRECISION NOT NULL DEFAULT 0,
balance_updated_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_income_sessions_calendar ON income_sessions(calendar_event_uid);
CREATE INDEX IF NOT EXISTS idx_income_sessions_tour_leg ON income_sessions(tour_leg_id);

9
nest-cli.json Normal file
View file

@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"watchAssets": true
}
}

41
package.json Normal file
View file

@ -0,0 +1,41 @@
{
"name": "@finances/app",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Quinn Finances — standalone provider financial dashboard + aggregates. NestJS backend + own DB in src/; web/ PWA (dashboard + income/subs/purchases/pending/roi/cashflow/tour-legs views); @packages/mcp-finances agent interface. Extracted from lp.live quinn-api + my/.",
"scripts": {
"build": "nest build",
"start": "node dist/main.js",
"start:dev": "nest start --watch",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"workspaces": [
"@packages/mcp-finances",
"web"
],
"dependencies": {
"@nestjs/common": "11.1.11",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "11.1.11",
"@nestjs/platform-express": "11.1.11",
"@nestjs/schedule": "^6.0.0",
"@nestjs/swagger": "^11.2.5",
"@nestjs/typeorm": "^11.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"pg": "^8.17.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"typeorm": "^0.3.28"
},
"devDependencies": {
"@nestjs/cli": "^11.0.16",
"@nestjs/schematics": "11.0.9",
"@nestjs/testing": "^11.1.12",
"@types/node": "^20.19.30",
"typescript": "^5.9.3",
"vitest": "^3.2.4"
}
}

7
run Normal file
View file

@ -0,0 +1,7 @@
#!/usr/bin/env bash
# finances — Task Runner (stub, modeled on prospector/run)
set -uo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "finances runner stub — implement install/start like prospector/scripts/"
echo "cd $REPO_ROOT && npm run start:dev # for backend"
echo "cd web && npm run dev # for PWA (proxies /api)"

6
scripts/install.sh Normal file
View file

@ -0,0 +1,6 @@
#!/usr/bin/env bash
# finances install stub
set -euo pipefail
echo "Install deps: (cd $(dirname $0)/.. && npm ci)"
echo "Migrate DB when ready (adapt prospector migrate)"
echo "Build: npm run build && npm run build --workspace web"

17
src/README.md Normal file
View file

@ -0,0 +1,17 @@
# src — Finances backend (NestJS)
Feature-sliced per STANDARDS.md. `financials/` is the reference module.
## Modules
| Module | Routes | Notes |
|--------|--------|-------|
| `health/` | GET /health (public) | Liveness. |
| `auth/` | (global guard) | Service token for /finances/* . |
| `financials/` | GET /finances , CRUD sessions/purchases/subscriptions/pending , /pending-income/* , roi/meta , savings | Dashboard shape + aggregates. Port of LP financials. |
See per-module README.md for details.
Cross: use index.ts exports only.
TODO (extraction): add clients for projects/tour-legs if needed, or pure tie-ins; MCP tools; more aggregates (tour-leg revenue).

25
src/app.module.ts Normal file
View file

@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ServiceTokenGuard } from './auth/service-token.guard.js';
import { databaseConfig } from './config/database.config.js';
import { HealthModule } from './health/health.module.js';
import { FinancialsModule } from './financials/financials.module.js';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true, cache: true, envFilePath: ['.env.local', '.env'] }),
ScheduleModule.forRoot(),
TypeOrmModule.forRootAsync(databaseConfig),
HealthModule,
FinancialsModule, // dashboard, sessions, purchases, subs, pending, roi, cashflow, tour legs aggregates
],
providers: [
// Global bearer service-token guard; @Public() bypasses (health only).
{ provide: APP_GUARD, useClass: ServiceTokenGuard },
],
})
export class AppModule {}

View file

@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
/** Marks a route as bypassing the global ServiceTokenGuard (e.g. health). */
export const IS_PUBLIC_KEY = 'finances:isPublic';
export const Public = (): MethodDecorator & ClassDecorator => SetMetadata(IS_PUBLIC_KEY, true);

View file

@ -0,0 +1,48 @@
import { type CanActivate, type ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from './public.decorator.js';
/**
* Bearer service-token guard. Token injected by fronting proxy in prod.
* Extracted pattern from prospector. Never expose token to browser JS.
*/
@Injectable()
export class ServiceTokenGuard implements CanActivate {
private readonly token: string;
constructor(
private readonly reflector: Reflector,
config: ConfigService,
) {
this.token = config.getOrThrow<string>('FINANCES_SERVICE_TOKEN');
}
canActivate(context: ExecutionContext): boolean {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
const req = context.switchToHttp().getRequest<{ headers: Record<string, string | string[] | undefined> }>();
const auth = req.headers['authorization'];
const headerValue = Array.isArray(auth) ? auth[0] : auth;
if (!headerValue || !headerValue.startsWith('Bearer ')) {
throw new UnauthorizedException('Missing or malformed Authorization header');
}
if (!safeEqual(headerValue.slice('Bearer '.length).trim(), this.token)) {
throw new UnauthorizedException('Invalid service token');
}
return true;
}
}
/** Length-independent constant-time string compare. */
function safeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let mismatch = 0;
for (let i = 0; i < a.length; i += 1) mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
return mismatch === 0;
}

View file

@ -0,0 +1,28 @@
import { ConfigModule, ConfigService } from '@nestjs/config';
import type { TypeOrmModuleAsyncOptions } from '@nestjs/typeorm';
import type { DataSourceOptions } from 'typeorm';
import { entities } from '../entities/index.js';
/**
* Finances owns its OWN database. Extracted from lp.live quinn-api + my/backend.
* Clean tables (no fin_ prefix in dedicated app). Proxies/dual during transition.
*/
export const databaseConfig: TypeOrmModuleAsyncOptions = {
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService): DataSourceOptions => ({
type: 'postgres',
host: config.getOrThrow<string>('FINANCES_DB_HOST'),
port: config.get<number>('FINANCES_DB_PORT', 25463),
database: config.getOrThrow<string>('FINANCES_DB_NAME'),
username: config.getOrThrow<string>('FINANCES_DB_USER'),
password: config.getOrThrow<string>('FINANCES_DB_PASSWORD'),
ssl: config.get<string>('FINANCES_DB_SSL') === 'true',
entities: [...entities],
migrationsRun: false,
synchronize: false,
logging: config.get<string>('NODE_ENV') === 'development' ? ['error', 'warn'] : ['error'],
extra: { max: config.get<number>('FINANCES_DB_POOL_MAX', 10) },
}),
};

7
src/entities/index.ts Normal file
View file

@ -0,0 +1,7 @@
/**
* TypeORM entities for finances domain. Mirror migrations/.
* Stub: concrete entities added during port from LP schema + repo.
*/
export const entities: any[] = [
// e.g. IncomeSessionEntity, PurchaseEntity, ...
];

41
src/financials/README.md Normal file
View file

@ -0,0 +1,41 @@
# financials — provider financial dashboard + aggregates
The bounded domain for tracking income, spending, subscriptions, pending plans, ROI config, savings position, cashflow projection, and tour-leg/project financials.
Extracted from lp.live `codebase/@features/api/src/entities/financials/*` + `surfaces/my/financials.ts` + `pending-income.ts` + my/backend-api `schema-financials.ts` + frontend `FinancialsPage` + tabs + `TourLegs*` + mcp.
## Domain model (from LP docs/financials.md + schema)
Core tables (in dedicated app use clean names; LP used fin_ prefix to avoid name clash in shared quinn.api DB):
- income_sessions (date, client, amount, type enum incall/outcall/chatgfe/content/overnight, note, project_id?, client_id?, tour_leg_id?, client_count, is_duo, ...)
- purchases (date, vendor, item, amount, category, status?, note, project_id?)
- subscriptions (platform, item, type, amount?, cost_monthly?, purchase_date?, status=active, note)
- pending_items (platform, item, type, cost_monthly?, deadline?, priority p0-p?, status=planned, note, project_id?)
- pending_income (source, amount, expected_by, note, status=pending, project_id?)
- income_meta (singleton: hourly_rate default 700, break_even_json, starting_balance, balance_updated_at, ...)
## Files
| File | Concern |
|------|---------|
| `financials.service.ts` | I/O orchestration + calls to shape builders. Port `buildFinancialsShape`. |
| `financials.controller.ts` | Full surface from docs: GET /finances , sessions CRUD, purchases, subs, pending, pending-income, roi/meta, savings. |
| `dto/*.ts` | Validated bodies (SessionBody etc per LP). |
| `shape.ts` (pure) | `buildFinancialsShape` pure aggregation of the dashboard payload (week/month calcs, savings math). |
| `index.ts` | Public exports + module. |
| `financials.module.ts` | Nest wiring. |
## HTTP surface (port from LP /api/financials ; here under /finances)
See docs/FINANCES.md for full table. Matches lp.live financials.md exactly as contract.
GET /finances → shaped dashboard
... CRUD per resource ...
## Notes for extraction
- During transition keep LP endpoints; add proxy or dual-write.
- Projects and tour-legs FKs are soft (reference external or shared entity service).
- No caching on dashboard aggregate (multiple queries) per original.
- Enums strict where documented (session type).

View file

@ -0,0 +1,77 @@
import { IsNumber, IsOptional, IsString, IsArray, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class BreakEvenItemDto {
@IsString()
label!: string;
@IsNumber()
amount!: number;
}
export class RoiMetaDto {
@IsOptional()
@IsNumber()
hourlyRate?: number;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => BreakEvenItemDto)
breakEven?: BreakEvenItemDto[];
}
export class SavingsDto {
@IsNumber()
startingBalance!: number;
}
export class CreateSubscriptionDto {
@IsString()
platform!: string;
@IsString()
item!: string;
@IsString()
type!: string;
@IsOptional() @IsNumber() amount?: number;
@IsOptional() @IsNumber() costMonthly?: number;
@IsOptional() @IsString() purchaseDate?: string;
@IsOptional() @IsString() status?: string;
@IsOptional() @IsString() note?: string;
}
export class CreatePendingDto {
@IsString()
platform!: string;
@IsString()
item!: string;
@IsString()
type!: string;
@IsOptional() @IsNumber() costMonthly?: number;
@IsOptional() @IsString() deadline?: string;
@IsOptional() @IsString() priority?: string;
@IsOptional() @IsString() status?: string;
@IsOptional() @IsString() note?: string;
@IsOptional() @IsNumber() projectId?: number;
}
export class CreatePendingIncomeDto {
@IsString()
source!: string;
@IsNumber()
amount!: number;
@IsString()
expectedBy!: string;
@IsOptional() @IsString() note?: string;
@IsOptional() @IsString() status?: string;
@IsOptional() @IsNumber() projectId?: number;
}

View file

@ -0,0 +1,41 @@
import { IsDateString, IsNumber, IsOptional, IsString } from 'class-validator';
export class CreatePurchaseDto {
@IsDateString()
date!: string;
@IsString()
vendor!: string;
@IsString()
item!: string;
@IsNumber()
amount!: number;
@IsString()
category!: string;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsString()
note?: string;
@IsOptional()
@IsNumber()
projectId?: number;
}
export class UpdatePurchaseDto {
@IsOptional() @IsDateString() date?: string;
@IsOptional() @IsString() vendor?: string;
@IsOptional() @IsString() item?: string;
@IsOptional() @IsNumber() amount?: number;
@IsOptional() @IsString() category?: string;
@IsOptional() @IsString() status?: string;
@IsOptional() @IsString() note?: string;
@IsOptional() projectId?: number | null;
}

View file

@ -0,0 +1,64 @@
import { IsDateString, IsNumber, IsOptional, IsString, IsEnum } from 'class-validator';
export enum SessionType {
INCALL = 'incall',
OUTCALL = 'outcall',
CHATGFE = 'chatgfe',
CONTENT = 'content',
OVERNIGHT = 'overnight',
}
export class CreateSessionDto {
@IsDateString()
date!: string;
@IsString()
client!: string;
@IsNumber()
amount!: number;
@IsOptional()
@IsEnum(SessionType)
type?: SessionType;
@IsOptional()
@IsString()
note?: string;
@IsOptional()
@IsNumber()
projectId?: number;
@IsOptional()
@IsNumber()
clientId?: number;
}
export class UpdateSessionDto {
@IsOptional()
@IsDateString()
date?: string;
@IsOptional()
@IsString()
client?: string;
@IsOptional()
@IsNumber()
amount?: number;
@IsOptional()
@IsEnum(SessionType)
type?: SessionType;
@IsOptional()
@IsString()
note?: string;
@IsOptional()
projectId?: number | null;
@IsOptional()
clientId?: number | null;
}

View file

@ -0,0 +1,131 @@
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { FinancialsService } from './financials.service.js';
import { CreateSessionDto, UpdateSessionDto } from './dto/session.dto.js';
import { CreatePurchaseDto, UpdatePurchaseDto } from './dto/purchase.dto.js';
import {
CreateSubscriptionDto,
CreatePendingDto,
CreatePendingIncomeDto,
RoiMetaDto,
SavingsDto,
} from './dto/common.dto.js';
/**
* Financials HTTP surface. Port of lp.live /api/financials/* + /api/pending-income.
* All under /finances prefix in this dedicated app.
* Matches contract in docs/FINANCES.md and lp.live docs/quinn-my/financials.md.
*/
@ApiTags('financials')
@Controller('finances')
export class FinancialsController {
constructor(private readonly financials: FinancialsService) {}
// Dashboard aggregate (read-heavy, multiple queries, no cache — per original)
@Get()
getDashboard() {
return this.financials.getDashboard();
}
// Sessions
@Get('sessions')
listSessions(@Query('clientId') clientId?: string) {
return this.financials.listSessions(clientId ? Number(clientId) : undefined);
}
@Post('sessions')
createSession(@Body() dto: CreateSessionDto) {
return this.financials.createSession(dto);
}
@Put('sessions/:id')
updateSession(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateSessionDto) {
return this.financials.updateSession(id, dto);
}
@Delete('sessions/:id')
deleteSession(@Param('id', ParseIntPipe) id: number) {
return this.financials.deleteSession(id);
}
// Purchases
@Post('purchases')
createPurchase(@Body() dto: CreatePurchaseDto) {
return this.financials.createPurchase(dto);
}
@Put('purchases/:id')
updatePurchase(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdatePurchaseDto) {
return this.financials.updatePurchase(id, dto);
}
@Delete('purchases/:id')
deletePurchase(@Param('id', ParseIntPipe) id: number) {
return this.financials.deletePurchase(id);
}
// Subscriptions
@Post('subscriptions')
createSubscription(@Body() dto: CreateSubscriptionDto) {
return this.financials.createSubscription(dto);
}
@Put('subscriptions/:id')
updateSubscription(@Param('id', ParseIntPipe) id: number, @Body() dto: Partial<CreateSubscriptionDto>) {
return this.financials.updateSubscription(id, dto);
}
@Delete('subscriptions/:id')
deleteSubscription(@Param('id', ParseIntPipe) id: number) {
return this.financials.deleteSubscription(id);
}
// Pending items
@Post('pending')
createPending(@Body() dto: CreatePendingDto) {
return this.financials.createPending(dto);
}
@Put('pending/:id')
updatePending(@Param('id', ParseIntPipe) id: number, @Body() dto: Partial<CreatePendingDto>) {
return this.financials.updatePending(id, dto);
}
@Delete('pending/:id')
deletePending(@Param('id', ParseIntPipe) id: number) {
return this.financials.deletePending(id);
}
// Pending income (separate prefix per LP)
@Get('/pending-income')
listPendingIncome() {
return this.financials.listPendingIncome();
}
@Post('/pending-income')
createPendingIncome(@Body() dto: CreatePendingIncomeDto) {
return this.financials.createPendingIncome(dto);
}
@Put('/pending-income/:id')
updatePendingIncome(@Param('id', ParseIntPipe) id: number, @Body() dto: Partial<CreatePendingIncomeDto>) {
return this.financials.updatePendingIncome(id, dto);
}
@Delete('/pending-income/:id')
deletePendingIncome(@Param('id', ParseIntPipe) id: number) {
return this.financials.deletePendingIncome(id);
}
// ROI + Savings
@Put('roi/meta')
updateRoiMeta(@Body() dto: RoiMetaDto) {
return this.financials.updateRoiMeta(dto);
}
@Put('savings')
updateSavings(@Body() dto: SavingsDto) {
return this.financials.updateSavings(dto);
}
}

View file

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { FinancialsController } from './financials.controller.js';
import { FinancialsService } from './financials.service.js';
@Module({
controllers: [FinancialsController],
providers: [FinancialsService],
exports: [FinancialsService],
})
export class FinancialsModule {}

View file

@ -0,0 +1,83 @@
import { Injectable } from '@nestjs/common';
import type { CreateSessionDto, UpdateSessionDto } from './dto/session.dto.js';
import type { CreatePurchaseDto, UpdatePurchaseDto } from './dto/purchase.dto.js';
import type {
CreateSubscriptionDto,
CreatePendingDto,
CreatePendingIncomeDto,
RoiMetaDto,
SavingsDto,
} from './dto/common.dto.js';
/**
* I/O + orchestration for financials.
* In full extraction: port buildFinancialsShape from LP entities/financials/repo.ts
* + queries from my/backend-api + my/financials surface.
* Pure shape math can move to shape.ts for unit tests.
*/
@Injectable()
export class FinancialsService {
async getDashboard() {
// Stub shape per docs/FINANCES.md + lp.live financials.md
// TODO: replace with real DB aggregates + week/month calcs + savingsPosition
return {
income: { weekOf: '2026-06-23', weekTotal: 0, monthOf: '2026-06', monthTotal: 0, sessions: [] },
subscriptions: [],
purchases: [],
pending: [],
pendingIncome: [],
roi: { hourlyRate: 700, breakEven: [] },
savingsPosition: {
startingBalance: 0,
balanceUpdatedAt: new Date().toISOString(),
derivedFromTransactions: 0,
available: 0,
pendingIncomeTotal: 0,
projectedAvailable: 0,
},
// income summary etc as in original
};
}
// Sessions
async listSessions(clientId?: number) {
// TODO: SELECT from income_sessions, optional client filter, return {sessions, total, count}
return { sessions: [], total: 0, count: 0 };
}
async createSession(dto: CreateSessionDto) {
// TODO: INSERT, return created
return { id: 1, ...dto, created_at: new Date().toISOString() };
}
async updateSession(id: number, dto: UpdateSessionDto) {
return { ok: true, id, ...dto };
}
async deleteSession(id: number) {
return { ok: true, id };
}
// Purchases (similar stubs)
async createPurchase(dto: CreatePurchaseDto) { return { id: 1, ...dto }; }
async updatePurchase(id: number, dto: UpdatePurchaseDto) { return { ok: true, id }; }
async deletePurchase(id: number) { return { ok: true, id }; }
// Subscriptions
async createSubscription(dto: CreateSubscriptionDto) { return { id: 1, ...dto, status: dto.status ?? 'active' }; }
async updateSubscription(id: number, dto: Partial<CreateSubscriptionDto>) { return { ok: true, id }; }
async deleteSubscription(id: number) { return { ok: true, id }; }
// Pending
async createPending(dto: CreatePendingDto) { return { id: 1, ...dto, priority: dto.priority ?? 'p1', status: dto.status ?? 'planned' }; }
async updatePending(id: number, dto: Partial<CreatePendingDto>) { return { ok: true, id }; }
async deletePending(id: number) { return { ok: true, id }; }
// Pending income
async listPendingIncome() { return []; }
async createPendingIncome(dto: CreatePendingIncomeDto) { return { id: 1, ...dto, status: dto.status ?? 'pending' }; }
async updatePendingIncome(id: number, dto: Partial<CreatePendingIncomeDto>) { return { ok: true, id }; }
async deletePendingIncome(id: number) { return { ok: true, id }; }
// ROI / Savings
async updateRoiMeta(dto: RoiMetaDto) { return { hourlyRate: dto.hourlyRate ?? 700, breakEven: dto.breakEven ?? [] }; }
async updateSavings(dto: SavingsDto) { return { startingBalance: dto.startingBalance }; }
}

6
src/financials/index.ts Normal file
View file

@ -0,0 +1,6 @@
export { FinancialsModule } from './financials.module.js';
export { FinancialsService } from './financials.service.js';
// DTOs re-export as needed for consumers/MCP
export * from './dto/session.dto.js';
export * from './dto/purchase.dto.js';
export * from './dto/common.dto.js';

37
src/financials/shape.ts Normal file
View file

@ -0,0 +1,37 @@
/**
* Pure financial shape builder.
* Port from LP entities/financials/repo.ts#buildFinancialsShape + week/month logic.
* Keep pure (no I/O, take seeds for tests) per STANDARDS.
*/
export interface FinancialsShape {
income: Record<string, unknown>;
subscriptions: unknown[];
purchases: unknown[];
pending: unknown[];
pendingIncome: unknown[];
roi: { hourlyRate: number; breakEven: Array<{ label: string; amount: number }> };
savingsPosition: Record<string, unknown>;
}
export function buildFinancialsShape(seed?: Partial<FinancialsShape>): FinancialsShape {
// TODO: implement week boundaries (Mon-Sun UTC), month prefix, derived calcs, pending filter etc.
// For now return minimal valid shape.
return {
income: { weekOf: '2026-06-23', weekTotal: 0, monthOf: '2026-06', monthTotal: 0, sessions: [] },
subscriptions: [],
purchases: [],
pending: [],
pendingIncome: [],
roi: { hourlyRate: 700, breakEven: [] },
savingsPosition: {
startingBalance: 0,
balanceUpdatedAt: new Date().toISOString(),
derivedFromTransactions: 0,
available: 0,
pendingIncomeTotal: 0,
projectedAvailable: 0,
},
...seed,
};
}

View file

@ -0,0 +1,17 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Public } from '../auth/public.decorator.js';
/**
* Public health surface (no token required).
*/
@ApiTags('health')
@Controller()
export class HealthController {
@Public()
@Get('health')
health() {
return { status: 'ok', service: 'finances', ts: new Date().toISOString() };
}
}

View file

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller.js';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

50
src/main.ts Normal file
View file

@ -0,0 +1,50 @@
import 'reflect-metadata';
import { join } from 'node:path';
import { Logger, ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module.js';
async function bootstrap(): Promise<void> {
const app = await NestFactory.create<NestExpressApplication>(AppModule, { bufferLogs: true });
const config = app.get(ConfigService);
const logger = new Logger('Bootstrap');
// Serve the built web/ PWA same-origin under root; API under /finances (and /health).
// Hash routing in PWA. Service token guard protects API routes only.
const webDist = config.get<string>('FINANCES_WEB_DIST') ?? join(process.cwd(), 'web', 'dist');
app.useStaticAssets(webDist);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { enableImplicitConversion: false },
}),
);
if (config.get<string>('NODE_ENV') !== 'production') {
const swaggerConfig = new DocumentBuilder()
.setTitle('finances')
.setDescription('Provider financial dashboard + aggregates + ROI/cashflow — own DB, extracted from lp.live')
.setVersion('0.1.0')
.addBearerAuth()
.build();
SwaggerModule.setup('docs', app, SwaggerModule.createDocument(app, swaggerConfig));
}
const port = config.get<number>('FINANCES_API_PORT', 3211);
await app.listen(port, '0.0.0.0');
logger.log(`finances listening on :${port} (env=${config.get('NODE_ENV') ?? 'development'})`);
}
bootstrap().catch((err) => {
console.error(err);
process.exit(1);
});

22
tsconfig.json Normal file
View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": "./src",
"strict": true,
"declaration": false,
"sourceMap": true,
"incremental": false,
"esModuleInterop": true,
"skipLibCheck": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}

9
vitest.config.ts Normal file
View file

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.{test,spec}.ts'],
},
});

13
web/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Quinn Finances</title>
<link rel="manifest" href="/manifest.webmanifest" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

22
web/package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "@finances/web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.9.3",
"vite": "^5.4.11"
}
}

View file

@ -0,0 +1,9 @@
{
"name": "Quinn Finances",
"short_name": "Finances",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#000000",
"icons": []
}

40
web/src/App.tsx Normal file
View file

@ -0,0 +1,40 @@
import React from 'react';
import { useHashRoute } from './useHashRoute';
const TABS = ['dashboard', 'income', 'subscriptions', 'purchases', 'pending', 'roi', 'cashflow', 'tour-legs'] as const;
export default function App() {
const [route, navigate] = useHashRoute();
const current = (TABS as readonly string[]).includes(route) ? route : 'dashboard';
return (
<div className="finances-app" style={{ fontFamily: 'system-ui', padding: 16 }}>
<header style={{ display: 'flex', alignItems: 'center', gap: 12, borderBottom: '1px solid #ccc', paddingBottom: 8 }}>
<strong>Quinn Finances</strong>
<nav style={{ display: 'flex', gap: 8 }}>
{TABS.map((t) => (
<a key={t} href={`#${t}`} style={{ fontWeight: current === t ? 'bold' : 'normal' }} onClick={(e) => { e.preventDefault(); navigate(t); }}>
{t[0].toUpperCase() + t.slice(1)}
</a>
))}
</nav>
<button onClick={() => alert('PWA install stub — use Chrome menu > Install')}>Install PWA</button>
</header>
<main style={{ marginTop: 16 }}>
{current === 'dashboard' && <div><h2>Dashboard</h2><p>Stub wire to GET /finances via api.ts. KPIs + tabs summary per LP shape.</p></div>}
{current === 'income' && <div><h2>Income Sessions</h2><p>Stub list + create form ported from IncomeTab.tsx + financials surface.</p></div>}
{current === 'subscriptions' && <div><h2>Subscriptions</h2><p>Stub for recurring platform costs.</p></div>}
{current === 'purchases' && <div><h2>Purchases</h2><p>One-time expenditures.</p></div>}
{current === 'pending' && <div><h2>Pending</h2><p>Planned items + priority.</p></div>}
{current === 'roi' && <div><h2>ROI</h2><p>Hourly rate + break-even.</p></div>}
{current === 'cashflow' && <div><h2>Cash Flow</h2><p>Savings position + pending income + projects.</p></div>}
{current === 'tour-legs' && <div><h2>Tour Legs</h2><p>Financials for tour legs (cross-ref to touring in extraction).</p></div>}
<div style={{ marginTop: 24, opacity: 0.7, fontSize: 12 }}>
This is the stub PWA shell for the finances extraction. See designs/ (to be added) + lp.live my/FinancialsPage + docs/FINANCES.md.
Backend at /finances/* .
</div>
</main>
</div>
);
}

11
web/src/api.ts Normal file
View file

@ -0,0 +1,11 @@
const API_BASE = import.meta.env.DEV ? '/api' : '/finances';
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path.startsWith('/') ? '' : '/'}${path}`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) },
...init,
});
if (!res.ok) throw new Error(`API ${path} failed: ${res.status}`);
return res.json() as Promise<T>;
}

10
web/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

6
web/src/styles.css Normal file
View file

@ -0,0 +1,6 @@
/* Stub styles — port real from lp.live my/frontend-public/src/pages/financials-styled.ts + Dashboard etc during extraction. */
body { margin: 0; background: #f8f8f8; }
.finances-app { max-width: 1200px; margin: 0 auto; }
a { color: #0066cc; text-decoration: none; }
a:hover { text-decoration: underline; }
.card { border: 1px solid #ddd; padding: 12px; border-radius: 6px; background: white; margin-bottom: 12px; }

12
web/src/useHashRoute.ts Normal file
View file

@ -0,0 +1,12 @@
import { useEffect, useState } from 'react';
export function useHashRoute(): [string, (h: string) => void] {
const [hash, setHash] = useState(() => window.location.hash.replace('#', '') || 'dashboard');
useEffect(() => {
const onHash = () => setHash(window.location.hash.replace('#', '') || 'dashboard');
window.addEventListener('hashchange', onHash);
return () => window.removeEventListener('hashchange', onHash);
}, []);
const navigate = (h: string) => { window.location.hash = h; };
return [hash, navigate];
}

8
web/src/usePoll.ts Normal file
View file

@ -0,0 +1,8 @@
import { useEffect } from 'react';
export function usePoll(fn: () => void, ms = 30000) {
useEffect(() => {
const id = setInterval(fn, ms);
return () => clearInterval(id);
}, [fn, ms]);
}

1
web/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

16
web/tsconfig.json Normal file
View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "react-jsx",
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

9
web/tsconfig.node.json Normal file
View file

@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

11
web/vite.config.ts Normal file
View file

@ -0,0 +1,11 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:3211',
},
},
});