diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2808f2c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +# Keep the build context small + deterministic. node_modules, build output, and +# the migration venv are all recreated inside the image, never copied from host. +**/node_modules +**/dist +**/.next +**/.turbo +apps/web/.next +packages/database/generated +migration/.venv +migration/**/__pycache__ +**/*.log +.git +.idea +.env +.env.* +!.env.example diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..ba2bc9b --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,91 @@ +# Build + push the API and web container images to the git.mancinas.io registry. +# +# Two images from this one repo: +# git.mancinas.io/rmancinas/jorgecuadros-api +# git.mancinas.io/rmancinas/jorgecuadros-web +# +# Comprehensive versioning (docker/metadata-action). Every build pushes a set +# of tags so an image is addressable at several granularities: +# - vX.Y.Z / vX.Y when the trigger is a git tag vX.Y.Z (releases) +# - the branch that was pushed (e.g. master, feat-foo) +# - sha- immutable per-commit id, always present +# - latest only on the default branch (master) +# The same version string + commit + build date are baked into the image as +# ARG/ENV (APP_VERSION / GIT_SHA / BUILD_DATE) and as OCI labels, so a running +# container can report exactly what is deployed. +# +# Release flow: git tag v1.2.0 && git push origin v1.2.0 -> versioned images. + +name: Build and Push Images + +on: + push: + branches: [master] + tags: ["v*"] + paths: + - "apps/**" + - "packages/**" + - "docker/**" + - "package.json" + - "pnpm-lock.yaml" + - ".gitea/workflows/build.yml" + workflow_dispatch: + +env: + REGISTRY: git.mancinas.io + +jobs: + build: + name: Build ${{ matrix.image }} + runs-on: docker + container: + image: docker:27-dind + options: --privileged + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - image: jorgecuadros-api + dockerfile: docker/api.Dockerfile + - image: jorgecuadros-web + dockerfile: docker/web.Dockerfile + steps: + - name: Install Node.js for actions + run: apk add --no-cache nodejs npm + + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=ref,event=branch + type=sha,format=short,prefix=sha- + type=raw,value=latest,enable={{is_default_branch}} + + - uses: docker/build-push-action@v5 + with: + context: . + file: ${{ matrix.dockerfile }} + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + platforms: linux/amd64 + build-args: | + APP_VERSION=${{ steps.meta.outputs.version }} + GIT_SHA=${{ github.sha }} + BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }} diff --git a/.gitignore b/.gitignore index 2936def..5a6ff02 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ build/ *.log migration/output/ migration/.venv/ +migration/ingest/ +migration/backups/ __pycache__/ *.pyc packages/database/generated/ diff --git a/PLAN.md b/PLAN.md index 1f8431c..84de7b0 100644 --- a/PLAN.md +++ b/PLAN.md @@ -128,7 +128,7 @@ Given the amount of near-duplicate/overlapping data across snapshot tables (mult 6. Shared billing/statements module (the payoff: one statement per customer spanning both utility and insurance transactions) — **DONE**. `apps/api/src/billing/` + web `/estado-cuenta` and `/estado-cuenta/[id]`. Two questions, two views: a per-customer **balances worklist** (who owes what) and a cross-customer **movement browser** (every charge and credit, filterable by line, concept, origin table and date range, with totals for the whole filtered set). The detail page is the actual statement: balance per currency, the same balance split by business line, charges broken out by concept, and the full movement list with a running balance. **Design constraint that shapes the whole module: balances are reported per currency and never collapsed into one number.** 912 of the 1,269 customers with a ledger move in both MXN and USD, the charge side is MXN-only while receipts arrive in both, and the legacy data never stored the exchange rate applied to a movement — so a single "total balance" would be a figure that never existed in the books. 7. Bank register module (`bank_transactions`/`business_line_categories` from SCOTHIA) — small, self-contained, and has no customer FK, so it can slot in independently once the core migration pipeline exists; low risk, low priority relative to the customer-facing modules. 8. VPS provisioning + Tailscale + MySQL replication setup. `utility_dbo`'s schema is now available (full dump on disk — 55 tables; see Status), so the exact replicated table/column set and inbox-table shape can be finalized against the real portal DB and the portal PHP code (`my-jorgecuadros-web`) that reads/writes it. -9. Sync worker (push replicated tables' relevant subset, poll inbox tables for payment/propane submissions) — depends on step 8. Portal write points confirmed present in `utility_dbo`: `peticion_gas` (propane requests), PayPal payment writes, `notifications_settings`, `verification_codes` — these define the VPS→internal inbox set. +9. Sync worker (push replicated tables' relevant subset, poll inbox tables for payment/propane submissions) — depends on step 8. **The separate Phase B Access additive sync is implemented:** `migration/run_all.py --sync` and the admin `SYNC` job upsert legacy-owned rows without truncating the database or touching manual rows. Portal write points confirmed present in `utility_dbo`: `peticion_gas` (propane requests), PayPal payment writes, `notifications_settings`, `verification_codes` — these define the VPS→internal inbox set. 10. Reports/email campaigns/admin — parity with old app's `reports.php`/`emailCampaigns.php` intent, rebuilt properly. ## Status @@ -146,6 +146,9 @@ Repo scaffolded at `jorgecuadros-platform/`: npm workspaces, NestJS API with a r - **i18n:** **Spanish-first** UI — matches the source data and staff usage. - **CI/CD:** **Gitea Actions** on `git.mancinas.io` — build image, push to the git.mancinas.io container registry, deploy to Portainer (mirrors the `portainer-gitea-deploy` pattern already used on this LAN). Jenkins/`git.freakma.com` dropped. - **`utility_dbo`:** resolved — full dump + portal code available (see Status). +- **Phase B Access additive sync:** implemented in `migration/run_all.py --sync` and the admin + `SYNC` job. It preserves manual rows and stable legacy-owned primary keys; end-to-end database + validation remains before production use. ## Open items (ops, not design) @@ -156,5 +159,7 @@ Repo scaffolded at `jorgecuadros-platform/`: npm workspaces, NestJS API with a r - Migration: automated row-count/sum reconciliation between `staging` and final schema per table group (see step 5 above), run as part of the migration script, not a manual spot-check. - App: standard NestJS unit/integration tests per module (auth guards, Prisma queries), Playwright/Cypress e2e for the core "look up a customer, see their unified policies + services + statement" flow — the thing the whole project exists to deliver. -- Sync: once the VPS replica and inbox tables exist, verify replication lag stays low (a few seconds to low minutes) and that a payment/propane submission on the portal reliably shows up in the internal app within one polling interval, before relying on it operationally. +- **Sync:** Phase B Access additive sync now has automated CLI/admin wiring, but must be verified + against a disposable DB with stable-PK, manual-row, update, and source-delete cases. The + separate VPS/portal sync still requires VPS provisioning and inbox-table implementation. - Before cutover: run the new app against migrated data side-by-side with the live Access files for a period, comparing balances/statements for a sample of active customers to catch migration logic errors before the Access files are retired. diff --git a/README.md b/README.md new file mode 100644 index 0000000..dd4de89 --- /dev/null +++ b/README.md @@ -0,0 +1,204 @@ +# Jorge Cuadros & Asociados — Platform + +Internal platform for a Baja California insurance brokerage and property-services +firm: a single expedient joining each client's **properties/services**, +**insurance policies**, **account statement**, and the firm's **checkbook**. +It replaces a legacy PHP/Access app (see `RESUME.md` and `PLAN.md` for the full +history and rebuild rationale). + +The UI is Spanish-first; the codebase and this document are in English. + +--- + +## Stack + +| Layer | Tech | Port | +| --------- | -------------------------------------------------------------- | ---- | +| Web | Next.js 14 (App Router, React 18) | 3000 | +| API | NestJS 10 · Passport local + `express-session` · Argon2 | 3001 | +| Database | MySQL 8 via Prisma 5 (`@jorgecuadros/database` workspace pkg) | 3306 | +| Migration | Python 3 pipeline (legacy Access → staging → transforms) | — | + +Monorepo managed with **pnpm workspaces**. Node **>= 20**. + +--- + +## Repository layout + +``` +apps/ + web/ Next.js frontend (@jorgecuadros/web) + api/ NestJS backend (@jorgecuadros/api) + scripts/seed-user.mjs idempotent admin seeder +packages/ + database/ Prisma schema + generated client (@jorgecuadros/database) + prisma/schema.prisma +migration/ One-off Python ETL from the legacy Access DB (run_all.py) +docker/ Dockerfiles for api + web +docker-compose.yml mysql + api + web +.env.example copy to .env +``` + +API feature modules: `auth`, `users`, `customers`, `policies`, `properties`, +`billing`, `bank`. Web routes: `/clientes`, `/polizas`, `/servicios`, +`/estado-cuenta`, `/banco` (chequera), `/catalogos`, `/usuarios`, `/login`. + +--- + +## Prerequisites + +- Node.js >= 20 and **pnpm** (`npm i -g pnpm`) +- Docker (for MySQL, or bring your own MySQL 8) +- Python 3 — only if you run the legacy data migration + +--- + +## Run it locally (development) + +### 1. Install + +```bash +pnpm install +``` + +pnpm blocks postinstall build scripts by default; the trusted ones +(`argon2`, `prisma`, `@prisma/client`, `@prisma/engines`, `@nestjs/core`) are +allowlisted in `pnpm-workspace.yaml`, so the native builds run automatically. + +### 2. Configure environment + +```bash +cp .env.example .env +``` + +Then edit `.env`. For the Docker MySQL below the defaults already line up; +just set a real `SESSION_SECRET`: + +```env +DATABASE_URL=mysql://jorgecuadros:jorgecuadros@localhost:3306/jorgecuadros +SESSION_SECRET= +WEB_ORIGIN=http://localhost:3000 +NEXT_PUBLIC_API_ORIGIN=http://localhost:3001 +``` + +The API loads `DATABASE_URL`, `SESSION_SECRET`, `WEB_ORIGIN`, and optional +`PORT` (default `3001`). The web app only needs `NEXT_PUBLIC_API_ORIGIN`. + +### 3. Start MySQL + +```bash +docker compose up -d mysql +``` + +(Or point `DATABASE_URL` at an existing MySQL 8 instance.) + +### 4. Create the schema + generate the Prisma client + +The schema is managed with `prisma db push` (no migration history committed): + +```bash +pnpm --filter @jorgecuadros/database exec prisma db push +pnpm --filter @jorgecuadros/database generate +``` + +### 5. Seed a sign-in user + +```bash +node apps/api/scripts/seed-user.mjs +``` + +Idempotent (upsert by email). Defaults — override with `SEED_EMAIL`, +`SEED_PASSWORD`, `SEED_NAME`: + +- email: `admin@jorgecuadros.local` +- password: `ChangeMe!2026` +- role: `ADMIN` + +### 6. Run the apps (two terminals) + +```bash +# API → http://localhost:3001 +pnpm --filter @jorgecuadros/api start:dev + +# Web → http://localhost:3000 +pnpm --filter @jorgecuadros/web dev +``` + +Root shortcuts also exist: `pnpm dev:api`, `pnpm dev:web`. + +### 7. Log in + +Open http://localhost:3000, sign in with the seeded credentials. +Sessions are cookie-based and last 8 hours. + +--- + +## Run it with Docker (full stack) + +Builds MySQL + API + web from `docker-compose.yml`: + +```bash +export SESSION_SECRET=$(openssl rand -hex 32) +docker compose up --build +``` + +Web on http://localhost:3000, API on http://localhost:3001. `SESSION_SECRET` +is required (compose fails without it). After first boot, push the schema and +seed a user against the container DB: + +```bash +docker compose exec api node apps/api/scripts/seed-user.mjs +``` + +--- + +## Common commands + +| Task | Command | +| ------------------------ | -------------------------------------------------------------- | +| Install | `pnpm install` | +| Dev — API | `pnpm dev:api` | +| Dev — Web | `pnpm dev:web` | +| Build all | `pnpm build` | +| Generate Prisma client | `pnpm prisma:generate` | +| Push schema (dev) | `pnpm --filter @jorgecuadros/database exec prisma db push` | +| Prisma Studio | `pnpm prisma:studio` | +| API tests | `pnpm --filter @jorgecuadros/api test` | +| Lint (web / api) | `pnpm --filter @jorgecuadros/web lint` · `... /api lint` | +| Seed admin user | `node apps/api/scripts/seed-user.mjs` | + +--- + +## Auth & roles + +Session auth via Passport local strategy; passwords hashed with Argon2 (no +plaintext, unlike the legacy app). Roles gate the UI and API — e.g. managing +`/catalogos` and `/usuarios` requires the appropriate ability (`ADMIN` / +`MANAGER`). New users are created by an admin in `/usuarios`; the first admin +comes from the seed script above. + +--- + +## Legacy data migration (optional) + +`migration/` holds the one-off Python ETL that lifts data out of the old +Microsoft Access database into MySQL. + +```bash +pip install -r migration/requirements.txt +python migration/run_all.py +``` + +> ⚠️ `run_all.py` truncates and reloads **all** downstream tables. Never run an +> individual `transform_*.py` in isolation — it orphans dependent tables. See +> `migration/RECONCILIATION.md` for details. + +--- + +## Production notes + +- Use `pnpm --filter @jorgecuadros/database exec prisma migrate deploy` if/when + a committed migration history is adopted; today dev uses `db push`. +- Set a strong `SESSION_SECRET` and a locked-down `DATABASE_URL`. +- The API expects `WEB_ORIGIN` to match the browser origin for session cookies. +- Documents are stored in MinIO in the deployed environment (see `RESUME.md`). diff --git a/RESUME.md b/RESUME.md index 1351726..13a3f39 100644 --- a/RESUME.md +++ b/RESUME.md @@ -162,6 +162,17 @@ the reconciliation pass (done, then corrected) are all closed. See §3 and §8. 5. **`TRASPASOS PAYPAL` is a clearing account, not a customer** — carries -7.03M MXN over 309 movements and therefore tops the adeudo worklist. Deliberately not special-cased in code; needs a business decision on how to model it. +6. **DB Operations — Phase B (additive sync) — IMPLEMENTED, verification pending.** Phase A provides + the admin-only `/operaciones` page + `ops` API module (ability `db:manage`, ADMIN), ingest + folder, backup, restore, and destructive re-import. Phase B now enables `SYNC`: `OpsService` + creates a safety backup and runs `run_all.py --sync`; transforms upsert legacy-owned rows by + provenance keys while preserving existing PKs and rows whose `legacyId IS NULL` (manual). + Prisma now enforces provenance uniqueness for properties, policies, transactions, vehicles, + and bank transactions. Sync intentionally skips prune/blob steps so manual customers and + document pointers are not removed. Python compilation plus API/web production builds pass; + still required before production use: push updated Prisma schema and run an end-to-end sync + against a disposable/dev DB proving stable PKs, manual-row preservation, changed-row updates, + and legacy-delete handling. ## 7. Environment notes (current macOS machine) @@ -374,11 +385,14 @@ for what's actually next. --- -⏭ **NEXT — where to pick up:** - -- **Plan step 8–9: VPS + sync worker.** Blocked on VPS provisioning (§6.1) — the only real - external dependency left. Pure ops: provider, size, Tailscale, MySQL replica. -- **Plan step 10: reports / email campaigns / admin.** +- **Sync implementation — DONE, validation pending.** `run_all.py --sync` performs the + non-destructive legacy upsert path for customers, properties, policies, transactions, and + bank rows. It preserves manual rows and stable legacy-owned primary keys; the admin SYNC job + automatically creates a pre-sync backup. Next validation: apply schema changes, then exercise + sync against a disposable DB with added, changed, removed, and manually-created rows. +- **Plan step 9: portal sync worker** remains separate and blocked on VPS provisioning. This + Phase B feature synchronizes Access source files into the internal platform; it does not yet + poll `utility_dbo` inbox tables or replicate portal-facing data to a VPS. - **Small / open:** (a) `TRASPASOS PAYPAL` clearing account still tops the adeudo worklist (§6.4d) — a business modelling call, not code. (b) Credential rotation on the old repo's exposed MySQL password. (c) The `/estado-cuenta` browser visual pass — `/banco` was verified diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index ce04fa0..7833e82 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,6 +1,7 @@ import { Module } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; import { PrismaModule } from "./prisma/prisma.module"; +import { CommonModule } from "./common/common.module"; import { UsersModule } from "./users/users.module"; import { AuthModule } from "./auth/auth.module"; import { CustomersModule } from "./customers/customers.module"; @@ -8,12 +9,14 @@ import { PoliciesModule } from "./policies/policies.module"; import { PropertiesModule } from "./properties/properties.module"; import { BillingModule } from "./billing/billing.module"; import { BankModule } from "./bank/bank.module"; +import { OpsModule } from "./ops/ops.module"; import { AppController } from "./app.controller"; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), PrismaModule, + CommonModule, UsersModule, AuthModule, CustomersModule, @@ -21,6 +24,7 @@ import { AppController } from "./app.controller"; PropertiesModule, BillingModule, BankModule, + OpsModule, ], controllers: [AppController], }) diff --git a/apps/api/src/auth/abilities.ts b/apps/api/src/auth/abilities.ts new file mode 100644 index 0000000..9ec2460 --- /dev/null +++ b/apps/api/src/auth/abilities.ts @@ -0,0 +1,69 @@ +// Server-authoritative permission matrix. Roles form an ordered rank +// (ADMIN > MANAGER > STAFF > VIEWER — this is the "level" concept); every +// write action carries a minimum rank. VIEWER holds rank 0 and is the +// read-only role. Reads are not listed here — they stay on AuthenticatedGuard +// alone, so any logged-in user (including VIEWER) can read. +// +// This is the single source of truth: the API enforces it via AbilityGuard and +// ships the resolved per-user map to the web through /auth/me, so the UI never +// keeps its own copy of the rules. + +export type Role = "ADMIN" | "MANAGER" | "STAFF" | "VIEWER"; + +export const ROLE_RANK: Record = { + VIEWER: 0, + STAFF: 1, + MANAGER: 2, + ADMIN: 3, +}; + +export type Ability = + | "customer:create" + | "customer:update" + | "customer:delete" + | "policy:create" + | "policy:update" + | "policy:delete" + | "property:create" + | "property:update" + | "property:delete" + | "ledger:create" + | "ledger:void" + | "bank:create" + | "bank:void" + | "lookup:manage" + | "user:manage" + | "db:manage"; + +/** Minimum role required for each ability. */ +export const ABILITY_MIN: Record = { + "customer:create": "STAFF", + "customer:update": "STAFF", + "customer:delete": "ADMIN", + "policy:create": "STAFF", + "policy:update": "STAFF", + "policy:delete": "MANAGER", + "property:create": "STAFF", + "property:update": "STAFF", + "property:delete": "MANAGER", + "ledger:create": "STAFF", + "ledger:void": "MANAGER", + "bank:create": "STAFF", + "bank:void": "MANAGER", + "lookup:manage": "MANAGER", + "user:manage": "ADMIN", + "db:manage": "ADMIN", +}; + +export const ALL_ABILITIES = Object.keys(ABILITY_MIN) as Ability[]; + +export function can(role: Role, ability: Ability): boolean { + return ROLE_RANK[role] >= ROLE_RANK[ABILITY_MIN[ability]]; +} + +/** Resolved {ability: boolean} map for a role — sent to the web via /auth/me. */ +export function abilitiesFor(role: Role): Record { + return Object.fromEntries( + ALL_ABILITIES.map((a) => [a, can(role, a)]), + ) as Record; +} diff --git a/apps/api/src/auth/ability.guard.ts b/apps/api/src/auth/ability.guard.ts new file mode 100644 index 0000000..ee28951 --- /dev/null +++ b/apps/api/src/auth/ability.guard.ts @@ -0,0 +1,36 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { Request } from "express"; +import { ABILITY_KEY } from "./require-ability.decorator"; +import { Ability, Role, can } from "./abilities"; + +/** + * Enforces the ability matrix (abilities.ts) against req.user.role. A route + * with no @RequireAbility passes through untouched — this guard only gates the + * routes that declare one. It does NOT check authentication; always list it + * after AuthenticatedGuard so an unauthenticated request is rejected first. + */ +@Injectable() +export class AbilityGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const ability = this.reflector.getAllAndOverride( + ABILITY_KEY, + [context.getHandler(), context.getClass()], + ); + if (!ability) return true; + + const req = context.switchToHttp().getRequest(); + const user = req.user as { role?: Role } | undefined; + if (!user?.role || !can(user.role, ability)) { + throw new ForbiddenException("No tiene permisos para esta acción"); + } + return true; + } +} diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 197f09c..f0403e1 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -3,6 +3,14 @@ import { Request, Response } from "express"; import { LocalAuthGuard } from "./local-auth.guard"; import { AuthenticatedGuard } from "./authenticated.guard"; import { LoginDto } from "./login.dto"; +import { abilitiesFor, Role } from "./abilities"; + +/** Attach the resolved ability map so the web can gate its UI off one payload. */ +function withAbilities(user: unknown) { + const u = user as { role?: Role } | undefined; + if (!u?.role) return u; + return { ...u, abilities: abilitiesFor(u.role) }; +} @Controller("auth") export class AuthController { @@ -13,13 +21,13 @@ export class AuthController { @Post("login") @HttpCode(200) login(@Req() req: Request, @Res({ passthrough: true }) _res: Response, _body?: LoginDto) { - return req.user; + return withAbilities(req.user); } @UseGuards(AuthenticatedGuard) @Get("me") me(@Req() req: Request) { - return req.user; + return withAbilities(req.user); } @Post("logout") diff --git a/apps/api/src/auth/require-ability.decorator.ts b/apps/api/src/auth/require-ability.decorator.ts new file mode 100644 index 0000000..062d04a --- /dev/null +++ b/apps/api/src/auth/require-ability.decorator.ts @@ -0,0 +1,12 @@ +import { SetMetadata } from "@nestjs/common"; +import type { Ability } from "./abilities"; + +export const ABILITY_KEY = "required_ability"; + +/** + * Tags a write route with the ability it requires. Pair with + * `@UseGuards(AuthenticatedGuard, AbilityGuard)` — AuthenticatedGuard proves + * the session, AbilityGuard checks this ability against the user's role. + */ +export const RequireAbility = (ability: Ability) => + SetMetadata(ABILITY_KEY, ability); diff --git a/apps/api/src/bank/bank-movement.dto.ts b/apps/api/src/bank/bank-movement.dto.ts new file mode 100644 index 0000000..b5d01ba --- /dev/null +++ b/apps/api/src/bank/bank-movement.dto.ts @@ -0,0 +1,19 @@ +import { IsBoolean, IsNumber, IsOptional, IsString, MinLength } from "class-validator"; + +/** + * A new bank-register movement. `amount` is signed: positive = ingreso, + * negative = egreso (the module's sign convention). Single currency (MXN). + * Booked rows are never edited — a mistake is corrected by voiding + re-capture. + */ +export class CreateBankMovementDto { + @IsNumber() amount!: number; + @IsString() @MinLength(1) transactionDate!: string; + + @IsOptional() @IsString() concept?: string; + @IsOptional() @IsString() reference?: string; + @IsOptional() @IsString() transactionType?: string; + @IsOptional() @IsBoolean() cleared?: boolean; + @IsOptional() @IsBoolean() transferred?: boolean; + @IsOptional() @IsString() notes?: string; + @IsOptional() @IsString() amountInWords?: string; +} diff --git a/apps/api/src/bank/bank.controller.ts b/apps/api/src/bank/bank.controller.ts index eb030a7..65f3b7f 100644 --- a/apps/api/src/bank/bank.controller.ts +++ b/apps/api/src/bank/bank.controller.ts @@ -1,11 +1,25 @@ -import { Controller, Get, Query, UseGuards } from "@nestjs/common"; +import { + Body, + Controller, + Get, + Param, + Post, + Query, + Req, + UseGuards, +} from "@nestjs/common"; +import { Request } from "express"; import { AuthenticatedGuard } from "../auth/authenticated.guard"; +import { AbilityGuard } from "../auth/ability.guard"; +import { RequireAbility } from "../auth/require-ability.decorator"; +import { AuditService } from "../common/audit.service"; import { BankCleared, BankDirection, BankService, BankSort, } from "./bank.service"; +import { CreateBankMovementDto } from "./bank-movement.dto"; const DIRECTIONS: BankDirection[] = ["income", "expense", "void"]; const CLEARED: BankCleared[] = ["cleared", "pending"]; @@ -28,10 +42,17 @@ function parseDate(v: string | undefined, endOfDay = false): Date | undefined { return Number.isNaN(d.getTime()) ? undefined : d; } -@UseGuards(AuthenticatedGuard) +@UseGuards(AuthenticatedGuard, AbilityGuard) @Controller("bank") export class BankController { - constructor(private readonly bank: BankService) {} + constructor( + private readonly bank: BankService, + private readonly audit: AuditService, + ) {} + + private actingId(req: Request): string { + return (req.user as { id: string }).id; + } @Get("stats") stats() { @@ -75,4 +96,25 @@ export class BankController { sort: one(SORTS, sort) ?? "date_desc", }); } + + // --- writes --------------------------------------------------------------- + + @Post() + @RequireAbility("bank:create") + async create(@Body() dto: CreateBankMovementDto, @Req() req: Request) { + const row = await this.bank.createMovement(dto); + void this.audit.log(this.actingId(req), "bank.create", { + bankTransactionId: row.id, + amount: dto.amount, + }); + return row; + } + + @Post(":id/void") + @RequireAbility("bank:void") + async void(@Param("id") id: string, @Req() req: Request) { + const row = await this.bank.voidMovement(id, this.actingId(req)); + void this.audit.log(this.actingId(req), "bank.void", { bankTransactionId: id }); + return row; + } } diff --git a/apps/api/src/bank/bank.service.ts b/apps/api/src/bank/bank.service.ts index 0dafccc..8b3df10 100644 --- a/apps/api/src/bank/bank.service.ts +++ b/apps/api/src/bank/bank.service.ts @@ -1,6 +1,15 @@ -import { Injectable } from "@nestjs/common"; +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; import { Prisma } from "@jorgecuadros/database"; import { PrismaService } from "../prisma/prisma.service"; +import { CreateBankMovementDto } from "./bank-movement.dto"; + +/** + * App-voided rows (voidedAt set) are reversed and must leave every + * income/expense/net total. This is distinct from the legacy zero-amount + * "void" cheques, which stay as amount-0 rows. List views still show voided + * rows struck-through. + */ +const NOT_VOIDED: Prisma.BankTransactionWhereInput = { voidedAt: null }; /** * Bank register (chequera) module — plan step 7. @@ -160,6 +169,7 @@ export class BankService { notes: true, amountInWords: true, legacySourceTable: true, + voidedAt: true, }, }), ]); @@ -182,6 +192,7 @@ export class BankService { notes: r.notes, amountInWords: r.amountInWords, source: r.legacySourceTable, + voided: r.voidedAt != null, })), total, page: params.page, @@ -195,17 +206,17 @@ export class BankService { private async totalsFor(where: Prisma.BankTransactionWhereInput) { const [income, expense, voided] = await Promise.all([ this.prisma.bankTransaction.aggregate({ - where: { AND: [where, { amount: { gt: 0 } }] }, + where: { AND: [where, { amount: { gt: 0 } }, NOT_VOIDED] }, _sum: { amount: true }, _count: { _all: true }, }), this.prisma.bankTransaction.aggregate({ - where: { AND: [where, { amount: { lt: 0 } }] }, + where: { AND: [where, { amount: { lt: 0 } }, NOT_VOIDED] }, _sum: { amount: true }, _count: { _all: true }, }), this.prisma.bankTransaction.count({ - where: { AND: [where, { amount: 0 }] }, + where: { AND: [where, { amount: 0 }, NOT_VOIDED] }, }), ]); @@ -225,13 +236,18 @@ export class BankService { /** Top-line figures for the bank page header. */ async stats() { const [count, bounds, pending, transferred, totals] = await Promise.all([ - this.prisma.bankTransaction.count(), + this.prisma.bankTransaction.count({ where: NOT_VOIDED }), this.prisma.bankTransaction.aggregate({ + where: NOT_VOIDED, _min: { transactionDate: true }, _max: { transactionDate: true }, }), - this.prisma.bankTransaction.count({ where: { cleared: false } }), - this.prisma.bankTransaction.count({ where: { transferred: true } }), + this.prisma.bankTransaction.count({ + where: { AND: [{ cleared: false }, NOT_VOIDED] }, + }), + this.prisma.bankTransaction.count({ + where: { AND: [{ transferred: true }, NOT_VOIDED] }, + }), this.totalsFor({}), ]); @@ -252,6 +268,7 @@ export class BankService { >` SELECT YEAR(transactionDate) AS year, COUNT(*) AS count FROM bank_transactions + WHERE voidedAt IS NULL GROUP BY year ORDER BY year DESC `; @@ -280,6 +297,7 @@ export class BankService { SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense, SUM(amount) AS net FROM bank_transactions + WHERE voidedAt IS NULL GROUP BY period ORDER BY period ASC `; @@ -293,7 +311,7 @@ export class BankService { SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense, SUM(amount) AS net FROM bank_transactions - WHERE YEAR(transactionDate) = ${year} + WHERE YEAR(transactionDate) = ${year} AND voidedAt IS NULL GROUP BY period ORDER BY period ASC ` @@ -346,6 +364,39 @@ export class BankService { opening: opening.toFixed(2), }; } + + // --- writes (append + void) ----------------------------------------------- + + async createMovement(dto: CreateBankMovementDto) { + const date = new Date(dto.transactionDate); + if (isNaN(date.getTime())) throw new BadRequestException("Fecha inválida"); + return this.prisma.bankTransaction.create({ + data: { + amount: dto.amount, + transactionDate: date, + concept: dto.concept, + reference: dto.reference, + transactionType: dto.transactionType, + cleared: dto.cleared ?? false, + transferred: dto.transferred ?? false, + notes: dto.notes, + amountInWords: dto.amountInWords, + }, + }); + } + + async voidMovement(id: string, userId: string) { + const row = await this.prisma.bankTransaction.findUnique({ + where: { id }, + select: { id: true, voidedAt: true }, + }); + if (!row) throw new NotFoundException(`Bank transaction ${id} not found`); + if (row.voidedAt) throw new BadRequestException("El movimiento ya está anulado"); + return this.prisma.bankTransaction.update({ + where: { id }, + data: { voidedAt: new Date(), voidedById: userId }, + }); + } } function directionOf(amount: Prisma.Decimal): BankDirection { diff --git a/apps/api/src/billing/billing.controller.ts b/apps/api/src/billing/billing.controller.ts index 52d8529..93219ee 100644 --- a/apps/api/src/billing/billing.controller.ts +++ b/apps/api/src/billing/billing.controller.ts @@ -1,6 +1,19 @@ -import { Controller, Get, Param, Query, UseGuards } from "@nestjs/common"; +import { + Body, + Controller, + Get, + Param, + Post, + Query, + Req, + UseGuards, +} from "@nestjs/common"; import { TransactionDomain } from "@jorgecuadros/database"; +import { Request } from "express"; import { AuthenticatedGuard } from "../auth/authenticated.guard"; +import { AbilityGuard } from "../auth/ability.guard"; +import { RequireAbility } from "../auth/require-ability.decorator"; +import { AuditService } from "../common/audit.service"; import { BalanceFilter, BalanceSort, @@ -9,6 +22,7 @@ import { LedgerDirection, MovementSort, } from "./billing.service"; +import { CreateMovementDto } from "./movement.dto"; const DOMAINS: TransactionDomain[] = ["UTILITY", "INSURANCE", "TRUST"]; const CURRENCIES: LedgerCurrency[] = ["MXN", "USD"]; @@ -39,10 +53,17 @@ function parseDate(v: string | undefined, endOfDay = false): Date | undefined { return Number.isNaN(d.getTime()) ? undefined : d; } -@UseGuards(AuthenticatedGuard) +@UseGuards(AuthenticatedGuard, AbilityGuard) @Controller("billing") export class BillingController { - constructor(private readonly billing: BillingService) {} + constructor( + private readonly billing: BillingService, + private readonly audit: AuditService, + ) {} + + private actingId(req: Request): string { + return (req.user as { id: string }).id; + } @Get("stats") stats() { @@ -113,4 +134,27 @@ export class BillingController { sort: one(MOVEMENT_SORTS, sort) ?? "date_desc", }); } + + // --- writes --------------------------------------------------------------- + + @Post() + @RequireAbility("ledger:create") + async create(@Body() dto: CreateMovementDto, @Req() req: Request) { + const tx = await this.billing.createMovement(dto); + void this.audit.log(this.actingId(req), "ledger.create", { + transactionId: tx.id, + customerId: dto.customerId, + amount: dto.amount, + currency: tx.currency, + }); + return tx; + } + + @Post(":id/void") + @RequireAbility("ledger:void") + async void(@Param("id") id: string, @Req() req: Request) { + const tx = await this.billing.voidMovement(id, this.actingId(req)); + void this.audit.log(this.actingId(req), "ledger.void", { transactionId: id }); + return tx; + } } diff --git a/apps/api/src/billing/billing.service.ts b/apps/api/src/billing/billing.service.ts index ad3422c..b4381a5 100644 --- a/apps/api/src/billing/billing.service.ts +++ b/apps/api/src/billing/billing.service.ts @@ -1,6 +1,7 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; import { Prisma, TransactionDomain } from "@jorgecuadros/database"; import { PrismaService } from "../prisma/prisma.service"; +import { CreateMovementDto } from "./movement.dto"; /** * Shared billing / statements module — plan step 6. @@ -104,6 +105,13 @@ function dec(v: Prisma.Decimal | null | undefined): string { return (v ?? new Prisma.Decimal(0)).toFixed(2); } +/** + * Every aggregate (SUM/count/groupBy and the raw balance SQL) must exclude + * voided rows, or a reversed movement keeps affecting the books. List views + * still show voided rows struck-through — only totals drop them. + */ +const NOT_VOIDED: Prisma.TransactionWhereInput = { voidedAt: null }; + @Injectable() export class BillingService { constructor(private readonly prisma: PrismaService) {} @@ -187,6 +195,7 @@ export class BillingService { checkNumber: true, message: true, legacySourceTable: true, + voidedAt: true, type: { select: { nameEn: true, nameEs: true } }, customer: { select: { id: true, name: true, nameSource: true, city: true }, @@ -200,19 +209,19 @@ export class BillingService { // cover everything the filter matched. const totals = await this.prisma.transaction.groupBy({ by: ["currency"], - where, + where: { AND: [where, NOT_VOIDED] }, _sum: { amount: true }, _count: { _all: true }, }); const charges = await this.prisma.transaction.groupBy({ by: ["currency"], - where: { AND: [where, { amount: { lt: 0 } }] }, + where: { AND: [where, { amount: { lt: 0 } }, NOT_VOIDED] }, _sum: { amount: true }, _count: { _all: true }, }); const credits = await this.prisma.transaction.groupBy({ by: ["currency"], - where: { AND: [where, { amount: { gt: 0 } }] }, + where: { AND: [where, { amount: { gt: 0 } }, NOT_VOIDED] }, _sum: { amount: true }, _count: { _all: true }, }); @@ -233,6 +242,7 @@ export class BillingService { message: r.message, source: r.legacySourceTable, type: r.type, + voided: r.voidedAt != null, customerId: r.customer.id, customerName: r.customer.name, customerNameSource: r.customer.nameSource, @@ -326,7 +336,7 @@ export class BillingService { MAX(t.transactionDate) AS lastMovement FROM customers c JOIN transactions t ON t.customerId = c.id - WHERE 1 = 1 ${nameFilter} ${txFilter} + WHERE t.voidedAt IS NULL ${nameFilter} ${txFilter} GROUP BY c.id, c.name, c.nameSource, c.nameMissing, c.city, c.state ${having} ${orderBy} @@ -382,17 +392,23 @@ export class BillingService { /** Top-line figures for the billing page header. */ async stats() { const [movements, ledgerCustomers, byCurrency, byDomain] = await Promise.all([ - this.prisma.transaction.count(), + this.prisma.transaction.count({ where: NOT_VOIDED }), this.prisma.transaction - .findMany({ distinct: ["customerId"], select: { customerId: true } }) + .findMany({ + where: NOT_VOIDED, + distinct: ["customerId"], + select: { customerId: true }, + }) .then((r) => r.length), this.prisma.transaction.groupBy({ by: ["currency"], + where: NOT_VOIDED, _sum: { amount: true }, _count: { _all: true }, }), this.prisma.transaction.groupBy({ by: ["domain", "currency"], + where: NOT_VOIDED, _sum: { amount: true }, _count: { _all: true }, }), @@ -400,13 +416,13 @@ export class BillingService { const charges = await this.prisma.transaction.groupBy({ by: ["currency"], - where: { amount: { lt: 0 } }, + where: { AND: [{ amount: { lt: 0 } }, NOT_VOIDED] }, _sum: { amount: true }, _count: { _all: true }, }); const credits = await this.prisma.transaction.groupBy({ by: ["currency"], - where: { amount: { gt: 0 } }, + where: { AND: [{ amount: { gt: 0 } }, NOT_VOIDED] }, _sum: { amount: true }, _count: { _all: true }, }); @@ -428,7 +444,7 @@ export class BillingService { SUM(bal > 0.005) AS inCredit FROM ( SELECT customerId, currency, SUM(amount) AS bal - FROM transactions GROUP BY customerId, currency + FROM transactions WHERE voidedAt IS NULL GROUP BY customerId, currency ) x GROUP BY currency `; @@ -436,10 +452,12 @@ export class BillingService { const [firstRow, lastRow] = await Promise.all([ this.prisma.transaction.findFirst({ + where: NOT_VOIDED, orderBy: { transactionDate: "asc" }, select: { transactionDate: true }, }), this.prisma.transaction.findFirst({ + where: NOT_VOIDED, orderBy: { transactionDate: "desc" }, select: { transactionDate: true }, }), @@ -449,7 +467,7 @@ export class BillingService { // module is one view instead of two. const crossLine = await this.prisma.$queryRaw<{ n: bigint | number | string }[]>` SELECT COUNT(*) AS n FROM ( - SELECT customerId FROM transactions + SELECT customerId FROM transactions WHERE voidedAt IS NULL GROUP BY customerId HAVING COUNT(DISTINCT domain) > 1 ) x `; @@ -484,7 +502,7 @@ export class BillingService { async facets() { const types = await this.prisma.transaction.groupBy({ by: ["typeId"], - where: { typeId: { not: null } }, + where: { AND: [{ typeId: { not: null } }, NOT_VOIDED] }, _count: { _all: true }, orderBy: { _count: { typeId: "desc" } }, }); @@ -496,6 +514,7 @@ export class BillingService { const sources = await this.prisma.transaction.groupBy({ by: ["legacySourceTable"], + where: NOT_VOIDED, _count: { _all: true }, orderBy: { _count: { legacySourceTable: "desc" } }, }); @@ -504,7 +523,7 @@ export class BillingService { { year: number; count: bigint | number | string }[] >` SELECT YEAR(transactionDate) AS year, COUNT(*) AS count - FROM transactions GROUP BY year ORDER BY year DESC + FROM transactions WHERE voidedAt IS NULL GROUP BY year ORDER BY year DESC `; return { @@ -573,14 +592,18 @@ export class BillingService { checkNumber: true, message: true, legacySourceTable: true, + voidedAt: true, type: { select: { nameEn: true, nameEs: true } }, }, }); const running = new Map(); const movements = rows.map((r) => { + const voided = r.voidedAt != null; const prev = running.get(r.currency) ?? new Prisma.Decimal(0); - const next = prev.plus(r.amount); + // A voided row does not move the running balance — it shows struck-through + // with the balance unchanged from the previous live movement. + const next = voided ? prev : prev.plus(r.amount); running.set(r.currency, next); return { id: r.id, @@ -595,6 +618,7 @@ export class BillingService { message: r.message, source: r.legacySourceTable, type: r.type, + voided, /** Balance in this row's currency after applying it. */ balanceAfter: next.toFixed(2), }; @@ -628,6 +652,7 @@ export class BillingService { >(); for (const r of rows) { + if (r.voidedAt != null) continue; // voided rows never enter a total const c = perCurrency.get(r.currency) ?? { @@ -675,6 +700,7 @@ export class BillingService { { name: string; currency: string; total: Prisma.Decimal; count: number } >(); for (const r of rows) { + if (r.voidedAt != null) continue; if (!r.amount.lessThan(0)) continue; const name = r.type?.nameEs || r.type?.nameEn || "Sin clasificar"; const key = `${name}|${r.currency}`; @@ -722,4 +748,45 @@ export class BillingService { movements, }; } + + // --- writes (append + void; never edit or delete a booked row) ------------ + + async createMovement(dto: CreateMovementDto) { + const customer = await this.prisma.customer.findUnique({ + where: { id: dto.customerId }, + select: { id: true }, + }); + if (!customer) throw new NotFoundException(`Customer ${dto.customerId} not found`); + const date = new Date(dto.transactionDate); + if (isNaN(date.getTime())) throw new BadRequestException("Fecha inválida"); + + return this.prisma.transaction.create({ + data: { + customerId: dto.customerId, + domain: dto.domain, + amount: dto.amount, + transactionDate: date, + currency: dto.currency, + typeId: dto.typeId, + period: dto.period, + reference: dto.reference, + checkNumber: dto.checkNumber, + message: dto.message, + }, + }); + } + + /** Reverse a movement by marking it voided; it stops counting toward totals. */ + async voidMovement(id: string, userId: string) { + const tx = await this.prisma.transaction.findUnique({ + where: { id }, + select: { id: true, voidedAt: true }, + }); + if (!tx) throw new NotFoundException(`Transaction ${id} not found`); + if (tx.voidedAt) throw new BadRequestException("El movimiento ya está anulado"); + return this.prisma.transaction.update({ + where: { id }, + data: { voidedAt: new Date(), voidedById: userId }, + }); + } } diff --git a/apps/api/src/billing/movement.dto.ts b/apps/api/src/billing/movement.dto.ts new file mode 100644 index 0000000..aadb104 --- /dev/null +++ b/apps/api/src/billing/movement.dto.ts @@ -0,0 +1,27 @@ +import { + IsEnum, + IsNumber, + IsOptional, + IsString, + MinLength, +} from "class-validator"; +import { Currency, TransactionDomain } from "@jorgecuadros/database"; + +/** + * A new ledger movement. `amount` is signed: negative = cargo (charge), + * positive = abono (credit) — the module's sign convention. Booked movements + * are never edited; a mistake is corrected by voiding and re-capturing. + */ +export class CreateMovementDto { + @IsString() @MinLength(1) customerId!: string; + @IsEnum(TransactionDomain) domain!: TransactionDomain; + @IsNumber() amount!: number; + @IsString() @MinLength(1) transactionDate!: string; + + @IsOptional() @IsEnum(Currency) currency?: Currency; + @IsOptional() @IsString() typeId?: string; + @IsOptional() @IsString() period?: string; + @IsOptional() @IsString() reference?: string; + @IsOptional() @IsString() checkNumber?: string; + @IsOptional() @IsString() message?: string; +} diff --git a/apps/api/src/common/audit.service.ts b/apps/api/src/common/audit.service.ts new file mode 100644 index 0000000..c1fce00 --- /dev/null +++ b/apps/api/src/common/audit.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from "@nestjs/common"; +import { Prisma } from "@jorgecuadros/database"; +import { PrismaService } from "../prisma/prisma.service"; + +/** + * Thin writer over the existing ActivityLog model. Every mutating route calls + * this so who-did-what is recorded — the structural replacement for the old + * PHP app's scattered Logger calls. Best-effort: a logging failure must never + * fail the underlying write, so callers `void audit.log(...)` without awaiting. + */ +@Injectable() +export class AuditService { + constructor(private readonly prisma: PrismaService) {} + + async log( + userId: string | null | undefined, + event: string, + message?: Record, + level = "info", + ): Promise { + try { + await this.prisma.activityLog.create({ + data: { + userId: userId ?? undefined, + event, + level, + message: (message as Prisma.InputJsonValue) ?? undefined, + }, + }); + } catch { + /* never let audit logging break a real write */ + } + } +} diff --git a/apps/api/src/common/coerce.ts b/apps/api/src/common/coerce.ts new file mode 100644 index 0000000..26afda9 --- /dev/null +++ b/apps/api/src/common/coerce.ts @@ -0,0 +1,10 @@ +// Small shared coercers for DTO fields that arrive as strings from JSON. +// Distinguishing "field absent" (undefined -> leave unchanged) from +// "field cleared" (null/"" -> set null) matters for PATCH semantics. + +export function toDate(v?: string | null): Date | null | undefined { + if (v === undefined) return undefined; + if (v === "" || v === null) return null; + const d = new Date(v); + return isNaN(d.getTime()) ? undefined : d; +} diff --git a/apps/api/src/common/common.module.ts b/apps/api/src/common/common.module.ts new file mode 100644 index 0000000..cd5a037 --- /dev/null +++ b/apps/api/src/common/common.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from "@nestjs/common"; +import { AuditService } from "./audit.service"; + +@Global() +@Module({ + providers: [AuditService], + exports: [AuditService], +}) +export class CommonModule {} diff --git a/apps/api/src/customers/create-customer.dto.ts b/apps/api/src/customers/create-customer.dto.ts new file mode 100644 index 0000000..e8e33f8 --- /dev/null +++ b/apps/api/src/customers/create-customer.dto.ts @@ -0,0 +1,42 @@ +import { + IsBoolean, + IsEmail, + IsEnum, + IsNumber, + IsOptional, + IsString, + MinLength, +} from "class-validator"; +import { Currency } from "@jorgecuadros/database"; + +/** + * Editable customer fields. Internal/derived columns (nameSource, nameMissing, + * legacy* provenance, archivedAt) are managed by the service, not the client. + * `name` is the only required field; everything else is optional. + */ +export class CreateCustomerDto { + @IsString() + @MinLength(1) + name!: string; + + @IsOptional() @IsString() addressLine1?: string; + @IsOptional() @IsString() addressLine2?: string; + @IsOptional() @IsString() city?: string; + @IsOptional() @IsString() state?: string; + @IsOptional() @IsString() zipCode?: string; + @IsOptional() @IsString() country?: string; + @IsOptional() @IsString() phone?: string; + @IsOptional() @IsString() mobile?: string; + @IsOptional() @IsString() fax?: string; + @IsOptional() @IsEmail() email?: string; + @IsOptional() @IsString() notes?: string; + @IsOptional() @IsString() identificationType?: string; + @IsOptional() @IsString() identificationNumber?: string; + /** ISO date string; coerced to Date by the service. */ + @IsOptional() @IsString() identificationExpiration?: string; + @IsOptional() @IsString() customerSince?: string; + @IsOptional() @IsBoolean() status?: boolean; + @IsOptional() @IsNumber() minimumBalance?: number; + @IsOptional() @IsNumber() feeAmount?: number; + @IsOptional() @IsEnum(Currency) preferredCurrency?: Currency; +} diff --git a/apps/api/src/customers/customers.controller.ts b/apps/api/src/customers/customers.controller.ts index 8ecbf7f..a23530e 100644 --- a/apps/api/src/customers/customers.controller.ts +++ b/apps/api/src/customers/customers.controller.ts @@ -1,11 +1,35 @@ -import { Controller, Get, Param, Query, UseGuards } from "@nestjs/common"; +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + Req, + UseGuards, +} from "@nestjs/common"; +import { Request } from "express"; import { AuthenticatedGuard } from "../auth/authenticated.guard"; +import { AbilityGuard } from "../auth/ability.guard"; +import { RequireAbility } from "../auth/require-ability.decorator"; +import { AuditService } from "../common/audit.service"; import { CustomersService } from "./customers.service"; +import { CreateCustomerDto } from "./create-customer.dto"; +import { UpdateCustomerDto } from "./update-customer.dto"; -@UseGuards(AuthenticatedGuard) +@UseGuards(AuthenticatedGuard, AbilityGuard) @Controller("customers") export class CustomersController { - constructor(private readonly customers: CustomersService) {} + constructor( + private readonly customers: CustomersService, + private readonly audit: AuditService, + ) {} + + private actingId(req: Request): string { + return (req.user as { id: string }).id; + } @Get("stats") stats() { @@ -18,14 +42,57 @@ export class CustomersController { @Query("page") page?: string, @Query("pageSize") pageSize?: string, @Query("line") line?: "utility" | "insurance" | "both", + @Query("includeArchived") includeArchived?: string, ) { const p = Math.max(1, Number(page) || 1); const ps = Math.min(100, Math.max(1, Number(pageSize) || 25)); - return this.customers.list({ query, page: p, pageSize: ps, line }); + return this.customers.list({ + query, + page: p, + pageSize: ps, + line, + includeArchived: includeArchived === "true", + }); } @Get(":id") detail(@Param("id") id: string) { return this.customers.detail(id); } + + @Post() + @RequireAbility("customer:create") + async create(@Body() dto: CreateCustomerDto, @Req() req: Request) { + const c = await this.customers.create(dto); + void this.audit.log(this.actingId(req), "customer.create", { customerId: c.id, name: c.name }); + return c; + } + + @Patch(":id") + @RequireAbility("customer:update") + async update( + @Param("id") id: string, + @Body() dto: UpdateCustomerDto, + @Req() req: Request, + ) { + const c = await this.customers.update(id, dto); + void this.audit.log(this.actingId(req), "customer.update", { customerId: id }); + return c; + } + + @Delete(":id") + @RequireAbility("customer:delete") + async archive(@Param("id") id: string, @Req() req: Request) { + const c = await this.customers.archive(id); + void this.audit.log(this.actingId(req), "customer.archive", { customerId: id }); + return c; + } + + @Post(":id/restore") + @RequireAbility("customer:delete") + async restore(@Param("id") id: string, @Req() req: Request) { + const c = await this.customers.restore(id); + void this.audit.log(this.actingId(req), "customer.restore", { customerId: id }); + return c; + } } diff --git a/apps/api/src/customers/customers.service.ts b/apps/api/src/customers/customers.service.ts index a31c57c..b523737 100644 --- a/apps/api/src/customers/customers.service.ts +++ b/apps/api/src/customers/customers.service.ts @@ -1,12 +1,23 @@ import { Injectable, NotFoundException } from "@nestjs/common"; import { Prisma } from "@jorgecuadros/database"; import { PrismaService } from "../prisma/prisma.service"; +import { CreateCustomerDto } from "./create-customer.dto"; +import { UpdateCustomerDto } from "./update-customer.dto"; export interface ListParams { query?: string; page: number; pageSize: number; line?: "utility" | "insurance" | "both"; + includeArchived?: boolean; +} + +/** Parse an optional ISO date string to a Date (or null to clear it). */ +function toDate(v?: string): Date | null | undefined { + if (v === undefined) return undefined; + if (v === "" || v === null) return null; + const d = new Date(v); + return isNaN(d.getTime()) ? undefined : d; } @Injectable() @@ -14,9 +25,11 @@ export class CustomersService { constructor(private readonly prisma: PrismaService) {} /** Unified customer list with search + business-line filter, paginated. */ - async list({ query, page, pageSize, line }: ListParams) { + async list({ query, page, pageSize, line, includeArchived }: ListParams) { const where: Prisma.CustomerWhereInput = {}; + if (!includeArchived) where.archivedAt = null; + if (query && query.trim()) { const q = query.trim(); where.OR = [ @@ -54,6 +67,7 @@ export class CustomersService { phone: true, mobile: true, status: true, + archivedAt: true, _count: { select: { properties: true, policies: true, transactions: true } }, }, }), @@ -69,6 +83,7 @@ export class CustomersService { phone: r.phone, mobile: r.mobile, status: r.status, + archived: r.archivedAt != null, propertyCount: r._count.properties, policyCount: r._count.policies, transactionCount: r._count.transactions, @@ -117,7 +132,8 @@ export class CustomersService { // business lines" payoff), computed in the DB rather than in JS. const summary = await this.prisma.transaction.groupBy({ by: ["domain", "currency"], - where: { customerId: id }, + // Exclude voided rows so the per-domain balance matches the statement. + where: { customerId: id, voidedAt: null }, _sum: { amount: true }, _count: { _all: true }, }); @@ -133,6 +149,58 @@ export class CustomersService { }; } + // --- writes --------------------------------------------------------------- + + private toData(dto: CreateCustomerDto | UpdateCustomerDto) { + // Whitelisted by the DTO already; map the date strings to Date objects. + const { identificationExpiration, customerSince, ...rest } = dto; + return { + ...rest, + ...(identificationExpiration !== undefined && { + identificationExpiration: toDate(identificationExpiration), + }), + ...(customerSince !== undefined && { customerSince: toDate(customerSince) }), + }; + } + + async create(dto: CreateCustomerDto) { + return this.prisma.customer.create({ + // App-created rows: nameMissing false (name is required), no legacy + // provenance — those columns stay null, marking a native record. + data: { ...this.toData(dto), name: dto.name, nameMissing: false }, + }); + } + + async update(id: string, dto: UpdateCustomerDto) { + await this.ensureExists(id); + return this.prisma.customer.update({ where: { id }, data: this.toData(dto) }); + } + + /** Soft-delete: hide from default lists, keep the row + provenance. */ + async archive(id: string) { + await this.ensureExists(id); + return this.prisma.customer.update({ + where: { id }, + data: { archivedAt: new Date() }, + }); + } + + async restore(id: string) { + await this.ensureExists(id); + return this.prisma.customer.update({ + where: { id }, + data: { archivedAt: null }, + }); + } + + private async ensureExists(id: string) { + const found = await this.prisma.customer.findUnique({ + where: { id }, + select: { id: true }, + }); + if (!found) throw new NotFoundException(`Customer ${id} not found`); + } + /** Top-line counts for a dashboard header. */ async stats() { const [customers, withUtilities, withInsurance, policies, properties, transactions] = diff --git a/apps/api/src/customers/update-customer.dto.ts b/apps/api/src/customers/update-customer.dto.ts new file mode 100644 index 0000000..21af1c6 --- /dev/null +++ b/apps/api/src/customers/update-customer.dto.ts @@ -0,0 +1,34 @@ +import { + IsBoolean, + IsEmail, + IsEnum, + IsNumber, + IsOptional, + IsString, + MinLength, +} from "class-validator"; +import { Currency } from "@jorgecuadros/database"; + +/** Same editable fields as create, all optional. */ +export class UpdateCustomerDto { + @IsOptional() @IsString() @MinLength(1) name?: string; + @IsOptional() @IsString() addressLine1?: string; + @IsOptional() @IsString() addressLine2?: string; + @IsOptional() @IsString() city?: string; + @IsOptional() @IsString() state?: string; + @IsOptional() @IsString() zipCode?: string; + @IsOptional() @IsString() country?: string; + @IsOptional() @IsString() phone?: string; + @IsOptional() @IsString() mobile?: string; + @IsOptional() @IsString() fax?: string; + @IsOptional() @IsEmail() email?: string; + @IsOptional() @IsString() notes?: string; + @IsOptional() @IsString() identificationType?: string; + @IsOptional() @IsString() identificationNumber?: string; + @IsOptional() @IsString() identificationExpiration?: string; + @IsOptional() @IsString() customerSince?: string; + @IsOptional() @IsBoolean() status?: boolean; + @IsOptional() @IsNumber() minimumBalance?: number; + @IsOptional() @IsNumber() feeAmount?: number; + @IsOptional() @IsEnum(Currency) preferredCurrency?: Currency; +} diff --git a/apps/api/src/ops/ops.controller.ts b/apps/api/src/ops/ops.controller.ts new file mode 100644 index 0000000..86b8078 --- /dev/null +++ b/apps/api/src/ops/ops.controller.ts @@ -0,0 +1,120 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Req, + Res, + StreamableFile, + UploadedFile, + UseGuards, + UseInterceptors, +} from "@nestjs/common"; +import { FileInterceptor } from "@nestjs/platform-express"; +import { Request, Response } from "express"; +import { AuthenticatedGuard } from "../auth/authenticated.guard"; +import { AbilityGuard } from "../auth/ability.guard"; +import { RequireAbility } from "../auth/require-ability.decorator"; +import { AuditService } from "../common/audit.service"; +import { OpsService } from "./ops.service"; +import { StartJobDto } from "./start-job.dto"; + +/** Every route is ADMIN-only (ability "db:manage"). */ +@UseGuards(AuthenticatedGuard, AbilityGuard) +@RequireAbility("db:manage") +@Controller("ops") +export class OpsController { + constructor( + private readonly ops: OpsService, + private readonly audit: AuditService, + ) {} + + private actingId(req: Request): string { + return (req.user as { id: string }).id; + } + + /* ------------------------------------------------------------- ingest */ + + @Get("ingest") + listIngest() { + return this.ops.listIngest(); + } + + @Post("ingest/:name") + @UseInterceptors( + FileInterceptor("file", { limits: { fileSize: 500 * 1024 * 1024 } }), + ) + async uploadIngest( + @Param("name") name: string, + @UploadedFile() file: { buffer: Buffer; size: number } | undefined, + @Req() req: Request, + ) { + if (!file) throw new Error("No se recibió ningún archivo."); + await this.ops.saveIngest(name, file.buffer); + void this.audit.log(this.actingId(req), "ops.ingest.upload", { + name, + size: file.size, + }); + return { ok: true }; + } + + @Delete("ingest/:name") + async deleteIngest(@Param("name") name: string, @Req() req: Request) { + await this.ops.deleteIngest(name); + void this.audit.log(this.actingId(req), "ops.ingest.delete", { name }); + return { ok: true }; + } + + /* ------------------------------------------------------------ backups */ + + @Get("backups") + listBackups() { + return this.ops.listBackups(); + } + + @Get("backups/:name/download") + download( + @Param("name") name: string, + @Res({ passthrough: true }) res: Response, + ): StreamableFile { + const { stream, name: safe } = this.ops.backupStream(name); + res.set({ + "Content-Type": "application/gzip", + "Content-Disposition": `attachment; filename="${safe}"`, + }); + return new StreamableFile(stream); + } + + @Delete("backups/:name") + async deleteBackup(@Param("name") name: string, @Req() req: Request) { + await this.ops.deleteBackup(name); + void this.audit.log(this.actingId(req), "ops.backup.delete", { name }); + return { ok: true }; + } + + /* --------------------------------------------------------------- jobs */ + + @Get("jobs") + listJobs() { + return this.ops.listJobs(); + } + + @Get("jobs/:id") + getJob(@Param("id") id: string) { + return this.ops.getJob(id); + } + + @Post("jobs") + async startJob(@Body() dto: StartJobDto, @Req() req: Request) { + const userId = this.actingId(req); + const job = await this.ops.startJob(dto.kind, { file: dto.file }, userId); + void this.audit.log(userId, "ops.job.start", { + jobId: job.id, + kind: dto.kind, + file: dto.file, + }); + return job; + } +} diff --git a/apps/api/src/ops/ops.module.ts b/apps/api/src/ops/ops.module.ts new file mode 100644 index 0000000..05824e1 --- /dev/null +++ b/apps/api/src/ops/ops.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@nestjs/common"; +import { OpsController } from "./ops.controller"; +import { OpsService } from "./ops.service"; + +@Module({ + controllers: [OpsController], + providers: [OpsService], +}) +export class OpsModule {} diff --git a/apps/api/src/ops/ops.service.ts b/apps/api/src/ops/ops.service.ts new file mode 100644 index 0000000..891b98a --- /dev/null +++ b/apps/api/src/ops/ops.service.ts @@ -0,0 +1,361 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, + OnModuleInit, +} from "@nestjs/common"; +import { spawn } from "node:child_process"; +import { createReadStream, promises as fs } from "node:fs"; +import * as path from "node:path"; +import { OpsJobKind } from "@jorgecuadros/database"; +import { PrismaService } from "../prisma/prisma.service"; + +/** + * Admin database operations. Everything long-running (mysqldump, mysql restore, + * the Python migration) runs as a detached child process recorded as one OpsJob + * row whose `log` is appended as the process talks; the web polls that row. + * + * Only ONE mutating job runs at a time (a RUNNING row blocks a new start) — a + * restore or re-import racing a migration would corrupt the database. + */ + +/** The four legacy Access files. Uploads are allowlisted to exactly these + * names so an ingest write can never land at an arbitrary path. */ +export const INGEST_FILES = [ + "UTILITIES.accdb", + "SEGUROS 16.mdb", + "SEGUROS 16_be.mdb", + "SCOTHIA.mdb", +] as const; +export type IngestName = (typeof INGEST_FILES)[number]; + +interface MysqlConn { + host: string; + port: string; + user: string; + password: string; + database: string; +} + +@Injectable() +export class OpsService implements OnModuleInit { + private readonly logger = new Logger(OpsService.name); + + private readonly migrationDir = + process.env.MIGRATION_DIR ?? path.resolve(process.cwd(), "migration"); + private readonly ingestDir = + process.env.INGEST_DIR ?? path.join(this.migrationDir, "ingest"); + private readonly backupDir = + process.env.BACKUP_DIR ?? path.join(this.migrationDir, "backups"); + private readonly migrationEnv = process.env.MIGRATION_ENV ?? "dev"; + + constructor(private readonly prisma: PrismaService) {} + + async onModuleInit(): Promise { + await fs.mkdir(this.ingestDir, { recursive: true }); + await fs.mkdir(this.backupDir, { recursive: true }); + } + + /* -------------------------------------------------------------- ingest */ + + private assertIngestName(name: string): IngestName { + if (!INGEST_FILES.includes(name as IngestName)) { + throw new BadRequestException( + `Archivo no permitido. Debe ser uno de: ${INGEST_FILES.join(", ")}`, + ); + } + return name as IngestName; + } + + async listIngest(): Promise< + { name: string; present: boolean; size: number | null; modifiedAt: string | null }[] + > { + return Promise.all( + INGEST_FILES.map(async (name) => { + try { + const st = await fs.stat(path.join(this.ingestDir, name)); + return { + name, + present: true, + size: st.size, + modifiedAt: st.mtime.toISOString(), + }; + } catch { + return { name, present: false, size: null, modifiedAt: null }; + } + }), + ); + } + + async saveIngest(name: string, data: Buffer): Promise { + const safe = this.assertIngestName(name); + await fs.writeFile(path.join(this.ingestDir, safe), data); + } + + async deleteIngest(name: string): Promise { + const safe = this.assertIngestName(name); + await fs.rm(path.join(this.ingestDir, safe), { force: true }); + } + + /* ------------------------------------------------------------- backups */ + + private assertBackupName(name: string): string { + // No path separators, must be a produced backup file. + if (!/^[A-Za-z0-9._-]+\.sql\.gz$/.test(name)) { + throw new BadRequestException("Nombre de respaldo inválido."); + } + return name; + } + + async listBackups(): Promise< + { name: string; size: number; createdAt: string }[] + > { + let names: string[]; + try { + names = await fs.readdir(this.backupDir); + } catch { + return []; + } + const rows = await Promise.all( + names + .filter((n) => n.endsWith(".sql.gz")) + .map(async (name) => { + const st = await fs.stat(path.join(this.backupDir, name)); + return { name, size: st.size, createdAt: st.mtime.toISOString() }; + }), + ); + return rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + } + + backupStream(name: string) { + const safe = this.assertBackupName(name); + const full = path.join(this.backupDir, safe); + return { stream: createReadStream(full), name: safe }; + } + + async deleteBackup(name: string): Promise { + const safe = this.assertBackupName(name); + await fs.rm(path.join(this.backupDir, safe), { force: true }); + } + + /* ---------------------------------------------------------------- jobs */ + + listJobs(limit = 20) { + return this.prisma.opsJob.findMany({ + orderBy: { startedAt: "desc" }, + take: limit, + }); + } + + async getJob(id: string) { + const job = await this.prisma.opsJob.findUnique({ where: { id } }); + if (!job) throw new NotFoundException("Trabajo no encontrado."); + return job; + } + + /** + * Start a mutating op. Refuses if another job is already RUNNING. Returns the + * new job row immediately; the process runs on in the background and appends + * to `log` until it exits. + */ + async startJob( + kind: OpsJobKind, + params: Record, + userId: string | undefined, + ) { + const running = await this.prisma.opsJob.count({ where: { status: "RUNNING" } }); + if (running > 0) { + throw new ConflictException( + "Ya hay una operación en curso. Espere a que termine.", + ); + } + + const conn = this.parseDbUrl(); + const { cmd, resolvedParams } = await this.buildCommand(kind, params, conn); + + const job = await this.prisma.opsJob.create({ + data: { + kind, + status: "RUNNING", + log: "", + params: resolvedParams as object, + createdById: userId, + }, + }); + + this.run(job.id, cmd, conn.password); + return job; + } + + /* --------------------------------------------------------- internals */ + + private parseDbUrl(): MysqlConn { + const raw = process.env.DATABASE_URL; + if (!raw) throw new BadRequestException("DATABASE_URL no está configurada."); + const u = new URL(raw); + return { + host: u.hostname, + port: u.port || "3306", + user: decodeURIComponent(u.username), + password: decodeURIComponent(u.password), + database: u.pathname.replace(/^\//, ""), + }; + } + + /** mysql/mysqldump connection flags. The password goes through MYSQL_PWD in + * the child env, never on the command line (which would leak via `ps`). */ + private connFlags(c: MysqlConn): string { + return `--host=${c.host} --port=${c.port} --user=${shq(c.user)}`; + } + + private timestamp(): string { + return new Date().toISOString().replace(/[:.]/g, "-").replace("T", "_").slice(0, 19); + } + + private async buildCommand( + kind: OpsJobKind, + params: Record, + conn: MysqlConn, + ): Promise<{ cmd: string; resolvedParams: Record }> { + const flags = this.connFlags(conn); + const db = shq(conn.database); + + if (kind === "BACKUP") { + const file = `backup-${this.migrationEnv}-${this.timestamp()}.sql.gz`; + const out = shq(path.join(this.backupDir, file)); + return { + cmd: `mysqldump ${flags} --single-transaction --routines --triggers --no-tablespaces ${db} | gzip -c > ${out}`, + resolvedParams: { file }, + }; + } + + if (kind === "RESTORE") { + const name = this.assertBackupName(String(params.file ?? "")); + const full = path.join(this.backupDir, name); + await fs.access(full).catch(() => { + throw new NotFoundException(`Respaldo no encontrado: ${name}`); + }); + return { + cmd: `gunzip -c ${shq(full)} | mysql ${flags} ${db}`, + resolvedParams: { file: name }, + }; + } + + if (kind === "SYNC") { + const file = `pre-sync-${this.migrationEnv}-${this.timestamp()}.sql.gz`; + const out = shq(path.join(this.backupDir, file)); + const py = await this.pythonBin(); + const runAll = shq(path.join(this.migrationDir, "run_all.py")); + const cmd = + `echo '== Respaldo de seguridad previo ==' && ` + + `mysqldump ${flags} --single-transaction --routines --triggers --no-tablespaces ${db} | gzip -c > ${out} && ` + + `echo '== Sincronización aditiva desde carpeta de ingesta ==' && ` + + `${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --sync`; + return { cmd, resolvedParams: { safetyBackup: file } }; + } + + if (kind === "REIMPORT") { + // Safety backup first, then a full truncate+rebuild from the ingest files. + const file = `pre-reimport-${this.migrationEnv}-${this.timestamp()}.sql.gz`; + const out = shq(path.join(this.backupDir, file)); + const py = await this.pythonBin(); + const runAll = shq(path.join(this.migrationDir, "run_all.py")); + const cmd = + `echo '== Respaldo de seguridad previo ==' && ` + + `mysqldump ${flags} --single-transaction --routines --triggers --no-tablespaces ${db} | gzip -c > ${out} && ` + + `echo '== Reimportación desde carpeta de ingesta ==' && ` + + `${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --stage`; + return { cmd, resolvedParams: { safetyBackup: file } }; + } + + throw new BadRequestException(`Operación no soportada: ${kind}`); + } + + /** Prefer the migration venv python if it exists (local dev), else system. */ + private async pythonBin(): Promise { + const venv = path.join(this.migrationDir, ".venv", "bin", "python"); + try { + await fs.access(venv); + return venv; + } catch { + return process.env.PYTHON_BIN ?? "python3"; + } + } + + private run(jobId: string, cmd: string, password: string): void { + const child = spawn("sh", ["-c", cmd], { + cwd: this.migrationDir, + env: { + ...process.env, + MYSQL_PWD: password, + INGEST_DIR: this.ingestDir, + BACKUP_DIR: this.backupDir, + }, + }); + + let buffer = ""; + let pending = ""; + let flushing = false; + let flushTimer: NodeJS.Timeout | null = null; + + const flush = async () => { + if (flushing || !pending) return; + flushing = true; + const chunk = pending; + pending = ""; + try { + await this.prisma.opsJob.update({ + where: { id: jobId }, + data: { log: { set: buffer } }, + }); + } catch (e) { + this.logger.warn(`ops job ${jobId} log flush failed: ${String(e)}`); + } finally { + flushing = false; + void chunk; + } + }; + + const onData = (d: Buffer) => { + const text = d.toString(); + buffer += text; + pending += text; + if (!flushTimer) { + flushTimer = setTimeout(() => { + flushTimer = null; + void flush(); + }, 1000); + } + }; + + child.stdout.on("data", onData); + child.stderr.on("data", onData); + + const finalize = async (status: "SUCCESS" | "FAILED", tail: string) => { + if (flushTimer) clearTimeout(flushTimer); + buffer += tail; + await this.prisma.opsJob + .update({ + where: { id: jobId }, + data: { status, log: { set: buffer }, finishedAt: new Date() }, + }) + .catch((e) => this.logger.error(`ops job ${jobId} finalize failed: ${String(e)}`)); + }; + + child.on("error", (err) => { + void finalize("FAILED", `\n[proceso no pudo iniciar] ${err.message}\n`); + }); + + child.on("close", (code) => { + if (code === 0) void finalize("SUCCESS", `\n[completado con éxito]\n`); + else void finalize("FAILED", `\n[terminó con código ${code}]\n`); + }); + } +} + +/** Single-quote a value for a POSIX shell command. */ +function shq(v: string): string { + return `'${v.replace(/'/g, `'\\''`)}'`; +} diff --git a/apps/api/src/ops/start-job.dto.ts b/apps/api/src/ops/start-job.dto.ts new file mode 100644 index 0000000..80bf26d --- /dev/null +++ b/apps/api/src/ops/start-job.dto.ts @@ -0,0 +1,12 @@ +import { IsEnum, IsOptional, IsString } from "class-validator"; +import { OpsJobKind } from "@jorgecuadros/database"; + +export class StartJobDto { + @IsEnum(OpsJobKind) + kind!: OpsJobKind; + + /** Target backup filename — required for RESTORE, ignored otherwise. */ + @IsOptional() + @IsString() + file?: string; +} diff --git a/apps/api/src/policies/children.dto.ts b/apps/api/src/policies/children.dto.ts new file mode 100644 index 0000000..f9dd95b --- /dev/null +++ b/apps/api/src/policies/children.dto.ts @@ -0,0 +1,78 @@ +import { + IsBoolean, + IsEmail, + IsEnum, + IsInt, + IsNumber, + IsOptional, + IsString, +} from "class-validator"; +import { Currency } from "@jorgecuadros/database"; + +// Each child DTO covers create; updates reuse the same shape with all fields +// optional via the corresponding Update class. Route supplies the policyId. + +export class InstallmentDto { + @IsInt() sequence!: number; + @IsOptional() @IsNumber() amount?: number; + @IsOptional() @IsEnum(Currency) currency?: Currency; + @IsOptional() @IsString() dueDate?: string; + @IsOptional() @IsString() paidDate?: string; + @IsOptional() @IsString() checkNumber?: string; + @IsOptional() @IsBoolean() isCash?: boolean; +} +export class UpdateInstallmentDto { + @IsOptional() @IsInt() sequence?: number; + @IsOptional() @IsNumber() amount?: number; + @IsOptional() @IsEnum(Currency) currency?: Currency; + @IsOptional() @IsString() dueDate?: string; + @IsOptional() @IsString() paidDate?: string; + @IsOptional() @IsString() checkNumber?: string; + @IsOptional() @IsBoolean() isCash?: boolean; +} + +export class VehicleDto { + @IsOptional() @IsString() make?: string; + @IsOptional() @IsString() model?: string; + @IsOptional() @IsString() modelYear?: string; + @IsOptional() @IsString() bodyType?: string; + @IsOptional() @IsString() engineNumber?: string; + @IsOptional() @IsString() licensePlate?: string; + @IsOptional() @IsString() vinNumber?: string; + @IsOptional() @IsString() stateCode?: string; + @IsOptional() @IsString() notes?: string; +} +export class UpdateVehicleDto extends VehicleDto {} + +export class DriverDto { + @IsOptional() @IsString() fullName?: string; + @IsOptional() @IsString() birthDate?: string; + @IsOptional() @IsString() sex?: string; + @IsOptional() @IsString() occupation?: string; + @IsOptional() @IsString() licenseNumber?: string; + @IsOptional() @IsString() licenseState?: string; +} +export class UpdateDriverDto extends DriverDto {} + +export class BeneficiaryDto { + @IsOptional() @IsString() name?: string; + @IsOptional() @IsString() address?: string; + @IsOptional() @IsString() phone?: string; + @IsOptional() @IsEmail() email?: string; +} +export class UpdateBeneficiaryDto extends BeneficiaryDto {} + +export class ClaimDto { + @IsOptional() @IsString() claimType?: string; + @IsOptional() @IsString() incidentDate?: string; + @IsOptional() @IsString() reportedDate?: string; + @IsOptional() @IsString() description?: string; + @IsOptional() @IsString() adjusterId?: string; + @IsOptional() @IsNumber() claimedAmount?: number; + @IsOptional() @IsNumber() settledAmount?: number; + @IsOptional() @IsString() settlementDate?: string; + @IsOptional() @IsString() checkNumber?: string; + @IsOptional() @IsBoolean() resolved?: boolean; + @IsOptional() @IsString() resolutionNotes?: string; +} +export class UpdateClaimDto extends ClaimDto {} diff --git a/apps/api/src/policies/lookup.dto.ts b/apps/api/src/policies/lookup.dto.ts new file mode 100644 index 0000000..02e6c31 --- /dev/null +++ b/apps/api/src/policies/lookup.dto.ts @@ -0,0 +1,26 @@ +import { IsOptional, IsString, MinLength } from "class-validator"; + +export class ProviderDto { + @IsString() @MinLength(1) name!: string; +} +export class UpdateProviderDto { + @IsOptional() @IsString() @MinLength(1) name?: string; +} + +export class PolicyTypeDto { + @IsString() @MinLength(1) name!: string; + @IsOptional() @IsString() shortDescription?: string; +} +export class UpdatePolicyTypeDto { + @IsOptional() @IsString() @MinLength(1) name?: string; + @IsOptional() @IsString() shortDescription?: string; +} + +export class AdjusterDto { + @IsOptional() @IsString() company?: string; + @IsOptional() @IsString() city?: string; + @IsOptional() @IsString() name?: string; + @IsOptional() @IsString() phone?: string; + @IsOptional() @IsString() beeper?: string; +} +export class UpdateAdjusterDto extends AdjusterDto {} diff --git a/apps/api/src/policies/lookups.controller.ts b/apps/api/src/policies/lookups.controller.ts new file mode 100644 index 0000000..7a31e55 --- /dev/null +++ b/apps/api/src/policies/lookups.controller.ts @@ -0,0 +1,146 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Req, + UseGuards, +} from "@nestjs/common"; +import { Request } from "express"; +import { AuthenticatedGuard } from "../auth/authenticated.guard"; +import { AbilityGuard } from "../auth/ability.guard"; +import { RequireAbility } from "../auth/require-ability.decorator"; +import { AuditService } from "../common/audit.service"; +import { PoliciesService } from "./policies.service"; +import { + AdjusterDto, + PolicyTypeDto, + ProviderDto, + UpdateAdjusterDto, + UpdatePolicyTypeDto, + UpdateProviderDto, +} from "./lookup.dto"; + +/** + * Insurance reference data: providers, policy types, adjusters. Reading is open + * to any authenticated user (the policy form needs the options); mutating needs + * "lookup:manage" (MANAGER+). + */ +@UseGuards(AuthenticatedGuard, AbilityGuard) +@Controller("lookups") +export class LookupsController { + constructor( + private readonly policies: PoliciesService, + private readonly audit: AuditService, + ) {} + + private actingId(req: Request): string { + return (req.user as { id: string }).id; + } + + @Get() + list() { + return this.policies.listLookups(); + } + + @Post("providers") + @RequireAbility("lookup:manage") + async createProvider(@Body() dto: ProviderDto, @Req() req: Request) { + const row = await this.policies.createProvider(dto); + void this.audit.log(this.actingId(req), "lookup.provider.create", { + providerId: row.id, + name: row.name, + }); + return row; + } + @Patch("providers/:id") + @RequireAbility("lookup:manage") + async updateProvider( + @Param("id") id: string, + @Body() dto: UpdateProviderDto, + @Req() req: Request, + ) { + const row = await this.policies.updateProvider(id, dto); + void this.audit.log(this.actingId(req), "lookup.provider.update", { + providerId: id, + }); + return row; + } + @Delete("providers/:id") + @RequireAbility("lookup:manage") + async removeProvider(@Param("id") id: string, @Req() req: Request) { + const row = await this.policies.removeProvider(id); + void this.audit.log(this.actingId(req), "lookup.provider.delete", { + providerId: id, + }); + return row; + } + + @Post("policy-types") + @RequireAbility("lookup:manage") + async createType(@Body() dto: PolicyTypeDto, @Req() req: Request) { + const row = await this.policies.createPolicyType(dto); + void this.audit.log(this.actingId(req), "lookup.policyType.create", { + policyTypeId: row.id, + name: row.name, + }); + return row; + } + @Patch("policy-types/:id") + @RequireAbility("lookup:manage") + async updateType( + @Param("id") id: string, + @Body() dto: UpdatePolicyTypeDto, + @Req() req: Request, + ) { + const row = await this.policies.updatePolicyType(id, dto); + void this.audit.log(this.actingId(req), "lookup.policyType.update", { + policyTypeId: id, + }); + return row; + } + @Delete("policy-types/:id") + @RequireAbility("lookup:manage") + async removeType(@Param("id") id: string, @Req() req: Request) { + const row = await this.policies.removePolicyType(id); + void this.audit.log(this.actingId(req), "lookup.policyType.delete", { + policyTypeId: id, + }); + return row; + } + + @Post("adjusters") + @RequireAbility("lookup:manage") + async createAdjuster(@Body() dto: AdjusterDto, @Req() req: Request) { + const row = await this.policies.createAdjuster(dto); + void this.audit.log(this.actingId(req), "lookup.adjuster.create", { + adjusterId: row.id, + }); + return row; + } + @Patch("adjusters/:id") + @RequireAbility("lookup:manage") + async updateAdjuster( + @Param("id") id: string, + @Body() dto: UpdateAdjusterDto, + @Req() req: Request, + ) { + const row = await this.policies.updateAdjuster(id, dto); + void this.audit.log(this.actingId(req), "lookup.adjuster.update", { + adjusterId: id, + }); + return row; + } + @Delete("adjusters/:id") + @RequireAbility("lookup:manage") + async removeAdjuster(@Param("id") id: string, @Req() req: Request) { + const row = await this.policies.removeAdjuster(id); + void this.audit.log(this.actingId(req), "lookup.adjuster.delete", { + adjusterId: id, + }); + return row; + } +} diff --git a/apps/api/src/policies/policies.controller.ts b/apps/api/src/policies/policies.controller.ts index 31a3d38..706af73 100644 --- a/apps/api/src/policies/policies.controller.ts +++ b/apps/api/src/policies/policies.controller.ts @@ -1,10 +1,37 @@ -import { Controller, Get, Param, Query, UseGuards } from "@nestjs/common"; +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + Req, + UseGuards, +} from "@nestjs/common"; +import { Request } from "express"; import { AuthenticatedGuard } from "../auth/authenticated.guard"; +import { AbilityGuard } from "../auth/ability.guard"; +import { RequireAbility } from "../auth/require-ability.decorator"; +import { AuditService } from "../common/audit.service"; import { PoliciesService, type PolicySort, type PolicyStatus, } from "./policies.service"; +import { CreatePolicyDto, UpdatePolicyDto } from "./policy.dto"; +import { + BeneficiaryDto, + ClaimDto, + DriverDto, + InstallmentDto, + UpdateBeneficiaryDto, + UpdateClaimDto, + UpdateDriverDto, + UpdateInstallmentDto, + VehicleDto, +} from "./children.dto"; const STATUSES: PolicyStatus[] = ["active", "expiring", "expired", "undated"]; const SORTS: PolicySort[] = [ @@ -15,15 +42,21 @@ const SORTS: PolicySort[] = [ "premium_desc", ]; -/** Clamped expiry window; 30 days is the default renewal horizon. */ function parseDays(days?: string): number { return Math.min(365, Math.max(1, Number(days) || 30)); } -@UseGuards(AuthenticatedGuard) +@UseGuards(AuthenticatedGuard, AbilityGuard) @Controller("policies") export class PoliciesController { - constructor(private readonly policies: PoliciesService) {} + constructor( + private readonly policies: PoliciesService, + private readonly audit: AuditService, + ) {} + + private actingId(req: Request): string { + return (req.user as { id: string }).id; + } @Get("stats") stats(@Query("days") days?: string) { @@ -45,6 +78,7 @@ export class PoliciesController { @Query("typeId") typeId?: string, @Query("providerId") providerId?: string, @Query("liquidated") liquidated?: string, + @Query("includeArchived") includeArchived?: string, @Query("sort") sort?: string, ) { return this.policies.list({ @@ -59,6 +93,7 @@ export class PoliciesController { providerId: providerId || undefined, liquidated: liquidated === "true" ? true : liquidated === "false" ? false : undefined, + includeArchived: includeArchived === "true", sort: SORTS.includes(sort as PolicySort) ? (sort as PolicySort) : "expiry_desc", @@ -69,4 +104,140 @@ export class PoliciesController { detail(@Param("id") id: string, @Query("days") days?: string) { return this.policies.detail(id, parseDays(days)); } + + // --- header writes -------------------------------------------------------- + + @Post() + @RequireAbility("policy:create") + async create(@Body() dto: CreatePolicyDto, @Req() req: Request) { + const p = await this.policies.create(dto); + void this.audit.log(this.actingId(req), "policy.create", { policyId: p.id }); + return p; + } + + @Patch(":id") + @RequireAbility("policy:update") + async update(@Param("id") id: string, @Body() dto: UpdatePolicyDto, @Req() req: Request) { + const p = await this.policies.update(id, dto); + void this.audit.log(this.actingId(req), "policy.update", { policyId: id }); + return p; + } + + @Delete(":id") + @RequireAbility("policy:delete") + async archive(@Param("id") id: string, @Req() req: Request) { + const p = await this.policies.archive(id); + void this.audit.log(this.actingId(req), "policy.archive", { policyId: id }); + return p; + } + + @Post(":id/restore") + @RequireAbility("policy:delete") + async restore(@Param("id") id: string, @Req() req: Request) { + const p = await this.policies.restore(id); + void this.audit.log(this.actingId(req), "policy.restore", { policyId: id }); + return p; + } + + // --- children (all editing a policy => policy:update) --------------------- + + @Post(":id/installments") + @RequireAbility("policy:update") + addInstallment(@Param("id") id: string, @Body() dto: InstallmentDto) { + return this.policies.addInstallment(id, dto); + } + @Patch(":id/installments/:childId") + @RequireAbility("policy:update") + updateInstallment( + @Param("id") id: string, + @Param("childId") childId: string, + @Body() dto: UpdateInstallmentDto, + ) { + return this.policies.updateInstallment(id, childId, dto); + } + @Delete(":id/installments/:childId") + @RequireAbility("policy:update") + removeInstallment(@Param("id") id: string, @Param("childId") childId: string) { + return this.policies.removeInstallment(id, childId); + } + + @Post(":id/vehicles") + @RequireAbility("policy:update") + addVehicle(@Param("id") id: string, @Body() dto: VehicleDto) { + return this.policies.addVehicle(id, dto); + } + @Patch(":id/vehicles/:childId") + @RequireAbility("policy:update") + updateVehicle( + @Param("id") id: string, + @Param("childId") childId: string, + @Body() dto: VehicleDto, + ) { + return this.policies.updateVehicle(id, childId, dto); + } + @Delete(":id/vehicles/:childId") + @RequireAbility("policy:update") + removeVehicle(@Param("id") id: string, @Param("childId") childId: string) { + return this.policies.removeVehicle(id, childId); + } + + @Post(":id/drivers") + @RequireAbility("policy:update") + addDriver(@Param("id") id: string, @Body() dto: DriverDto) { + return this.policies.addDriver(id, dto); + } + @Patch(":id/drivers/:childId") + @RequireAbility("policy:update") + updateDriver( + @Param("id") id: string, + @Param("childId") childId: string, + @Body() dto: UpdateDriverDto, + ) { + return this.policies.updateDriver(id, childId, dto); + } + @Delete(":id/drivers/:childId") + @RequireAbility("policy:update") + removeDriver(@Param("id") id: string, @Param("childId") childId: string) { + return this.policies.removeDriver(id, childId); + } + + @Post(":id/beneficiaries") + @RequireAbility("policy:update") + addBeneficiary(@Param("id") id: string, @Body() dto: BeneficiaryDto) { + return this.policies.addBeneficiary(id, dto); + } + @Patch(":id/beneficiaries/:childId") + @RequireAbility("policy:update") + updateBeneficiary( + @Param("id") id: string, + @Param("childId") childId: string, + @Body() dto: UpdateBeneficiaryDto, + ) { + return this.policies.updateBeneficiary(id, childId, dto); + } + @Delete(":id/beneficiaries/:childId") + @RequireAbility("policy:update") + removeBeneficiary(@Param("id") id: string, @Param("childId") childId: string) { + return this.policies.removeBeneficiary(id, childId); + } + + @Post(":id/claims") + @RequireAbility("policy:update") + addClaim(@Param("id") id: string, @Body() dto: ClaimDto) { + return this.policies.addClaim(id, dto); + } + @Patch(":id/claims/:childId") + @RequireAbility("policy:update") + updateClaim( + @Param("id") id: string, + @Param("childId") childId: string, + @Body() dto: UpdateClaimDto, + ) { + return this.policies.updateClaim(id, childId, dto); + } + @Delete(":id/claims/:childId") + @RequireAbility("policy:update") + removeClaim(@Param("id") id: string, @Param("childId") childId: string) { + return this.policies.removeClaim(id, childId); + } } diff --git a/apps/api/src/policies/policies.module.ts b/apps/api/src/policies/policies.module.ts index f6ce42c..b3d1389 100644 --- a/apps/api/src/policies/policies.module.ts +++ b/apps/api/src/policies/policies.module.ts @@ -1,9 +1,10 @@ import { Module } from "@nestjs/common"; import { PoliciesController } from "./policies.controller"; +import { LookupsController } from "./lookups.controller"; import { PoliciesService } from "./policies.service"; @Module({ - controllers: [PoliciesController], + controllers: [PoliciesController, LookupsController], providers: [PoliciesService], }) export class PoliciesModule {} diff --git a/apps/api/src/policies/policies.service.ts b/apps/api/src/policies/policies.service.ts index a18e213..df64066 100644 --- a/apps/api/src/policies/policies.service.ts +++ b/apps/api/src/policies/policies.service.ts @@ -1,6 +1,27 @@ import { Injectable, NotFoundException } from "@nestjs/common"; import { Prisma } from "@jorgecuadros/database"; import { PrismaService } from "../prisma/prisma.service"; +import { toDate } from "../common/coerce"; +import { CreatePolicyDto, UpdatePolicyDto } from "./policy.dto"; +import { + BeneficiaryDto, + ClaimDto, + DriverDto, + InstallmentDto, + UpdateBeneficiaryDto, + UpdateClaimDto, + UpdateDriverDto, + UpdateInstallmentDto, + VehicleDto, +} from "./children.dto"; +import { + AdjusterDto, + PolicyTypeDto, + ProviderDto, + UpdateAdjusterDto, + UpdatePolicyTypeDto, + UpdateProviderDto, +} from "./lookup.dto"; /** * Vigencia buckets, derived from `policyTo` against today. `undated` is a real @@ -27,6 +48,7 @@ export interface ListParams { typeId?: string; providerId?: string; liquidated?: boolean; + includeArchived?: boolean; sort: PolicySort; } @@ -97,11 +119,13 @@ export class PoliciesService { /** Policy list with search, vigencia/type/provider filters, paginated. */ async list(params: ListParams) { - const { query, page, pageSize, status, days, typeId, providerId, liquidated, sort } = - params; + const { query, page, pageSize, status, days, typeId, providerId, liquidated, + includeArchived, sort } = params; const where: Prisma.PolicyWhereInput = { ...this.statusWhere(status, days) }; + if (!includeArchived) where.archivedAt = null; + if (query && query.trim()) { const q = query.trim(); where.OR = [ @@ -134,6 +158,7 @@ export class PoliciesService { total: true, currency: true, liquidated: true, + archivedAt: true, customer: { select: { id: true, name: true, city: true } }, policyType: { select: { id: true, name: true } }, insuranceProvider: { select: { id: true, name: true } }, @@ -155,6 +180,7 @@ export class PoliciesService { total: r.total, currency: r.currency, liquidated: r.liquidated, + archived: r.archivedAt != null, customerId: r.customer.id, customerName: r.customer.name, customerCity: r.customer.city, @@ -278,4 +304,229 @@ export class PoliciesService { daysToExpiry: daysUntil(policy.policyTo, from), }; } + + // --- policy header writes ------------------------------------------------- + + private headerData(dto: CreatePolicyDto | UpdatePolicyDto) { + const { policyDate, policyFrom, policyTo, liquidationDate, ...rest } = + dto as CreatePolicyDto; + return { + ...rest, + ...(policyDate !== undefined && { policyDate: toDate(policyDate) }), + ...(policyFrom !== undefined && { policyFrom: toDate(policyFrom) }), + ...(policyTo !== undefined && { policyTo: toDate(policyTo) }), + ...(liquidationDate !== undefined && { liquidationDate: toDate(liquidationDate) }), + }; + } + + async create(dto: CreatePolicyDto) { + // Validate the customer FK up front for a clean 404 instead of a raw + // Prisma constraint error. + const customer = await this.prisma.customer.findUnique({ + where: { id: dto.customerId }, + select: { id: true }, + }); + if (!customer) throw new NotFoundException(`Customer ${dto.customerId} not found`); + + return this.prisma.policy.create({ + data: { + ...this.headerData(dto), + policyNumber: dto.policyNumber, + customerId: dto.customerId, + }, + }); + } + + async update(id: string, dto: UpdatePolicyDto) { + await this.ensurePolicy(id); + return this.prisma.policy.update({ where: { id }, data: this.headerData(dto) }); + } + + async archive(id: string) { + await this.ensurePolicy(id); + return this.prisma.policy.update({ where: { id }, data: { archivedAt: new Date() } }); + } + + async restore(id: string) { + await this.ensurePolicy(id); + return this.prisma.policy.update({ where: { id }, data: { archivedAt: null } }); + } + + private async ensurePolicy(id: string) { + const found = await this.prisma.policy.findUnique({ + where: { id }, + select: { id: true }, + }); + if (!found) throw new NotFoundException(`Policy ${id} not found`); + } + + // --- child rows ----------------------------------------------------------- + // Each child is created under a policy and edited/removed by its own id, + // scoped to that policy so one policy's id can't touch another's rows. + + private async ensureChild( + model: "policyPaymentInstallment" | "vehicle" | "insuredDriver" | "policyBeneficiary" | "claim", + policyId: string, + childId: string, + ) { + await this.ensurePolicy(policyId); + // @ts-expect-error dynamic delegate access is safe for these known models + const row = await this.prisma[model].findFirst({ + where: { id: childId, policyId }, + select: { id: true }, + }); + if (!row) throw new NotFoundException(`Child ${childId} not found on policy ${policyId}`); + } + + async addInstallment(policyId: string, dto: InstallmentDto) { + await this.ensurePolicy(policyId); + return this.prisma.policyPaymentInstallment.create({ + data: { + policyId, + sequence: dto.sequence, + amount: dto.amount, + currency: dto.currency, + dueDate: toDate(dto.dueDate) ?? undefined, + paidDate: toDate(dto.paidDate) ?? undefined, + checkNumber: dto.checkNumber, + isCash: dto.isCash, + }, + }); + } + async updateInstallment(policyId: string, id: string, dto: UpdateInstallmentDto) { + await this.ensureChild("policyPaymentInstallment", policyId, id); + return this.prisma.policyPaymentInstallment.update({ + where: { id }, + data: { + sequence: dto.sequence, + amount: dto.amount, + currency: dto.currency, + ...(dto.dueDate !== undefined && { dueDate: toDate(dto.dueDate) }), + ...(dto.paidDate !== undefined && { paidDate: toDate(dto.paidDate) }), + checkNumber: dto.checkNumber, + isCash: dto.isCash, + }, + }); + } + async removeInstallment(policyId: string, id: string) { + await this.ensureChild("policyPaymentInstallment", policyId, id); + return this.prisma.policyPaymentInstallment.delete({ where: { id } }); + } + + async addVehicle(policyId: string, dto: VehicleDto) { + await this.ensurePolicy(policyId); + return this.prisma.vehicle.create({ data: { policyId, ...dto } }); + } + async updateVehicle(policyId: string, id: string, dto: VehicleDto) { + await this.ensureChild("vehicle", policyId, id); + return this.prisma.vehicle.update({ where: { id }, data: { ...dto } }); + } + async removeVehicle(policyId: string, id: string) { + await this.ensureChild("vehicle", policyId, id); + return this.prisma.vehicle.delete({ where: { id } }); + } + + async addDriver(policyId: string, dto: DriverDto) { + await this.ensurePolicy(policyId); + return this.prisma.insuredDriver.create({ + data: { policyId, ...dto, birthDate: toDate(dto.birthDate) ?? undefined }, + }); + } + async updateDriver(policyId: string, id: string, dto: UpdateDriverDto) { + await this.ensureChild("insuredDriver", policyId, id); + return this.prisma.insuredDriver.update({ + where: { id }, + data: { ...dto, ...(dto.birthDate !== undefined && { birthDate: toDate(dto.birthDate) }) }, + }); + } + async removeDriver(policyId: string, id: string) { + await this.ensureChild("insuredDriver", policyId, id); + return this.prisma.insuredDriver.delete({ where: { id } }); + } + + async addBeneficiary(policyId: string, dto: BeneficiaryDto) { + await this.ensurePolicy(policyId); + return this.prisma.policyBeneficiary.create({ data: { policyId, ...dto } }); + } + async updateBeneficiary(policyId: string, id: string, dto: UpdateBeneficiaryDto) { + await this.ensureChild("policyBeneficiary", policyId, id); + return this.prisma.policyBeneficiary.update({ where: { id }, data: { ...dto } }); + } + async removeBeneficiary(policyId: string, id: string) { + await this.ensureChild("policyBeneficiary", policyId, id); + return this.prisma.policyBeneficiary.delete({ where: { id } }); + } + + async addClaim(policyId: string, dto: ClaimDto) { + await this.ensurePolicy(policyId); + return this.prisma.claim.create({ data: { policyId, ...this.claimData(dto) } }); + } + async updateClaim(policyId: string, id: string, dto: UpdateClaimDto) { + await this.ensureChild("claim", policyId, id); + return this.prisma.claim.update({ where: { id }, data: this.claimData(dto) }); + } + async removeClaim(policyId: string, id: string) { + await this.ensureChild("claim", policyId, id); + return this.prisma.claim.delete({ where: { id } }); + } + private claimData(dto: ClaimDto) { + const { incidentDate, reportedDate, settlementDate, ...rest } = dto; + return { + ...rest, + ...(incidentDate !== undefined && { incidentDate: toDate(incidentDate) }), + ...(reportedDate !== undefined && { reportedDate: toDate(reportedDate) }), + ...(settlementDate !== undefined && { settlementDate: toDate(settlementDate) }), + }; + } + + // --- lookups (providers / policy types / adjusters) ----------------------- + + listLookups() { + return this.prisma.$transaction([ + this.prisma.insuranceProvider.findMany({ + orderBy: { name: "asc" }, + select: { id: true, name: true, _count: { select: { policies: true } } }, + }), + this.prisma.policyType.findMany({ + orderBy: { name: "asc" }, + select: { + id: true, + name: true, + shortDescription: true, + _count: { select: { policies: true } }, + }, + }), + this.prisma.adjuster.findMany({ orderBy: { name: "asc" } }), + ]).then(([providers, types, adjusters]) => ({ providers, types, adjusters })); + } + + createProvider(dto: ProviderDto) { + return this.prisma.insuranceProvider.create({ data: dto }); + } + updateProvider(id: string, dto: UpdateProviderDto) { + return this.prisma.insuranceProvider.update({ where: { id }, data: dto }); + } + removeProvider(id: string) { + return this.prisma.insuranceProvider.delete({ where: { id } }); + } + + createPolicyType(dto: PolicyTypeDto) { + return this.prisma.policyType.create({ data: dto }); + } + updatePolicyType(id: string, dto: UpdatePolicyTypeDto) { + return this.prisma.policyType.update({ where: { id }, data: dto }); + } + removePolicyType(id: string) { + return this.prisma.policyType.delete({ where: { id } }); + } + + createAdjuster(dto: AdjusterDto) { + return this.prisma.adjuster.create({ data: dto }); + } + updateAdjuster(id: string, dto: UpdateAdjusterDto) { + return this.prisma.adjuster.update({ where: { id }, data: dto }); + } + removeAdjuster(id: string) { + return this.prisma.adjuster.delete({ where: { id } }); + } } diff --git a/apps/api/src/policies/policy.dto.ts b/apps/api/src/policies/policy.dto.ts new file mode 100644 index 0000000..fe7cce1 --- /dev/null +++ b/apps/api/src/policies/policy.dto.ts @@ -0,0 +1,62 @@ +import { + IsBoolean, + IsInt, + IsNumber, + IsOptional, + IsString, + MinLength, +} from "class-validator"; +import { Currency } from "@jorgecuadros/database"; +import { IsEnum } from "class-validator"; + +/** Editable policy-header fields. coveragesJson (freeform legacy blob) is not + * exposed for editing. Dates arrive as ISO strings and are coerced by the + * service. `total` is legacy-dead data — the UI uses netPremium. */ +export class CreatePolicyDto { + @IsString() @MinLength(1) policyNumber!: string; + @IsString() @MinLength(1) customerId!: string; + + @IsOptional() @IsString() policyTypeId?: string; + @IsOptional() @IsString() insuranceProviderId?: string; + @IsOptional() @IsString() agentName?: string; + @IsOptional() @IsString() policyDate?: string; + @IsOptional() @IsString() policyFrom?: string; + @IsOptional() @IsString() policyTo?: string; + @IsOptional() @IsInt() coveragePeriodDays?: number; + @IsOptional() @IsNumber() netPremium?: number; + @IsOptional() @IsNumber() policyFee?: number; + @IsOptional() @IsNumber() brokerFee?: number; + @IsOptional() @IsNumber() commission?: number; + @IsOptional() @IsNumber() total?: number; + @IsOptional() @IsEnum(Currency) currency?: Currency; + @IsOptional() @IsString() observations?: string; + @IsOptional() @IsString() notes?: string; + @IsOptional() @IsBoolean() endorsement?: boolean; + @IsOptional() @IsBoolean() liquidated?: boolean; + @IsOptional() @IsString() liquidationNumber?: string; + @IsOptional() @IsString() liquidationDate?: string; +} + +/** All header fields optional (customerId is not re-assignable on update). */ +export class UpdatePolicyDto { + @IsOptional() @IsString() @MinLength(1) policyNumber?: string; + @IsOptional() @IsString() policyTypeId?: string; + @IsOptional() @IsString() insuranceProviderId?: string; + @IsOptional() @IsString() agentName?: string; + @IsOptional() @IsString() policyDate?: string; + @IsOptional() @IsString() policyFrom?: string; + @IsOptional() @IsString() policyTo?: string; + @IsOptional() @IsInt() coveragePeriodDays?: number; + @IsOptional() @IsNumber() netPremium?: number; + @IsOptional() @IsNumber() policyFee?: number; + @IsOptional() @IsNumber() brokerFee?: number; + @IsOptional() @IsNumber() commission?: number; + @IsOptional() @IsNumber() total?: number; + @IsOptional() @IsEnum(Currency) currency?: Currency; + @IsOptional() @IsString() observations?: string; + @IsOptional() @IsString() notes?: string; + @IsOptional() @IsBoolean() endorsement?: boolean; + @IsOptional() @IsBoolean() liquidated?: boolean; + @IsOptional() @IsString() liquidationNumber?: string; + @IsOptional() @IsString() liquidationDate?: string; +} diff --git a/apps/api/src/properties/properties.controller.ts b/apps/api/src/properties/properties.controller.ts index b09a3cf..b5abe5b 100644 --- a/apps/api/src/properties/properties.controller.ts +++ b/apps/api/src/properties/properties.controller.ts @@ -1,11 +1,34 @@ -import { Controller, Get, Param, Query, UseGuards } from "@nestjs/common"; +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Put, + Query, + Req, + UseGuards, +} from "@nestjs/common"; import { ServiceKind } from "@jorgecuadros/database"; +import { Request } from "express"; import { AuthenticatedGuard } from "../auth/authenticated.guard"; +import { AbilityGuard } from "../auth/ability.guard"; +import { RequireAbility } from "../auth/require-ability.decorator"; +import { AuditService } from "../common/audit.service"; import { PropertiesService, type PropertySort, type TrustFilter, } from "./properties.service"; +import { + CreatePropertyDto, + ServiceDto, + TrustDto, + UpdatePropertyDto, + UpdateServiceDto, +} from "./property.dto"; const KINDS: ServiceKind[] = [ "WATER", @@ -35,15 +58,21 @@ const SORTS: PropertySort[] = [ "trust_due_desc", ]; -/** Clamped trust-renewal window; 30 days matches the policies module. */ function parseDays(days?: string): number { return Math.min(365, Math.max(1, Number(days) || 30)); } -@UseGuards(AuthenticatedGuard) +@UseGuards(AuthenticatedGuard, AbilityGuard) @Controller("properties") export class PropertiesController { - constructor(private readonly properties: PropertiesService) {} + constructor( + private readonly properties: PropertiesService, + private readonly audit: AuditService, + ) {} + + private actingId(req: Request): string { + return (req.user as { id: string }).id; + } @Get("stats") stats(@Query("days") days?: string) { @@ -67,6 +96,7 @@ export class PropertiesController { @Query("hasServices") hasServices?: string, @Query("customerId") customerId?: string, @Query("days") days?: string, + @Query("includeArchived") includeArchived?: string, @Query("sort") sort?: string, ) { return this.properties.list({ @@ -85,6 +115,7 @@ export class PropertiesController { hasServices === "true" ? true : hasServices === "false" ? false : undefined, customerId: customerId || undefined, days: parseDays(days), + includeArchived: includeArchived === "true", sort: SORTS.includes(sort as PropertySort) ? (sort as PropertySort) : "customer", @@ -95,4 +126,81 @@ export class PropertiesController { detail(@Param("id") id: string, @Query("days") days?: string) { return this.properties.detail(id, parseDays(days)); } + + // --- header writes -------------------------------------------------------- + + @Post() + @RequireAbility("property:create") + async create(@Body() dto: CreatePropertyDto, @Req() req: Request) { + const p = await this.properties.create(dto); + void this.audit.log(this.actingId(req), "property.create", { propertyId: p.id }); + return p; + } + + @Patch(":id") + @RequireAbility("property:update") + async update(@Param("id") id: string, @Body() dto: UpdatePropertyDto, @Req() req: Request) { + const p = await this.properties.update(id, dto); + void this.audit.log(this.actingId(req), "property.update", { propertyId: id }); + return p; + } + + @Delete(":id") + @RequireAbility("property:delete") + async archive(@Param("id") id: string, @Req() req: Request) { + const p = await this.properties.archive(id); + void this.audit.log(this.actingId(req), "property.archive", { propertyId: id }); + return p; + } + + @Post(":id/restore") + @RequireAbility("property:delete") + async restore(@Param("id") id: string, @Req() req: Request) { + const p = await this.properties.restore(id); + void this.audit.log(this.actingId(req), "property.restore", { propertyId: id }); + return p; + } + + // --- services (property:update) ------------------------------------------- + + @Post(":id/services") + @RequireAbility("property:update") + addService(@Param("id") id: string, @Body() dto: ServiceDto) { + return this.properties.addService(id, dto); + } + @Patch(":id/services/:childId") + @RequireAbility("property:update") + updateService( + @Param("id") id: string, + @Param("childId") childId: string, + @Body() dto: UpdateServiceDto, + ) { + return this.properties.updateService(id, childId, dto); + } + @Delete(":id/services/:childId") + @RequireAbility("property:update") + removeService(@Param("id") id: string, @Param("childId") childId: string) { + return this.properties.removeService(id, childId); + } + + // --- trust account (1:1) -------------------------------------------------- + + @Put(":id/trust") + @RequireAbility("property:update") + upsertTrust(@Param("id") id: string, @Body() dto: TrustDto) { + return this.properties.upsertTrust(id, dto); + } + @Delete(":id/trust") + @RequireAbility("property:update") + removeTrust(@Param("id") id: string) { + return this.properties.removeTrust(id); + } + + // --- documents (remove pointer only) -------------------------------------- + + @Delete(":id/documents/:childId") + @RequireAbility("property:update") + removeDocument(@Param("id") id: string, @Param("childId") childId: string) { + return this.properties.removeDocument(id, childId); + } } diff --git a/apps/api/src/properties/properties.service.ts b/apps/api/src/properties/properties.service.ts index cf7a578..1430ebb 100644 --- a/apps/api/src/properties/properties.service.ts +++ b/apps/api/src/properties/properties.service.ts @@ -1,6 +1,14 @@ import { Injectable, NotFoundException } from "@nestjs/common"; import { Prisma, ServiceKind } from "@jorgecuadros/database"; import { PrismaService } from "../prisma/prisma.service"; +import { toDate } from "../common/coerce"; +import { + CreatePropertyDto, + ServiceDto, + TrustDto, + UpdatePropertyDto, + UpdateServiceDto, +} from "./property.dto"; /** * Trust (fideicomiso) renewal buckets, derived from `trustAccount.dueDate2` @@ -37,6 +45,7 @@ export interface ListParams { customerId?: string; /** Window in days for the `expiring` trust bucket. */ days: number; + includeArchived?: boolean; sort: PropertySort; } @@ -134,11 +143,14 @@ export class PropertiesService { hasServices, customerId, days, + includeArchived, sort, } = params; const and: Prisma.PropertyWhereInput[] = [this.trustWhere(trust, days)]; + if (!includeArchived) and.push({ archivedAt: null }); + // Sorting by trust due date is only meaningful for properties that have a // trust; MySQL would otherwise float the ~966 trust-less rows (NULL first // on ASC) above every real due date. Scoping is explicit in the UI label. @@ -191,6 +203,7 @@ export class PropertiesService { phone2: true, phone3: true, zone: true, + archivedAt: true, customer: { select: { id: true, name: true, city: true, state: true }, }, @@ -221,6 +234,7 @@ export class PropertiesService { addressLine1: r.addressLine1, addressLine2: r.addressLine2, zone: r.zone, + archived: r.archivedAt != null, phones: [r.phone1, r.phone2, r.phone3].filter(Boolean) as string[], customerId: r.customer.id, customerName: r.customer.name, @@ -411,7 +425,7 @@ export class PropertiesService { }); const ledger = await this.prisma.transaction.groupBy({ by: ["currency"], - where: { customerId: property.customerId, domain: "UTILITY" }, + where: { customerId: property.customerId, domain: "UTILITY", voidedAt: null }, _sum: { amount: true }, _count: { _all: true }, }); @@ -443,4 +457,102 @@ export class PropertiesService { })), }; } + + // --- property header writes ----------------------------------------------- + + async create(dto: CreatePropertyDto) { + const customer = await this.prisma.customer.findUnique({ + where: { id: dto.customerId }, + select: { id: true }, + }); + if (!customer) throw new NotFoundException(`Customer ${dto.customerId} not found`); + return this.prisma.property.create({ data: { ...dto } }); + } + + async update(id: string, dto: UpdatePropertyDto) { + await this.ensureProperty(id); + return this.prisma.property.update({ where: { id }, data: { ...dto } }); + } + + async archive(id: string) { + await this.ensureProperty(id); + return this.prisma.property.update({ where: { id }, data: { archivedAt: new Date() } }); + } + async restore(id: string) { + await this.ensureProperty(id); + return this.prisma.property.update({ where: { id }, data: { archivedAt: null } }); + } + + private async ensureProperty(id: string) { + const found = await this.prisma.property.findUnique({ + where: { id }, + select: { id: true }, + }); + if (!found) throw new NotFoundException(`Property ${id} not found`); + } + + private async ensureService(propertyId: string, serviceId: string) { + await this.ensureProperty(propertyId); + const row = await this.prisma.propertyService.findFirst({ + where: { id: serviceId, propertyId }, + select: { id: true }, + }); + if (!row) throw new NotFoundException(`Service ${serviceId} not found on property ${propertyId}`); + } + + // --- services ------------------------------------------------------------- + + async addService(propertyId: string, dto: ServiceDto) { + await this.ensureProperty(propertyId); + return this.prisma.propertyService.create({ data: { propertyId, ...dto } }); + } + async updateService(propertyId: string, id: string, dto: UpdateServiceDto) { + await this.ensureService(propertyId, id); + return this.prisma.propertyService.update({ where: { id }, data: { ...dto } }); + } + async removeService(propertyId: string, id: string) { + await this.ensureService(propertyId, id); + return this.prisma.propertyService.delete({ where: { id } }); + } + + // --- trust account (1:1 upsert) ------------------------------------------- + + async upsertTrust(propertyId: string, dto: TrustDto) { + await this.ensureProperty(propertyId); + const data = { + bankName: dto.bankName, + trustNumber: dto.trustNumber, + bankFee: dto.bankFee, + ...(dto.dueDate1 !== undefined && { dueDate1: toDate(dto.dueDate1) }), + ...(dto.dueDate2 !== undefined && { dueDate2: toDate(dto.dueDate2) }), + }; + return this.prisma.trustAccount.upsert({ + where: { propertyId }, + create: { propertyId, ...data }, + update: data, + }); + } + async removeTrust(propertyId: string) { + await this.ensureProperty(propertyId); + const existing = await this.prisma.trustAccount.findUnique({ + where: { propertyId }, + select: { id: true }, + }); + if (!existing) throw new NotFoundException(`No trust account on property ${propertyId}`); + return this.prisma.trustAccount.delete({ where: { propertyId } }); + } + + // --- documents ------------------------------------------------------------ + // Removing a pointer row only; uploading files needs the object-storage + // client wired into the API (today only the migration writes to MinIO). + + async removeDocument(propertyId: string, id: string) { + await this.ensureProperty(propertyId); + const row = await this.prisma.serviceDocument.findFirst({ + where: { id, propertyId }, + select: { id: true }, + }); + if (!row) throw new NotFoundException(`Document ${id} not found on property ${propertyId}`); + return this.prisma.serviceDocument.delete({ where: { id } }); + } } diff --git a/apps/api/src/properties/property.dto.ts b/apps/api/src/properties/property.dto.ts new file mode 100644 index 0000000..f3da599 --- /dev/null +++ b/apps/api/src/properties/property.dto.ts @@ -0,0 +1,58 @@ +import { + IsBoolean, + IsEnum, + IsNumber, + IsOptional, + IsString, + MinLength, +} from "class-validator"; +import { ServiceKind } from "@jorgecuadros/database"; + +export class CreatePropertyDto { + @IsString() @MinLength(1) customerId!: string; + @IsOptional() @IsString() policyId?: string; + @IsOptional() @IsString() addressLine1?: string; + @IsOptional() @IsString() addressLine2?: string; + @IsOptional() @IsString() phone1?: string; + @IsOptional() @IsString() phone2?: string; + @IsOptional() @IsString() phone3?: string; + @IsOptional() @IsString() zone?: string; +} + +export class UpdatePropertyDto { + @IsOptional() @IsString() policyId?: string; + @IsOptional() @IsString() addressLine1?: string; + @IsOptional() @IsString() addressLine2?: string; + @IsOptional() @IsString() phone1?: string; + @IsOptional() @IsString() phone2?: string; + @IsOptional() @IsString() phone3?: string; + @IsOptional() @IsString() zone?: string; +} + +export class ServiceDto { + @IsEnum(ServiceKind) kind!: ServiceKind; + @IsOptional() @IsString() accountNumber?: string; + @IsOptional() @IsString() meterNumber?: string; + @IsOptional() @IsString() route?: string; + @IsOptional() @IsString() dueDay?: string; + @IsOptional() @IsBoolean() active?: boolean; + @IsOptional() @IsString() notes?: string; +} +export class UpdateServiceDto { + @IsOptional() @IsEnum(ServiceKind) kind?: ServiceKind; + @IsOptional() @IsString() accountNumber?: string; + @IsOptional() @IsString() meterNumber?: string; + @IsOptional() @IsString() route?: string; + @IsOptional() @IsString() dueDay?: string; + @IsOptional() @IsBoolean() active?: boolean; + @IsOptional() @IsString() notes?: string; +} + +/** Trust is 1:1 with a property — this both creates and updates it (upsert). */ +export class TrustDto { + @IsOptional() @IsString() bankName?: string; + @IsOptional() @IsString() trustNumber?: string; + @IsOptional() @IsNumber() bankFee?: number; + @IsOptional() @IsString() dueDate1?: string; + @IsOptional() @IsString() dueDate2?: string; +} diff --git a/apps/api/src/users/create-user.dto.ts b/apps/api/src/users/create-user.dto.ts new file mode 100644 index 0000000..06a5db8 --- /dev/null +++ b/apps/api/src/users/create-user.dto.ts @@ -0,0 +1,21 @@ +import { IsEmail, IsEnum, IsOptional, IsString, MinLength } from "class-validator"; +import { UserRole } from "@jorgecuadros/database"; + +export class CreateUserDto { + @IsString() + @MinLength(1) + name!: string; + + @IsEmail() + email!: string; + + @IsString() + @MinLength(8) + password!: string; + + @IsEnum(UserRole) + role!: UserRole; + + @IsOptional() + active?: boolean; +} diff --git a/apps/api/src/users/reset-password.dto.ts b/apps/api/src/users/reset-password.dto.ts new file mode 100644 index 0000000..c103899 --- /dev/null +++ b/apps/api/src/users/reset-password.dto.ts @@ -0,0 +1,7 @@ +import { IsString, MinLength } from "class-validator"; + +export class ResetPasswordDto { + @IsString() + @MinLength(8) + password!: string; +} diff --git a/apps/api/src/users/update-user.dto.ts b/apps/api/src/users/update-user.dto.ts new file mode 100644 index 0000000..e9e4b41 --- /dev/null +++ b/apps/api/src/users/update-user.dto.ts @@ -0,0 +1,22 @@ +import { IsBoolean, IsEmail, IsEnum, IsOptional, IsString, MinLength } from "class-validator"; +import { UserRole } from "@jorgecuadros/database"; + +/** Password changes go through the dedicated reset-password route, not here. */ +export class UpdateUserDto { + @IsOptional() + @IsString() + @MinLength(1) + name?: string; + + @IsOptional() + @IsEmail() + email?: string; + + @IsOptional() + @IsEnum(UserRole) + role?: UserRole; + + @IsOptional() + @IsBoolean() + active?: boolean; +} diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts new file mode 100644 index 0000000..9f641cf --- /dev/null +++ b/apps/api/src/users/users.controller.ts @@ -0,0 +1,72 @@ +import { + Body, + Controller, + Get, + Param, + Patch, + Post, + Req, + UseGuards, +} from "@nestjs/common"; +import { Request } from "express"; +import { AuthenticatedGuard } from "../auth/authenticated.guard"; +import { AbilityGuard } from "../auth/ability.guard"; +import { RequireAbility } from "../auth/require-ability.decorator"; +import { AuditService } from "../common/audit.service"; +import { UsersService } from "./users.service"; +import { CreateUserDto } from "./create-user.dto"; +import { UpdateUserDto } from "./update-user.dto"; +import { ResetPasswordDto } from "./reset-password.dto"; + +/** Every route here is ADMIN-only (ability "user:manage"). */ +@UseGuards(AuthenticatedGuard, AbilityGuard) +@RequireAbility("user:manage") +@Controller("users") +export class UsersController { + constructor( + private readonly users: UsersService, + private readonly audit: AuditService, + ) {} + + private actingId(req: Request): string { + return (req.user as { id: string }).id; + } + + @Get() + list() { + return this.users.list(); + } + + @Post() + async create(@Body() dto: CreateUserDto, @Req() req: Request) { + const user = await this.users.create(dto); + void this.audit.log(this.actingId(req), "user.create", { + userId: user.id, + email: user.email, + role: user.role, + }); + return user; + } + + @Patch(":id") + async update( + @Param("id") id: string, + @Body() dto: UpdateUserDto, + @Req() req: Request, + ) { + const user = await this.users.update(id, dto, this.actingId(req)); + void this.audit.log(this.actingId(req), "user.update", { userId: id, changes: dto }); + return user; + } + + @Post(":id/reset-password") + async resetPassword( + @Param("id") id: string, + @Body() dto: ResetPasswordDto, + @Req() req: Request, + ) { + const user = await this.users.resetPassword(id, dto.password); + void this.audit.log(this.actingId(req), "user.reset_password", { userId: id }); + return user; + } +} diff --git a/apps/api/src/users/users.module.ts b/apps/api/src/users/users.module.ts index 82b5091..29bec3f 100644 --- a/apps/api/src/users/users.module.ts +++ b/apps/api/src/users/users.module.ts @@ -1,8 +1,10 @@ import { Module } from "@nestjs/common"; import { UsersService } from "./users.service"; +import { UsersController } from "./users.controller"; @Module({ providers: [UsersService], + controllers: [UsersController], exports: [UsersService], }) export class UsersModule {} diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index e7c8927..59f8465 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -1,11 +1,35 @@ -import { Injectable } from "@nestjs/common"; -import { PrismaService } from "../prisma/prisma.service"; +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import * as argon2 from "argon2"; +import { Prisma } from "@jorgecuadros/database"; import type { User } from "@jorgecuadros/database"; +import { PrismaService } from "../prisma/prisma.service"; +import { CreateUserDto } from "./create-user.dto"; +import { UpdateUserDto } from "./update-user.dto"; + +/** Shape returned to the UI — never carries passwordHash. */ +const safeSelect = { + id: true, + name: true, + email: true, + role: true, + active: true, + createdAt: true, + updatedAt: true, +} satisfies Prisma.UserSelect; + +export type SafeUserRow = Prisma.UserGetPayload<{ select: typeof safeSelect }>; @Injectable() export class UsersService { constructor(private readonly prisma: PrismaService) {} + // --- used by auth (need the hash / full row) ----------------------------- + findByEmail(email: string): Promise { return this.prisma.user.findUnique({ where: { email } }); } @@ -13,4 +37,89 @@ export class UsersService { findById(id: string): Promise { return this.prisma.user.findUnique({ where: { id } }); } + + // --- admin CRUD (safe rows only) ----------------------------------------- + + list(): Promise { + return this.prisma.user.findMany({ + orderBy: [{ active: "desc" }, { name: "asc" }], + select: safeSelect, + }); + } + + async create(dto: CreateUserDto): Promise { + const passwordHash = await argon2.hash(dto.password); + try { + return await this.prisma.user.create({ + data: { + name: dto.name, + email: dto.email, + passwordHash, + role: dto.role, + active: dto.active ?? true, + }, + select: safeSelect, + }); + } catch (e) { + throw this.mapError(e); + } + } + + /** + * `actingUserId` is the admin making the change — used to stop an admin from + * locking themselves out (deactivating or demoting their own account). + */ + async update( + id: string, + dto: UpdateUserDto, + actingUserId: string, + ): Promise { + await this.ensureExists(id); + + if (id === actingUserId) { + if (dto.active === false) { + throw new BadRequestException("No puede desactivar su propia cuenta"); + } + if (dto.role && dto.role !== "ADMIN") { + throw new BadRequestException("No puede quitarse su propio rol de administrador"); + } + } + + try { + return await this.prisma.user.update({ + where: { id }, + data: { + name: dto.name, + email: dto.email, + role: dto.role, + active: dto.active, + }, + select: safeSelect, + }); + } catch (e) { + throw this.mapError(e); + } + } + + async resetPassword(id: string, password: string): Promise { + await this.ensureExists(id); + const passwordHash = await argon2.hash(password); + return this.prisma.user.update({ + where: { id }, + data: { passwordHash }, + select: safeSelect, + }); + } + + private async ensureExists(id: string): Promise { + const found = await this.prisma.user.findUnique({ where: { id }, select: { id: true } }); + if (!found) throw new NotFoundException(`Usuario ${id} no encontrado`); + } + + private mapError(e: unknown): Error { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") { + return new ConflictException("Ya existe un usuario con ese correo"); + } + return e as Error; + } } diff --git a/apps/web/src/app/banco/page.tsx b/apps/web/src/app/banco/page.tsx index ff2794f..83028fb 100644 --- a/apps/web/src/app/banco/page.tsx +++ b/apps/web/src/app/banco/page.tsx @@ -3,11 +3,14 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { AppShell } from "@/components/AppShell"; import { + createBankMovement, getBankFacets, getBankStats, getBankSummary, listBankMovements, + voidBankMovement, } from "@/lib/api"; +import { useCan } from "@/lib/abilities"; import { bankDirectionLabel, bankSourceLabel, @@ -27,6 +30,7 @@ import type { BankStats, BankSummary, BankTotals, + CreateBankMovementInput, } from "@/lib/types"; /** @@ -77,6 +81,8 @@ export default function BancoPage() { } function BankBrowser() { + const canCapture = useCan("bank:create"); + const canVoid = useCan("bank:void"); const [stats, setStats] = useState(null); const [facets, setFacets] = useState(null); const [view, setView] = useState("movimientos"); @@ -93,6 +99,7 @@ function BankBrowser() { const [summaryYear, setSummaryYear] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [captureOpen, setCaptureOpen] = useState(false); const debounceRef = useRef>(); @@ -237,8 +244,28 @@ function BankBrowser() { ))} + {view === "movimientos" && canCapture && ( + + )} + {view === "movimientos" && captureOpen && ( + { + setCaptureOpen(false); + runSearch(movements?.page ?? 1); + getBankStats().then(setStats).catch(() => setStats(null)); + }} + onCancel={() => setCaptureOpen(false)} + /> + )} + {view === "movimientos" && (