diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..bb37debf --- /dev/null +++ b/.dockerignore @@ -0,0 +1,32 @@ +# Build context excludes — keep images lean and secret-free. + +# deps & build outputs (installed/built fresh inside the image) +**/node_modules +**/.next +**/dist +**/coverage +**/*.tsbuildinfo +.turbo +**/.turbo + +# vcs / editor / tooling +.git +.gitignore +.vscode +.claude +.github + +# secrets & env — injected at RUNTIME via compose env_file; NEXT_PUBLIC_* via build-arg. +# Never bake .env files into an image layer. +**/.env +**/.env.* + +# local-only docs (may contain prior brand text) — never ship in images +*.md +docs +VPS-*.md +CLAUDE.md +AGENTS.md + +# screenshots / misc binaries +**/*.png diff --git a/.gitignore b/.gitignore index a18d53ec..b5af9b95 100644 --- a/.gitignore +++ b/.gitignore @@ -37,7 +37,7 @@ yarn-error.log* .DS_Store *.pem -# --- Forward-Mena additions --- +# --- Property Manager additions --- # Harden env handling: ignore every env file except *.env.example .env .env.* @@ -51,7 +51,7 @@ yarn-error.log* id_* *_rsa *_ed25519 -forward_mena_vps* +*_vps* # Private / local-only docs — NEVER commit to the shared bootcamp repo docs/ @@ -69,5 +69,11 @@ temporary.md # Local issue tracking issues/ +# Local PM2 process manager config (machine-specific: ssh alias, ports) +ecosystem.config.js + # TypeScript incremental build info *.tsbuildinfo + +# Playwright MCP local artifacts (screenshots, traces) +.playwright-mcp/ diff --git a/AGENTS.md b/AGENTS.md index 00929d5d..14cef52e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,15 +1,15 @@ -# AGENTS.md — Forward-Mena +# AGENTS.md — Property Manager Instructions for AI coding assistants (Claude Code, Cursor, Codex, Aider, …). This is the source of truth for **how to write code here**. Read it before writing code; live architecture + status is in [docs/CONTEXT.md](docs/CONTEXT.md). ## What this repo is -**Forward-Mena** — a multi-org rental-management SaaS. Property owners subscribe ($20/mo) and manage their own staff (supervisor / finance / maintenance) and tenants in a fully data-isolated workspace. **v1** = platform foundation (auth, multi-org RBAC, subscription/payments, timeline, role-aware dashboard shells); the rentals domain is being built (`Building` / `BuildingAssignment` models exist). Turborepo monorepo. +**Property Manager** — a multi-org rental-management SaaS. Property owners subscribe (from $29/mo) and manage their own staff (supervisor / finance / maintenance) and tenants in a fully data-isolated workspace. **v1** = platform foundation (auth, multi-org RBAC, subscription/payments, timeline, role-aware dashboard shells); the rentals domain is being built (`Building` / `BuildingAssignment` models exist). Turborepo monorepo. ## Stack -- **`apps/web`** (`forward-mena-fe`) — Next.js **16.2.4** + React 19, App Router, bilingual `[lang]` routing (`ar` RTL / `en`), **next-auth v5 (Auth.js) with Keycloak OIDC** (JWT session, no DB adapter), **RTK Query + redux-persist**, Tailwind v4 + shadcn (on `@base-ui/react`), Stripe embedded checkout, **vitest**. **Port 3000.** -- **`apps/api`** (`forward-mena-be`) — NestJS **11**, **Prisma 7** (`@prisma/adapter-pg`), **passport-jwt + jwks-rsa** (validates Keycloak RS256 JWTs), Stripe 22, nestjs-pino, Swagger at `/docs`, `@nestjs/throttler`, **jest**. **Port 4000.** +- **`apps/web`** (`property-manager-fe`) — Next.js **16.2.4** + React 19, App Router, bilingual `[lang]` routing (`ar` RTL / `en`), **next-auth v5 (Auth.js) with Keycloak OIDC** (JWT session, no DB adapter), **RTK Query + redux-persist**, Tailwind v4 + shadcn (on `@base-ui/react`), Stripe embedded checkout, **vitest**. **Port 3000.** +- **`apps/api`** (`property-manager-be`) — NestJS **11**, **Prisma 7** (`@prisma/adapter-pg`), **passport-jwt + jwks-rsa** (validates Keycloak RS256 JWTs), Stripe 22, nestjs-pino, Swagger at `/docs`, `@nestjs/throttler`, **jest**. **Port 4000.** - **`packages/`** — shared internal packages: **`@repo/db`** (`packages/database` — Prisma schema/migrations + generated client, the single source of truth for the DB), **`@repo/contracts`** (shared API types/DTOs consumed by both apps), plus **`@repo/typescript-config`** and **`@repo/eslint-config`**. New cross-app types/DTOs belong in `@repo/contracts`, not per-app. - **Tooling** — **npm** (`npm@11.6.2`, `package-lock.json`), Node **24.11.1** (`.nvmrc`), Turborepo 2. @@ -50,7 +50,7 @@ cd apps/web && npm run dev # Next.js :3000 → /en or /ar - **Every BFF route handler under `app/api/` MUST use `forwardRoute('/path')`** (`lib/api/forward.ts`) — e.g. `export const GET = forwardRoute('/me')`. It wraps `auth(...)` so Auth.js writes the rotated Keycloak token back via `Set-Cookie`. A bare `await auth()` inside a handler refreshes in-memory only → eventual `invalid_grant`. - `session.update()` MUST carry a payload (e.g. `update({ refresh: Date.now() })`) — a bare `update()` is a no-op that never triggers the `jwt` callback. (See memory: post-payment role refresh depended on this.) - RSC guards `requireSession` / `requireRole` / `requireActiveOrg` (`auth/guards.ts`) are defense-in-depth. `proxy.ts` middleware handles locale + route gating; **paywall gating is intentionally NOT in middleware** (a lagging role claim causes redirect loops) — gate in RSC/components. -- Forms: react-hook-form + zod. UI: shadcn (base-ui) in `components/ui/` — **don't hand-edit**; use the shadcn MCP or CLI. Tailwind v4 tokens. State persisted via redux-persist (`ui`, `auth`, `api` under key `forward-mena`). +- Forms: react-hook-form + zod. UI: shadcn (base-ui) in `components/ui/` — **don't hand-edit**; use the shadcn MCP or CLI. Tailwind v4 tokens. State persisted via redux-persist (`ui`, `auth`, `api` under key `property-manager`). ### Billing (Stripe) @@ -82,47 +82,45 @@ CI (`.github/workflows/ci.yml`, on PR + push to `main`) runs **lint + check-type - Don't duplicate cross-app types per-app — put shared API types/DTOs in `@repo/contracts`, and import the DB client/types from `@repo/db` (never re-declare them). - # GitNexus — Code Intelligence -This project is indexed by GitNexus as **bootcamp-starter** (1530 symbols, 3956 relationships, 116 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **bootcamp-starter** (3700 symbols, 7360 relationships, 139 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. -> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939). +> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. ## Always Do -- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. -- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: `detect_changes({scope: "compare", base_ref: "main"})`. +- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. +- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. - **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. -- When exploring unfamiliar code, use `query({search_query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. -- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`. -- For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`). +- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. +- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. ## Never Do -- NEVER edit a function, class, or method without first running `impact` on it. +- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. - NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. -- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph. -- NEVER commit changes without running `detect_changes()` to check affected scope. +- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. +- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. ## Resources -| Resource | Use for | -| ------------------------------------------------- | ---------------------------------------- | -| `gitnexus://repo/bootcamp-starter/context` | Codebase overview, check index freshness | -| `gitnexus://repo/bootcamp-starter/clusters` | All functional areas | -| `gitnexus://repo/bootcamp-starter/processes` | All execution flows | -| `gitnexus://repo/bootcamp-starter/process/{name}` | Step-by-step execution trace | +| Resource | Use for | +|----------|---------| +| `gitnexus://repo/bootcamp-starter/context` | Codebase overview, check index freshness | +| `gitnexus://repo/bootcamp-starter/clusters` | All functional areas | +| `gitnexus://repo/bootcamp-starter/processes` | All execution flows | +| `gitnexus://repo/bootcamp-starter/process/{name}` | Step-by-step execution trace | ## CLI -| Task | Read this skill file | -| -------------------------------------------- | ----------------------------------------------------------- | -| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | -| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | -| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | -| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | -| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | -| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | +| Task | Read this skill file | +|------|---------------------| +| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | +| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | +| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | +| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | +| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | +| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 00000000..7d74d0c3 --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,40 @@ +# syntax=docker/dockerfile:1 +# Property Manager — API (NestJS) image. Build context = repo root. +# docker build -f apps/api/Dockerfile -t /property-manager-api: . + +# ---------- builder ---------- +FROM node:22-slim AS builder +# openssl → Prisma engine. (No node-gyp toolchain: the only native dep, +# msgpackr-extract, is optional and fails soft to a pure-JS path.) +RUN apt-get update && apt-get install -y --no-install-recommends \ + openssl ca-certificates && \ + rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY . . +RUN npm ci +# Build shared workspace packages in EXPLICIT order first. apps/* import +# @repo/contracts but don't all declare it as a dependency, so turbo's graph +# can race and build the app before contracts/dist exists. Build deps by hand. +RUN npm run build --workspace=@repo/contracts +RUN npm run db:generate --workspace=@repo/db +RUN npm run db:build --workspace=@repo/db +# API build: `npm run build` = rm tsbuildinfo && nest build. +RUN cd apps/api && npm run build +# Fail loudly if the Nest build did not emit. +RUN test -f apps/api/dist/main.js + +# ---------- runtime ---------- +FROM node:22-slim AS runtime +RUN apt-get update && apt-get install -y --no-install-recommends \ + openssl ca-certificates && \ + rm -rf /var/lib/apt/lists/* +ENV NODE_ENV=production +WORKDIR /app +# Copy the whole built monorepo. We intentionally keep devDependencies because +# `start:prod` registers `tsconfig-paths` (a devDependency) at runtime, and the +# generated Prisma client/engine lives under node_modules/.prisma. +COPY --from=builder /app ./ +EXPOSE 20101 +# start:prod = node -e "require('tsconfig-paths').register({baseUrl:'./dist',...}); require('./dist/main')" +# --workspace runs it with cwd = apps/api so the relative ./dist paths resolve. +CMD ["npm", "run", "start:prod", "--workspace=property-manager-be"] diff --git a/apps/api/package.json b/apps/api/package.json index 16d59fed..48ed4a09 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,7 +1,7 @@ { - "name": "forward-mena-be", + "name": "property-manager-be", "version": "0.1.0", - "description": "Forward-Mena multi-org rentals SaaS backend", + "description": "Property Manager multi-org rentals SaaS backend", "author": "", "private": true, "license": "UNLICENSED", @@ -22,6 +22,7 @@ }, "dependencies": { "@nestjs/axios": "^4.0.1", + "@nestjs/bullmq": "^11.0.4", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.0.1", @@ -33,6 +34,7 @@ "@prisma/adapter-pg": "^7.8.0", "@repo/db": "*", "axios": "^1.15.2", + "bullmq": "^5.80.6", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "compression": "^1.8.1", @@ -41,6 +43,7 @@ "joi": "^18.1.2", "jwks-rsa": "^4.0.1", "nestjs-pino": "^4.6.1", + "nodemailer": "^7.0.13", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "pg": "^8.20.0", @@ -63,6 +66,7 @@ "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^24.0.0", + "@types/nodemailer": "^8.0.1", "@types/passport-jwt": "^4.0.1", "@types/pg": "^8.20.0", "@types/supertest": "^7.0.0", @@ -83,7 +87,11 @@ "typescript-eslint": "^8.20.0" }, "jest": { - "moduleFileExtensions": ["js", "json", "ts"], + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], "rootDir": "src", "testRegex": ".*\\.spec\\.ts$", "transform": { @@ -92,7 +100,9 @@ "moduleNameMapper": { "^@/(.*)$": "/$1" }, - "collectCoverageFrom": ["**/*.(t|j)s"], + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], "coverageDirectory": "../coverage", "testEnvironment": "node" } diff --git a/apps/api/scripts/seed-demo.js b/apps/api/scripts/seed-demo.js new file mode 100644 index 00000000..d4bb0ed7 --- /dev/null +++ b/apps/api/scripts/seed-demo.js @@ -0,0 +1,210 @@ +/* Demo seed for presentation — idempotent, additive. + * Creates ONE ACTIVE org + a small rentals tree (so reports/tenant show real + * numbers) and 3 Keycloak logins (org_admin / finance / tenant) in OUR realm, + * wired exactly like the app (client roles on the web client + org_id attr). + * Run: node apps/api/scripts/seed-demo.js (cwd anywhere; abs paths used) */ +const path = require('path'); +const API_DIR = path.resolve(__dirname, '..'); +require('dotenv').config({ path: API_DIR + '/.env.local' }); +const axios = require('axios'); +const { PrismaClient } = require('@repo/db'); +const { PrismaPg } = require('@prisma/adapter-pg'); + +const prisma = new PrismaClient({ + adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }), +}); + +const KC = process.env.KEYCLOAK_BASE; +const REALM = process.env.KEYCLOAK_REALM; +const ADMIN_CID = process.env.KEYCLOAK_API_CLIENT_ID; +const ADMIN_SECRET = process.env.KEYCLOAK_API_CLIENT_SECRET; +const WEB_CID = process.env.KEYCLOAK_WEB_CLIENT_ID; +const PASSWORD = 'ForwardDemo!2026'; +const ORG_NAME = 'Property Manager Demo Co'; + +const http = axios.create({ baseURL: KC, timeout: 15000 }); +let token; +async function kcToken() { + if (token) return token; + const form = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: ADMIN_CID, + client_secret: ADMIN_SECRET, + }); + const { data } = await http.post( + `/realms/${REALM}/protocol/openid-connect/token`, + form, + { headers: { 'content-type': 'application/x-www-form-urlencoded' } }, + ); + token = data.access_token; + return token; +} +const auth = async () => ({ + headers: { Authorization: `Bearer ${await kcToken()}` }, +}); + +async function ensureUser(username, firstName, lastName, orgId) { + const a = await auth(); + const found = await http.get(`/admin/realms/${REALM}/users`, { + ...a, + params: { username, exact: true }, + }); + const body = { + username, + email: username, + firstName, + lastName, + enabled: true, + emailVerified: true, + attributes: { org_id: [orgId] }, + requiredActions: [], + credentials: [{ type: 'password', value: PASSWORD, temporary: false }], + }; + let id; + if (found.data.length) { + id = found.data[0].id; + await http.put(`/admin/realms/${REALM}/users/${id}`, body, a); + } else { + const res = await http.post(`/admin/realms/${REALM}/users`, body, a); + id = res.headers['location'].split('/').pop(); + } + return id; +} + +async function assignClientRole(userId, roleName) { + const a = await auth(); + const clients = await http.get(`/admin/realms/${REALM}/clients`, { + ...a, + params: { clientId: WEB_CID }, + }); + const clientUuid = clients.data[0].id; + const roleRep = await http.get( + `/admin/realms/${REALM}/clients/${clientUuid}/roles/${encodeURIComponent(roleName)}`, + a, + ); + try { + await http.post( + `/admin/realms/${REALM}/users/${userId}/role-mappings/clients/${clientUuid}`, + [roleRep.data], + a, + ); + } catch (e) { + if (!(e.response && e.response.status === 409)) throw e; + } +} + +(async () => { + const out = {}; + // 1) Org (ACTIVE) + Subscription (ACTIVE) — idempotent by name. + let org = await prisma.organization.findFirst({ where: { name: ORG_NAME } }); + const fresh = !org; + if (!org) { + org = await prisma.organization.create({ + data: { name: ORG_NAME, status: 'ACTIVE' }, + }); + } else { + await prisma.organization.update({ + where: { id: org.id }, + data: { status: 'ACTIVE' }, + }); + } + await prisma.subscription.upsert({ + where: { orgId: org.id }, + create: { orgId: org.id, status: 'ACTIVE', planKey: 'standard' }, + update: { status: 'ACTIVE', planKey: 'standard' }, + }); + out.orgId = org.id; + + // 2) Keycloak users. + const adminSub = await ensureUser('demo.admin@prorentallb.cloud', 'Demo', 'Admin', org.id); + await assignClientRole(adminSub, 'org_admin'); + const financeSub = await ensureUser('demo.finance@prorentallb.cloud', 'Demo', 'Finance', org.id); + await assignClientRole(financeSub, 'finance'); + const tenantSub = await ensureUser('demo.tenant@prorentallb.cloud', 'Layla', 'Hassan', org.id); + await assignClientRole(tenantSub, 'tenant'); + out.users = { adminSub, financeSub, tenantSub }; + + // 3) Rentals tree — only build once (skip if org already had it). + const existingBuilding = await prisma.building.findFirst({ + where: { orgId: org.id }, + }); + if (!existingBuilding) { + const building = await prisma.building.create({ + data: { orgId: org.id, name: 'Al Manar Residences', code: 'MANAR', address: '12 Corniche Rd' }, + }); + const floor = await prisma.floor.create({ + data: { orgId: org.id, buildingId: building.id, name: 'Ground', order: 1 }, + }); + const apt = await prisma.apartment.create({ + data: { + orgId: org.id, buildingId: building.id, floorId: floor.id, + unitNumber: 'G-01', bedrooms: 2, bathrooms: '1.5', status: 'occupied', + }, + }); + const renter = await prisma.renter.create({ + data: { + orgId: org.id, fullName: 'Layla Hassan', + email: 'demo.tenant@prorentallb.cloud', phone: '+971500000000', + renterUserId: tenantSub, + }, + }); + const now = new Date(); + const start = new Date(now); start.setMonth(start.getMonth() - 3); + const end = new Date(now); end.setMonth(end.getMonth() + 9); + const lease = await prisma.lease.create({ + data: { + orgId: org.id, buildingId: building.id, floorId: floor.id, + apartmentId: apt.id, renterId: renter.id, + startDate: start, endDate: end, + rentAmount: '1200.00', depositAmount: '1200.00', status: 'active', + }, + }); + // Invoice #1: last month, fully paid (YTD income). + const lastMonthDue = new Date(now); lastMonthDue.setMonth(lastMonthDue.getMonth() - 1); + const inv1 = await prisma.invoice.create({ + data: { + orgId: org.id, buildingId: building.id, leaseId: lease.id, dueDate: lastMonthDue, + lineItems: { create: [{ category: 'rent', description: 'Monthly rent', amount: '1200.00' }] }, + }, + }); + await prisma.invoicePayment.create({ + data: { orgId: org.id, invoiceId: inv1.id, amount: '1200.00', method: 'bank_transfer', paidAt: lastMonthDue }, + }); + // Invoice #2: due 5 days ago, only partially paid → overdue + outstanding. + const overdueDue = new Date(now); overdueDue.setDate(overdueDue.getDate() - 5); + const paidThisMonth = new Date(now); paidThisMonth.setDate(paidThisMonth.getDate() - 2); + const inv2 = await prisma.invoice.create({ + data: { + orgId: org.id, buildingId: building.id, leaseId: lease.id, dueDate: overdueDue, + lineItems: { create: [{ category: 'rent', description: 'Monthly rent', amount: '1200.00' }] }, + }, + }); + await prisma.invoicePayment.create({ + data: { orgId: org.id, invoiceId: inv2.id, amount: '800.00', method: 'card', paidAt: paidThisMonth }, + }); + // A welcome notification for the tenant (N1 inbox demo). + await prisma.notification.create({ + data: { + orgId: org.id, userId: tenantSub, type: 'welcome', + title: 'Welcome to Property Manager', body: 'Your tenant portal is ready.', data: {}, + }, + }); + out.rentals = { building: building.id, apartment: apt.id, lease: lease.id, invoices: [inv1.id, inv2.id] }; + } else { + // Ensure the renter link points at the current tenant sub. + await prisma.renter.updateMany({ + where: { orgId: org.id, email: 'demo.tenant@prorentallb.cloud' }, + data: { renterUserId: tenantSub }, + }); + out.rentals = 'already seeded (renter link refreshed)'; + } + + out.freshOrg = fresh; + console.log(JSON.stringify(out, null, 2)); + await prisma.$disconnect(); + process.exit(0); +})().catch(async (e) => { + console.error('SEED ERROR:', e.response ? JSON.stringify(e.response.data) : e.message); + await prisma.$disconnect(); + process.exit(1); +}); diff --git a/apps/api/scripts/verify-n1.ts b/apps/api/scripts/verify-n1.ts new file mode 100644 index 00000000..1c70ee06 --- /dev/null +++ b/apps/api/scripts/verify-n1.ts @@ -0,0 +1,93 @@ +/** + * Sprint N1 runtime verification (throwaway; not part of the app). + * + * Boots the real Nest DI graph with NOTIFICATIONS_EMAIL=on, resolves a real + * Keycloak user with an email, and drives NotificationsProcessor.process() with + * the exact job SupportTicketsService.create enqueues — proving that one + * "support ticket received" produces BOTH an in-app Notification row AND an + * email (captured by mailpit). Run via: + * + * NOTIFICATIONS_EMAIL=on SMTP_HOST=127.0.0.1 SMTP_PORT=2025 \ + * npx ts-node -r tsconfig-paths/register scripts/verify-n1.ts + */ +import { NestFactory } from '@nestjs/core'; +import type { Job } from 'bullmq'; +import { AppModule } from '../src/app.module'; +import { KeycloakAdminService } from '../src/infrastructure/keycloak/keycloak-admin.service'; +import { NotificationsProcessor } from '../src/modules/notifications/notifications.processor'; +import { PrismaService } from '../src/infrastructure/prisma/prisma.service'; +import { NotificationJobData } from '../src/modules/notifications/notifications.constants'; +import { Role } from '../src/common/enums'; + +async function main() { + const app = await NestFactory.createApplicationContext(AppModule, { + logger: ['error', 'warn', 'log'], + }); + try { + const keycloak = app.get(KeycloakAdminService); + const processor = app.get(NotificationsProcessor); + const prisma = app.get(PrismaService); + + // Find any real realm user that has an email on record. + let recipient: { sub: string; email: string; orgId: string } | null = null; + for (const role of Object.values(Role)) { + const users = await keycloak.getUsersWithClientRole(role); + const withEmail = users.find((u) => u.email && u.email.includes('@')); + if (withEmail) { + recipient = { + sub: withEmail.id, + email: withEmail.email as string, + orgId: withEmail.attributes?.['org_id']?.[0] ?? 'verify-n1-org', + }; + console.log(`\n▶ Recipient resolved from Keycloak (role=${role}):`); + break; + } + } + if (!recipient) { + console.error('✖ No Keycloak user with an email found — cannot verify email send.'); + return; + } + console.log(` sub=${recipient.sub}\n email=${recipient.email}\n orgId=${recipient.orgId}`); + + const job: NotificationJobData = { + orgId: recipient.orgId, + userId: recipient.sub, + type: 'support_ticket.acknowledged', + title: 'Support ticket received', + body: 'We\'ve received your ticket "N1 verification" and will follow up shortly.', + data: { ticketId: 'verify-n1', category: 'general' }, + }; + + const before = await prisma.notification.count({ + where: { userId: recipient.sub, type: job.type }, + }); + + console.log('\n▶ Driving NotificationsProcessor.process() (persist + email step)…'); + await processor.process({ data: job } as Job); + + const after = await prisma.notification.count({ + where: { userId: recipient.sub, type: job.type }, + }); + const latest = await prisma.notification.findFirst({ + where: { userId: recipient.sub, type: job.type }, + orderBy: { createdAt: 'desc' }, + }); + + console.log(`\n✔ In-app Notification rows for this user (type=${job.type}): ${before} → ${after}`); + if (latest) { + console.log( + ` latest row: id=${latest.id} title="${latest.title}" createdAt=${latest.createdAt.toISOString()}`, + ); + } + console.log('\n(now querying mailpit for the delivered email — see next step)'); + } finally { + await app.close(); + } +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/apps/api/scripts/verify-o1-tx.js b/apps/api/scripts/verify-o1-tx.js new file mode 100644 index 00000000..85f1f10c --- /dev/null +++ b/apps/api/scripts/verify-o1-tx.js @@ -0,0 +1,147 @@ +/* O1 verification (live DB): prove checkout activation is atomic. + * Throwaway — lives under apps/api/scripts so bare requires resolve from + * apps/api/node_modules. Absolute paths for env + dist so cwd doesn't matter. */ +const path = require('path'); +const API_DIR = path.resolve(__dirname, '..'); +require('dotenv').config({ path: API_DIR + '/.env.local' }); +require('tsconfig-paths').register({ + baseUrl: API_DIR + '/dist', + paths: { '@/*': ['*'] }, +}); + +const { PrismaClient } = require('@repo/db'); +const { PrismaPg } = require('@prisma/adapter-pg'); +const { + WebhooksService, +} = require(API_DIR + '/dist/modules/webhooks/webhooks.service'); + +const prisma = new PrismaClient({ + adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }), +}); + +const TAG = 'O1-VERIFY-' + process.pid; +const results = []; +const record = (name, pass, detail) => { + results.push({ name, pass }); + console.log(`${pass ? 'PASS' : 'FAIL'} ${name} ${detail ?? ''}`); +}; + +function checkoutEvent(orgId) { + return { + id: 'evt_' + TAG, + type: 'checkout.session.completed', + data: { + object: { + id: 'cs_' + TAG, + metadata: { orgId, planKey: 'standard' }, + customer: 'cus_' + TAG, + subscription: 'sub_' + TAG, + }, + }, + }; +} + +async function partA_rollback() { + const org = await prisma.organization.create({ + data: { name: TAG + '-A', status: 'PENDING' }, + }); + try { + await prisma.$transaction(async (tx) => { + await tx.subscription.upsert({ + where: { orgId: org.id }, + create: { + orgId: org.id, + stripeSubscriptionId: 'sub_A_' + TAG, + status: 'ACTIVE', + }, + update: { status: 'ACTIVE' }, + }); + await tx.organization.update({ + where: { id: org.id }, + data: { status: 'ACTIVE' }, + }); + // Both writes executed; now fail — a real DB must roll BOTH back. + throw new Error('simulated mid-handler failure'); + }); + } catch { + /* expected */ + } + const after = await prisma.organization.findUnique({ where: { id: org.id } }); + const sub = await prisma.subscription.findUnique({ + where: { orgId: org.id }, + }); + record( + 'A: org stays PENDING after mid-tx failure', + after.status === 'PENDING', + `status=${after.status}`, + ); + record( + 'A: no subscription row persisted after rollback', + sub === null, + `sub=${sub ? 'EXISTS' : 'null'}`, + ); + return org.id; +} + +async function partB_commit() { + const org = await prisma.organization.create({ + data: { name: TAG + '-B', status: 'PENDING' }, + }); + const service = new WebhooksService( + prisma, + { emit: async () => {} }, + { + searchUsersByOrg: async () => [], + getUsersWithClientRole: async () => [], + setSingleClientRole: async () => {}, + }, + ); + + await service.handleEvent(checkoutEvent(org.id)); + + const after = await prisma.organization.findUnique({ where: { id: org.id } }); + const sub = await prisma.subscription.findUnique({ + where: { orgId: org.id }, + }); + record( + 'B: real handleCheckoutCompleted activates org', + after.status === 'ACTIVE' && after.stripeCustomerId === 'cus_' + TAG, + `status=${after.status} customer=${after.stripeCustomerId}`, + ); + record( + 'B: subscription row created ACTIVE in same handler', + !!sub && + sub.status === 'ACTIVE' && + sub.stripeSubscriptionId === 'sub_' + TAG, + `sub=${sub ? sub.status + '/' + sub.stripeSubscriptionId : 'null'}`, + ); + return org.id; +} + +(async () => { + const created = []; + try { + created.push(await partA_rollback()); + created.push(await partB_commit()); + } catch (e) { + console.log('SCRIPT ERROR:', e && e.stack ? e.stack : e); + } finally { + for (const orgId of created) { + await prisma.subscription.deleteMany({ where: { orgId } }).catch(() => {}); + await prisma.organization + .deleteMany({ where: { id: orgId } }) + .catch(() => {}); + } + await prisma.organization + .deleteMany({ where: { name: { startsWith: TAG } } }) + .catch(() => {}); + await prisma.$disconnect(); + } + const allPass = results.length === 4 && results.every((r) => r.pass); + console.log( + '\n=== VERDICT ===', + allPass ? 'PASS' : 'FAIL', + `(${results.filter((r) => r.pass).length}/${results.length})`, + ); + process.exit(allPass ? 0 : 1); +})(); diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index f5879e8a..f9d9411e 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,12 +1,19 @@ import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; +import { ConfigModule, ConfigService } from '@nestjs/config'; import { APP_GUARD } from '@nestjs/core'; +import { BullModule } from '@nestjs/bullmq'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { LoggerModule } from 'nestjs-pino'; import { appConfig } from '@/config/app.config'; import { databaseConfig } from '@/config/database.config'; import { envValidationSchema } from '@/config/env.validation'; import { keycloakConfig } from '@/config/keycloak.config'; +import { mailConfig } from '@/config/mail.config'; +import { + buildRedisConnection, + DEFAULT_REDIS_URL, + redisConfig, +} from '@/config/redis.config'; import { stripeConfig } from '@/config/stripe.config'; import { JwtAuthGuard, RolesGuard } from '@/common/guards'; import { KeycloakModule } from '@/infrastructure/keycloak/keycloak.module'; @@ -31,6 +38,12 @@ import { VendorsModule } from '@/modules/vendors/vendors.module'; import { MaintenanceRequestsModule } from '@/modules/maintenance-requests/maintenance-requests.module'; import { WorkOrdersModule } from '@/modules/work-orders/work-orders.module'; import { ExpensesModule } from '@/modules/expenses/expenses.module'; +import { InvoicesModule } from '@/modules/invoices/invoices.module'; +import { InvoicePaymentsModule } from '@/modules/invoice-payments/invoice-payments.module'; +import { NotificationsModule } from '@/modules/notifications/notifications.module'; +import { SupportTicketsModule } from '@/modules/support-tickets/support-tickets.module'; +import { ReportsModule } from '@/modules/reports/reports.module'; +import { TenantModule } from '@/modules/tenant/tenant.module'; @Module({ imports: [ @@ -42,9 +55,24 @@ import { ExpensesModule } from '@/modules/expenses/expenses.module'; : '.env.local', '.env', ], - load: [appConfig, databaseConfig, keycloakConfig, stripeConfig], + load: [ + appConfig, + databaseConfig, + keycloakConfig, + mailConfig, + redisConfig, + stripeConfig, + ], validationSchema: envValidationSchema, }), + BullModule.forRootAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + connection: buildRedisConnection( + config.get('redis.url') ?? DEFAULT_REDIS_URL, + ), + }), + }), LoggerModule.forRoot({ pinoHttp: { transport: @@ -76,6 +104,12 @@ import { ExpensesModule } from '@/modules/expenses/expenses.module'; MaintenanceRequestsModule, WorkOrdersModule, ExpensesModule, + InvoicesModule, + InvoicePaymentsModule, + NotificationsModule, + SupportTicketsModule, + ReportsModule, + TenantModule, ], providers: [ { provide: APP_GUARD, useClass: ThrottlerGuard }, diff --git a/apps/api/src/common/invoice-summary/compute-invoice-summary.spec.ts b/apps/api/src/common/invoice-summary/compute-invoice-summary.spec.ts new file mode 100644 index 00000000..917fd30a --- /dev/null +++ b/apps/api/src/common/invoice-summary/compute-invoice-summary.spec.ts @@ -0,0 +1,113 @@ +import { computeInvoiceSummary } from './compute-invoice-summary'; + +describe('computeInvoiceSummary', () => { + const dueDate = new Date('2026-02-01T00:00:00.000Z'); + const beforeDue = new Date('2026-01-15T00:00:00.000Z'); + const afterDue = new Date('2026-02-15T00:00:00.000Z'); + + it('is "open" when there are no payments and the due date has not passed', () => { + const result = computeInvoiceSummary( + [{ amount: 1000 }], + [], + dueDate, + beforeDue, + ); + + expect(result).toEqual({ + totalAmount: 1000, + paidAmount: 0, + status: 'open', + }); + }); + + it('is "partially_paid" when some but not all of the total has been paid before the due date', () => { + const result = computeInvoiceSummary( + [{ amount: 1000 }], + [{ amount: 400 }], + dueDate, + beforeDue, + ); + + expect(result).toEqual({ + totalAmount: 1000, + paidAmount: 400, + status: 'partially_paid', + }); + }); + + it('is "paid" when payments exactly match the total', () => { + const result = computeInvoiceSummary( + [{ amount: 1000 }], + [{ amount: 1000 }], + dueDate, + beforeDue, + ); + + expect(result.status).toBe('paid'); + }); + + it('is "paid" when payments exceed the total (overpayment)', () => { + const result = computeInvoiceSummary( + [{ amount: 1000 }], + [{ amount: 1200 }], + dueDate, + beforeDue, + ); + + expect(result).toEqual({ + totalAmount: 1000, + paidAmount: 1200, + status: 'paid', + }); + }); + + it('is "overdue" when unpaid and the due date has passed', () => { + const result = computeInvoiceSummary( + [{ amount: 1000 }], + [], + dueDate, + afterDue, + ); + + expect(result.status).toBe('overdue'); + }); + + it('is "overdue" when partially paid and the due date has passed', () => { + const result = computeInvoiceSummary( + [{ amount: 1000 }], + [{ amount: 300 }], + dueDate, + afterDue, + ); + + expect(result.status).toBe('overdue'); + }); + + it('is "paid", not "overdue", when fully paid even past the due date (overdue-vs-paid precedence)', () => { + const result = computeInvoiceSummary( + [{ amount: 1000 }], + [{ amount: 1000 }], + dueDate, + afterDue, + ); + + expect(result.status).toBe('paid'); + }); + + it('sums multiple line items into the total', () => { + const result = computeInvoiceSummary( + [{ amount: 1000 }, { amount: 50 }, { amount: 25 }], + [], + dueDate, + beforeDue, + ); + + expect(result.totalAmount).toBe(1075); + }); + + it('treats an empty line-item list as a zero total', () => { + const result = computeInvoiceSummary([], [], dueDate, beforeDue); + + expect(result.totalAmount).toBe(0); + }); +}); diff --git a/apps/api/src/common/invoice-summary/compute-invoice-summary.ts b/apps/api/src/common/invoice-summary/compute-invoice-summary.ts new file mode 100644 index 00000000..901efe40 --- /dev/null +++ b/apps/api/src/common/invoice-summary/compute-invoice-summary.ts @@ -0,0 +1,44 @@ +import { InvoiceStatus } from '@repo/contracts'; + +export type InvoiceSummaryLineItem = { amount: number }; +export type InvoiceSummaryPayment = { amount: number }; + +export type InvoiceSummary = { + totalAmount: number; + paidAmount: number; + status: InvoiceStatus; +}; + +/** + * Pure, dependency-free derivation of an Invoice's total/paid amounts and + * status from its line items and payments. No Prisma, no I/O — the single + * source of truth for these derived fields, consumed by both InvoicesService + * and InvoicePaymentsService so they can never disagree. + */ +export function computeInvoiceSummary( + lineItems: InvoiceSummaryLineItem[], + payments: InvoiceSummaryPayment[], + dueDate: Date, + now: Date, +): InvoiceSummary { + const totalAmount = lineItems.reduce((sum, item) => sum + item.amount, 0); + const paidAmount = payments.reduce((sum, p) => sum + p.amount, 0); + + // Compare in integer cents — JS float addition (e.g. 0.1 + 0.2) can leave + // paidAmount a hair below totalAmount for a fully paid invoice. + const totalCents = Math.round(totalAmount * 100); + const paidCents = Math.round(paidAmount * 100); + + let status: InvoiceStatus; + if (paidCents >= totalCents) { + status = 'paid'; + } else if (dueDate < now) { + status = 'overdue'; + } else if (paidCents > 0) { + status = 'partially_paid'; + } else { + status = 'open'; + } + + return { totalAmount, paidAmount, status }; +} diff --git a/apps/api/src/config/env.validation.ts b/apps/api/src/config/env.validation.ts index 836d62db..c14297c9 100644 --- a/apps/api/src/config/env.validation.ts +++ b/apps/api/src/config/env.validation.ts @@ -9,6 +9,9 @@ export const envValidationSchema = Joi.object({ DATABASE_URL: Joi.string() .uri({ scheme: ['postgresql', 'postgres'] }) .required(), + REDIS_URL: Joi.string() + .uri({ scheme: ['redis', 'rediss'] }) + .default('redis://127.0.0.1:6380'), KEYCLOAK_BASE: Joi.string().uri().required(), KEYCLOAK_REALM: Joi.string().required(), KEYCLOAK_ISSUER: Joi.string().uri().required(), @@ -18,5 +21,17 @@ export const envValidationSchema = Joi.object({ KEYCLOAK_WEB_CLIENT_ID: Joi.string().required(), STRIPE_SECRET_KEY: Joi.string().required(), STRIPE_PRICE_ID: Joi.string().required(), + // Per-plan recurring price ids (optional; empty ⇒ fall back to STRIPE_PRICE_ID). + STRIPE_PRICE_STARTER: Joi.string().allow('').optional(), + STRIPE_PRICE_GROWTH: Joi.string().allow('').optional(), + STRIPE_PRICE_PRO: Joi.string().allow('').optional(), STRIPE_WEBHOOK_SECRET: Joi.string().allow('').optional(), + // Notification email delivery (mailpit locally). OFF by default so the app + // boots without an SMTP server; set to 'on' to deliver notification emails. + NOTIFICATIONS_EMAIL: Joi.string().valid('on', 'off').default('off'), + SMTP_HOST: Joi.string().hostname().default('127.0.0.1'), + SMTP_PORT: Joi.number().port().default(1025), + NOTIFICATIONS_EMAIL_FROM: Joi.string().default( + 'Property Manager ', + ), }); diff --git a/apps/api/src/config/mail.config.ts b/apps/api/src/config/mail.config.ts new file mode 100644 index 00000000..1873b002 --- /dev/null +++ b/apps/api/src/config/mail.config.ts @@ -0,0 +1,17 @@ +import { registerAs } from '@nestjs/config'; + +/** + * Outbound email for notification delivery. Delivery is OFF by default so a + * missing SMTP server (e.g. no local mailpit) never breaks a deploy — flip + * NOTIFICATIONS_EMAIL=on to enable it. Local dev points at the repo's + * docker-compose mailpit on 127.0.0.1:1025 (accepts any/insecure auth); set + * SMTP_HOST/SMTP_PORT/NOTIFICATIONS_EMAIL_FROM to target another relay. + */ +export const mailConfig = registerAs('mail', () => ({ + enabled: process.env.NOTIFICATIONS_EMAIL === 'on', + host: process.env.SMTP_HOST ?? '127.0.0.1', + port: Number(process.env.SMTP_PORT ?? 1025), + from: + process.env.NOTIFICATIONS_EMAIL_FROM ?? + 'Property Manager ', +})); diff --git a/apps/api/src/config/redis.config.ts b/apps/api/src/config/redis.config.ts new file mode 100644 index 00000000..02a61886 --- /dev/null +++ b/apps/api/src/config/redis.config.ts @@ -0,0 +1,41 @@ +import { registerAs } from '@nestjs/config'; + +/** Fallback Redis URL — the repo's docker-compose Redis for local dev. */ +export const DEFAULT_REDIS_URL = 'redis://127.0.0.1:6380'; + +/** + * Redis connection for BullMQ (notifications queue). Local dev points at the + * repo's docker-compose Redis on 127.0.0.1:6380 by default; override with + * REDIS_URL in production (own instance / own port — never the shared infra). + */ +export const redisConfig = registerAs('redis', () => ({ + url: process.env.REDIS_URL ?? DEFAULT_REDIS_URL, +})); + +/** Connection options accepted by BullMQ / ioredis. */ +export interface RedisConnectionOptions { + host: string; + port: number; + username?: string; + password?: string; + retryStrategy: (times: number) => number; +} + +/** + * Parse a `redis[s]://` URL into a BullMQ/ioredis connection object. Single + * source of truth so the queue root, the dead-letter QueueEvents listener and + * the health indicator all connect the same way. Reconnects with backoff + * instead of crashing when Redis is briefly unavailable. + */ +export function buildRedisConnection( + url: string = DEFAULT_REDIS_URL, +): RedisConnectionOptions { + const parsed = new URL(url); + return { + host: parsed.hostname, + port: Number(parsed.port) || 6379, + username: parsed.username || undefined, + password: parsed.password || undefined, + retryStrategy: (times: number) => Math.min(times * 500, 5000), + }; +} diff --git a/apps/api/src/config/stripe.config.ts b/apps/api/src/config/stripe.config.ts index bc2bca74..687d2332 100644 --- a/apps/api/src/config/stripe.config.ts +++ b/apps/api/src/config/stripe.config.ts @@ -2,6 +2,13 @@ import { registerAs } from '@nestjs/config'; export const stripeConfig = registerAs('stripe', () => ({ secretKey: process.env.STRIPE_SECRET_KEY ?? '', + // Fallback single price — also validated as recurring on boot (StripeService). priceId: process.env.STRIPE_PRICE_ID ?? '', webhookSecret: process.env.STRIPE_WEBHOOK_SECRET ?? '', + // Per-plan recurring prices. Empty string ⇒ that plan falls back to priceId. + prices: { + starter: process.env.STRIPE_PRICE_STARTER ?? '', + growth: process.env.STRIPE_PRICE_GROWTH ?? '', + pro: process.env.STRIPE_PRICE_PRO ?? '', + } as Record, })); diff --git a/apps/api/src/infrastructure/mail/mail.module.ts b/apps/api/src/infrastructure/mail/mail.module.ts new file mode 100644 index 00000000..bef88e5b --- /dev/null +++ b/apps/api/src/infrastructure/mail/mail.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { MailService } from './mail.service'; + +/** + * Provides the SMTP MailService. Imported wherever email is sent (currently the + * notifications module). Kept as a plain feature module rather than @Global so + * dependencies stay explicit. + */ +@Module({ + providers: [MailService], + exports: [MailService], +}) +export class MailModule {} diff --git a/apps/api/src/infrastructure/mail/mail.service.ts b/apps/api/src/infrastructure/mail/mail.service.ts new file mode 100644 index 00000000..75c6f160 --- /dev/null +++ b/apps/api/src/infrastructure/mail/mail.service.ts @@ -0,0 +1,60 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { createTransport, type Transporter } from 'nodemailer'; + +export interface SendMailOptions { + to: string; + subject: string; + text: string; + html?: string; +} + +/** + * Thin SMTP wrapper (nodemailer). Local dev sends to the docker-compose mailpit + * on 127.0.0.1:1025, which accepts any/insecure auth. The transporter is + * created lazily on first send so simply constructing this service (e.g. when + * delivery is disabled) never opens a socket. + * + * This service intentionally lets errors propagate — callers decide whether a + * send is best-effort. Notification email is delivered via NotificationEmailService, + * which wraps every send in a never-throw guard. + */ +@Injectable() +export class MailService { + private readonly logger = new Logger(MailService.name); + private transporter?: Transporter; + + constructor(private readonly config: ConfigService) {} + + private getTransporter(): Transporter { + if (!this.transporter) { + const host = this.config.get('mail.host', '127.0.0.1'); + const port = this.config.get('mail.port', 1025); + this.transporter = createTransport({ + host, + port, + // mailpit (and most dev relays) speak plaintext SMTP on 1025. + secure: false, + // Do not fail on self-signed certs if a relay upgrades to TLS. + tls: { rejectUnauthorized: false }, + }); + this.logger.log(`SMTP transport ready → ${host}:${port}`); + } + return this.transporter; + } + + /** Send an email. Throws on failure (caller decides best-effort semantics). */ + async sendMail(options: SendMailOptions): Promise { + const from = this.config.get( + 'mail.from', + 'Property Manager ', + ); + await this.getTransporter().sendMail({ + from, + to: options.to, + subject: options.subject, + text: options.text, + html: options.html, + }); + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index daec5a96..87ad613b 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -45,7 +45,7 @@ async function bootstrap() { // Swagger at /docs with Bearer auth const swaggerConfig = new DocumentBuilder() - .setTitle('Forward-Mena API') + .setTitle('Property Manager API') .setDescription('Multi-org rentals SaaS backend — v1 foundation') .setVersion('1.0') .addBearerAuth() diff --git a/apps/api/src/modules/billing/billing.service.ts b/apps/api/src/modules/billing/billing.service.ts index 681d8160..0308ab9c 100644 --- a/apps/api/src/modules/billing/billing.service.ts +++ b/apps/api/src/modules/billing/billing.service.ts @@ -62,8 +62,11 @@ export class BillingService { } const frontendUrl = this.config.getOrThrow('app.frontendUrl'); - // Resolve priceId: per-plan override or env fallback - const priceId = plan.stripePriceId; + // Resolve priceId: per-plan env price (stripe.prices[planKey]) → catalog + // override → undefined (StripeService then falls back to env STRIPE_PRICE_ID). + const perPlanPrices = + this.config.get>('stripe.prices') ?? {}; + const priceId = perPlanPrices[planKey] || plan.stripePriceId || undefined; return this.stripe.createSubscriptionCheckoutSession({ orgId, customerId, diff --git a/apps/api/src/modules/billing/plan-catalog.ts b/apps/api/src/modules/billing/plan-catalog.ts index 4aab68a1..8d4a2b13 100644 --- a/apps/api/src/modules/billing/plan-catalog.ts +++ b/apps/api/src/modules/billing/plan-catalog.ts @@ -1,10 +1,10 @@ /** - * PLAN CATALOG — single source of truth for plan definitions. + * PLAN CATALOG — single source of truth for plan definitions (display + limits). * - * All stripePriceId fields are intentionally undefined here so all plans - * fall back to the env STRIPE_PRICE_ID. This is the seam for the future - * Stripe account: set distinct price ids per plan when ready, and this - * file is the only place that needs to change. + * Per-plan Stripe price ids are resolved at checkout time from env + * (STRIPE_PRICE_STARTER / _GROWTH / _PRO via stripe.config `prices`), falling + * back to the catalog `stripePriceId` override below, then to the single env + * STRIPE_PRICE_ID. Set the per-plan env vars to bill each plan at its own price. */ export type PlanKey = 'starter' | 'growth' | 'pro'; @@ -29,7 +29,7 @@ export const PLAN_CATALOG: Record = { starter: { key: 'starter', displayName: 'Starter', - price: 20, + price: 29, currency: 'usd', buildingsLimit: 3, usersLimit: 5, @@ -38,7 +38,7 @@ export const PLAN_CATALOG: Record = { growth: { key: 'growth', displayName: 'Growth', - price: 20, + price: 79, currency: 'usd', buildingsLimit: 5, usersLimit: 10, @@ -48,7 +48,7 @@ export const PLAN_CATALOG: Record = { pro: { key: 'pro', displayName: 'Pro', - price: 20, + price: 199, currency: 'usd', buildingsLimit: null, usersLimit: null, diff --git a/apps/api/src/modules/health/health.controller.ts b/apps/api/src/modules/health/health.controller.ts index 9a412a1c..4fb344a3 100644 --- a/apps/api/src/modules/health/health.controller.ts +++ b/apps/api/src/modules/health/health.controller.ts @@ -1,13 +1,27 @@ import { Controller, Get } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { HealthCheck, HealthCheckService } from '@nestjs/terminus'; import { Public } from '@/common/decorators'; +import { RedisHealthIndicator } from './redis.health'; @ApiTags('health') @Controller('health') export class HealthController { + constructor( + private readonly health: HealthCheckService, + private readonly redis: RedisHealthIndicator, + ) {} + + /** + * Liveness + dependency readiness. Returns the Terminus envelope + * (`{ status, info, error, details }`); `status` is `ok` when Redis answers + * PING and `error` (HTTP 503) when it does not — so load balancers and uptime + * checks see the backend as unhealthy while its queue backend is unreachable. + */ @Public() @Get() + @HealthCheck() check() { - return { status: 'ok', timestamp: new Date().toISOString() }; + return this.health.check([() => this.redis.isHealthy('redis')]); } } diff --git a/apps/api/src/modules/health/health.module.ts b/apps/api/src/modules/health/health.module.ts index 7476abed..98ae67fc 100644 --- a/apps/api/src/modules/health/health.module.ts +++ b/apps/api/src/modules/health/health.module.ts @@ -1,7 +1,18 @@ import { Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bullmq'; +import { TerminusModule } from '@nestjs/terminus'; +import { NOTIFICATIONS_QUEUE } from '@/modules/notifications/notifications.constants'; import { HealthController } from './health.controller'; +import { RedisHealthIndicator } from './redis.health'; @Module({ + imports: [ + TerminusModule, + // Reuse the same queue/connection the app runs on so /health reflects the + // real Redis the notifications pipeline depends on. + BullModule.registerQueue({ name: NOTIFICATIONS_QUEUE }), + ], controllers: [HealthController], + providers: [RedisHealthIndicator], }) export class HealthModule {} diff --git a/apps/api/src/modules/health/redis.health.spec.ts b/apps/api/src/modules/health/redis.health.spec.ts new file mode 100644 index 00000000..9decc35e --- /dev/null +++ b/apps/api/src/modules/health/redis.health.spec.ts @@ -0,0 +1,83 @@ +import { RedisHealthIndicator, REDIS_HEALTH_TIMEOUT_MS } from './redis.health'; + +/** + * Fake Terminus HealthIndicatorService: `check(key)` returns a session whose + * `up()`/`down()` produce recognizable, assertable results. + */ +function makeHealthIndicatorService() { + const up = jest.fn(() => ({ redis: { status: 'up' } })); + const down = jest.fn((meta?: Record) => ({ + redis: { status: 'down', ...(meta ?? {}) }, + })); + const check = jest.fn(() => ({ up, down })); + return { service: { check } as any, up, down, check }; +} + +function makeIndicator(clientImpl: { + ping?: () => Promise; + client?: Promise; +}) { + const queue: any = { + client: + clientImpl.client ?? + Promise.resolve({ ping: clientImpl.ping ?? (async () => 'PONG') }), + }; + const health = makeHealthIndicatorService(); + const indicator = new RedisHealthIndicator(queue, health.service); + return { indicator, health }; +} + +describe('RedisHealthIndicator', () => { + it('reports UP when Redis replies PONG', async () => { + const { indicator, health } = makeIndicator({ ping: async () => 'PONG' }); + const result = await indicator.isHealthy('redis'); + expect(health.check).toHaveBeenCalledWith('redis'); + expect(health.up).toHaveBeenCalledTimes(1); + expect(health.down).not.toHaveBeenCalled(); + expect(result).toEqual({ redis: { status: 'up' } }); + }); + + it('reports DOWN on an unexpected ping reply', async () => { + const { indicator, health } = makeIndicator({ ping: async () => 'NOPE' }); + const result = await indicator.isHealthy('redis'); + expect(health.up).not.toHaveBeenCalled(); + expect(health.down).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('NOPE') }), + ); + expect(result.redis.status).toBe('down'); + }); + + it('reports DOWN (never throws) when the ping rejects', async () => { + const { indicator, health } = makeIndicator({ + ping: async () => { + throw new Error('ECONNREFUSED'); + }, + }); + const result = await indicator.isHealthy('redis'); + expect(health.down).toHaveBeenCalledWith( + expect.objectContaining({ message: 'ECONNREFUSED' }), + ); + expect(result.redis.status).toBe('down'); + }); + + it('reports DOWN when the ping hangs past the timeout (does not block /health)', async () => { + jest.useFakeTimers(); + try { + // ping never resolves — simulates ioredis stuck in a reconnect loop. + const { indicator, health } = makeIndicator({ + ping: () => new Promise(() => {}), + }); + const pending = indicator.isHealthy('redis'); + await jest.advanceTimersByTimeAsync(REDIS_HEALTH_TIMEOUT_MS + 10); + const result = await pending; + expect(health.down).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('timed out'), + }), + ); + expect(result.redis.status).toBe('down'); + } finally { + jest.useRealTimers(); + } + }); +}); diff --git a/apps/api/src/modules/health/redis.health.ts b/apps/api/src/modules/health/redis.health.ts new file mode 100644 index 00000000..44ee2c0b --- /dev/null +++ b/apps/api/src/modules/health/redis.health.ts @@ -0,0 +1,63 @@ +import { InjectQueue } from '@nestjs/bullmq'; +import { Injectable } from '@nestjs/common'; +import { HealthIndicatorResult, HealthIndicatorService } from '@nestjs/terminus'; +import { Queue } from 'bullmq'; +import { NOTIFICATIONS_QUEUE } from '@/modules/notifications/notifications.constants'; + +/** How long a health-check PING may take before Redis is considered down. */ +export const REDIS_HEALTH_TIMEOUT_MS = 1500; + +/** + * Terminus health indicator that reports whether Redis (the BullMQ backend) is + * reachable. It reuses the notifications queue's own ioredis connection — the + * exact client the app depends on — and issues a bounded `PING`. The timeout is + * essential: when Redis is down ioredis sits in a reconnect loop, so an + * un-raced ping would hang the whole `/health` request instead of failing it. + */ +@Injectable() +export class RedisHealthIndicator { + constructor( + @InjectQueue(NOTIFICATIONS_QUEUE) private readonly queue: Queue, + private readonly healthIndicatorService: HealthIndicatorService, + ) {} + + async isHealthy(key: string): Promise { + const indicator = this.healthIndicatorService.check(key); + try { + const pong = await this.pingWithTimeout(REDIS_HEALTH_TIMEOUT_MS); + if (pong !== 'PONG') { + return indicator.down({ message: `unexpected ping reply: ${pong}` }); + } + return indicator.up(); + } catch (error) { + return indicator.down({ + message: (error as Error)?.message ?? 'redis unreachable', + }); + } + } + + private async pingWithTimeout(ms: number): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`redis ping timed out after ${ms}ms`)), + ms, + ); + }); + try { + return await Promise.race([ + (async () => { + // bullmq types the client as a minimal IRedisClient; the runtime + // object is the ioredis connection, which implements PING. + const client = (await this.queue.client) as unknown as { + ping: () => Promise; + }; + return client.ping(); + })(), + timeout, + ]); + } finally { + if (timer) clearTimeout(timer); + } + } +} diff --git a/apps/api/src/modules/invoice-payments/dto/create-invoice-payment.dto.ts b/apps/api/src/modules/invoice-payments/dto/create-invoice-payment.dto.ts new file mode 100644 index 00000000..5fdffb83 --- /dev/null +++ b/apps/api/src/modules/invoice-payments/dto/create-invoice-payment.dto.ts @@ -0,0 +1,36 @@ +import { + IsDateString, + IsEnum, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + Min, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { InvoicePaymentMethod } from '@repo/db'; + +export class CreateInvoicePaymentDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + invoiceId: string; + + @ApiProperty() + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0) + amount: number; + + @ApiProperty({ enum: InvoicePaymentMethod }) + @IsEnum(InvoicePaymentMethod) + method: InvoicePaymentMethod; + + @ApiProperty() + @IsDateString() + paidAt: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/api/src/modules/invoice-payments/invoice-payments.controller.ts b/apps/api/src/modules/invoice-payments/invoice-payments.controller.ts new file mode 100644 index 00000000..4726fe6b --- /dev/null +++ b/apps/api/src/modules/invoice-payments/invoice-payments.controller.ts @@ -0,0 +1,71 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { InvoicePaymentsService } from './invoice-payments.service'; +import { OrgScopeService } from '@/common/org-scope/org-scope.service'; +import { CurrentUser, Roles } from '@/common/decorators'; +import { AuthenticatedUser } from '@/common/types/authenticated-user.type'; +import { Role } from '@/common/enums'; +import { CreateInvoicePaymentDto } from './dto/create-invoice-payment.dto'; + +@ApiTags('invoice-payments') +@ApiBearerAuth() +@Controller('invoice-payments') +export class InvoicePaymentsController { + constructor( + private readonly invoicePaymentsService: InvoicePaymentsService, + private readonly orgScope: OrgScopeService, + ) {} + + @Roles(Role.ORG_ADMIN, Role.FINANCE, Role.SUPERVISOR) + @Get() + async getInvoicePayments( + @CurrentUser() user: AuthenticatedUser, + @Query('invoiceId') invoiceId?: string, + ) { + const { orgId, role } = await this.orgScope.resolveForCaller(user); + return this.invoicePaymentsService.findAll( + orgId, + user.sub, + role, + invoiceId, + ); + } + + @Roles(Role.ORG_ADMIN, Role.FINANCE, Role.SUPERVISOR) + @Get(':id') + async getInvoicePayment( + @CurrentUser() user: AuthenticatedUser, + @Param('id') id: string, + ) { + const { orgId, role } = await this.orgScope.resolveForCaller(user); + return this.invoicePaymentsService.findOne(orgId, user.sub, role, id); + } + + @Roles(Role.ORG_ADMIN, Role.FINANCE) + @Post() + async createInvoicePayment( + @CurrentUser() user: AuthenticatedUser, + @Body() dto: CreateInvoicePaymentDto, + ) { + const { orgId, role } = await this.orgScope.resolveForCaller(user); + return this.invoicePaymentsService.create(orgId, user.sub, role, dto); + } + + @Roles(Role.ORG_ADMIN, Role.FINANCE) + @Delete(':id') + async deleteInvoicePayment( + @CurrentUser() user: AuthenticatedUser, + @Param('id') id: string, + ) { + const { orgId, role } = await this.orgScope.resolveForCaller(user); + return this.invoicePaymentsService.remove(orgId, user.sub, role, id); + } +} diff --git a/apps/api/src/modules/invoice-payments/invoice-payments.module.ts b/apps/api/src/modules/invoice-payments/invoice-payments.module.ts new file mode 100644 index 00000000..19644921 --- /dev/null +++ b/apps/api/src/modules/invoice-payments/invoice-payments.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { InvoicePaymentsController } from './invoice-payments.controller'; +import { InvoicePaymentsService } from './invoice-payments.service'; + +@Module({ + controllers: [InvoicePaymentsController], + providers: [InvoicePaymentsService], + exports: [InvoicePaymentsService], +}) +export class InvoicePaymentsModule {} diff --git a/apps/api/src/modules/invoice-payments/invoice-payments.service.spec.ts b/apps/api/src/modules/invoice-payments/invoice-payments.service.spec.ts new file mode 100644 index 00000000..71956c24 --- /dev/null +++ b/apps/api/src/modules/invoice-payments/invoice-payments.service.spec.ts @@ -0,0 +1,283 @@ +import { + BadRequestException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { InvoicePaymentsService } from './invoice-payments.service'; +import { Role } from '@/common/enums'; + +describe('InvoicePaymentsService', () => { + const orgId = 'org-1'; + const callerId = 'caller-1'; + const buildingId = 'building-1'; + + function makeService( + overrides: { + invoicePayment?: Partial>; + invoice?: Partial>; + buildingAccess?: Partial>; + } = {}, + ) { + const prisma: any = { + invoicePayment: { + findFirst: jest.fn().mockResolvedValue(null), + findMany: jest.fn().mockResolvedValue([]), + create: jest.fn(), + delete: jest.fn(), + ...overrides.invoicePayment, + }, + invoice: { + findFirst: jest.fn().mockResolvedValue(null), + ...overrides.invoice, + }, + }; + const buildingAccess = { + getAllowedBuildingIds: jest.fn().mockResolvedValue(null), + assertBuildingAccess: jest.fn().mockResolvedValue(undefined), + ...overrides.buildingAccess, + }; + const timeline = { emit: jest.fn().mockResolvedValue(undefined) }; + const service = new InvoicePaymentsService( + prisma, + buildingAccess as any, + timeline as any, + ); + return { service, prisma, buildingAccess, timeline }; + } + + const decimal = (value: string) => ({ + toString: () => value, + toNumber: () => Number(value), + }); + + const paymentRow = (overrides: Partial> = {}) => ({ + id: 'payment-1', + orgId, + invoiceId: 'invoice-1', + amount: decimal('500.00'), + method: 'cash', + paidAt: new Date('2026-02-01T00:00:00.000Z'), + notes: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + ...overrides, + }); + + const invoiceForSummary = ( + overrides: Partial> = {}, + ) => ({ + dueDate: new Date('2099-02-01T00:00:00.000Z'), + lineItems: [{ amount: decimal('1000.00') }], + payments: [{ amount: decimal('500.00') }], + ...overrides, + }); + + describe('findAll', () => { + it('sees the full org for a finance caller', async () => { + const { service, prisma, buildingAccess } = makeService({ + invoicePayment: { + findMany: jest.fn().mockResolvedValue([paymentRow()]), + }, + }); + + await service.findAll(orgId, callerId, Role.FINANCE); + + expect(buildingAccess.getAllowedBuildingIds).toHaveBeenCalledWith( + orgId, + callerId, + Role.FINANCE, + ); + expect(prisma.invoicePayment.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { orgId } }), + ); + }); + + it('filters to allowed building ids for a supervisor', async () => { + const { service, prisma } = makeService({ + buildingAccess: { + getAllowedBuildingIds: jest.fn().mockResolvedValue([buildingId]), + }, + }); + + await service.findAll(orgId, callerId, Role.SUPERVISOR); + + expect(prisma.invoicePayment.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + orgId, + invoice: { buildingId: { in: [buildingId] } }, + }, + }), + ); + }); + + it('filters by invoiceId when provided', async () => { + const { service, prisma } = makeService(); + + await service.findAll(orgId, callerId, Role.ORG_ADMIN, 'invoice-1'); + + expect(prisma.invoicePayment.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { orgId, invoiceId: 'invoice-1' }, + }), + ); + }); + }); + + describe('findOne', () => { + it('throws NotFoundException for a payment in a different org', async () => { + const { service } = makeService(); + + await expect( + service.findOne(orgId, callerId, Role.ORG_ADMIN, 'missing'), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('throws ForbiddenException for a supervisor not assigned to the building', async () => { + const { service } = makeService({ + invoicePayment: { + findFirst: jest.fn().mockResolvedValue({ + ...paymentRow(), + invoice: { buildingId }, + }), + }, + buildingAccess: { + assertBuildingAccess: jest + .fn() + .mockRejectedValue(new ForbiddenException()), + }, + }); + + await expect( + service.findOne(orgId, callerId, Role.SUPERVISOR, 'payment-1'), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + }); + + describe('create', () => { + const dto = { + invoiceId: 'invoice-1', + amount: 500, + method: 'cash' as const, + paidAt: '2026-02-01', + notes: undefined, + }; + + it('succeeds with a valid invoice/amount/method/paidAt and recomputes the invoice summary', async () => { + const { service, prisma, timeline } = makeService({ + invoice: { + findFirst: jest + .fn() + .mockResolvedValueOnce({ id: 'invoice-1' }) + .mockResolvedValueOnce(invoiceForSummary()), + }, + invoicePayment: { + create: jest.fn().mockResolvedValue(paymentRow()), + }, + }); + + const result = await service.create(orgId, callerId, Role.ORG_ADMIN, dto); + + expect(prisma.invoicePayment.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + invoiceId: 'invoice-1', + amount: 500, + }), + }), + ); + expect(timeline.emit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'invoice_payment.created' }), + ); + expect(result.data.payment.id).toBe('payment-1'); + expect(result.data.invoice).toEqual({ + totalAmount: '1000.00', + paidAmount: '500.00', + status: 'partially_paid', + }); + }); + + it('rejects when a required field is missing', async () => { + const { service } = makeService(); + + await expect( + service.create(orgId, callerId, Role.ORG_ADMIN, { + ...dto, + amount: undefined, + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rejects when the invoice does not exist / belongs to another org', async () => { + const { service } = makeService({ + invoice: { findFirst: jest.fn().mockResolvedValue(null) }, + }); + + await expect( + service.create(orgId, callerId, Role.ORG_ADMIN, dto), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('rejects a supervisor caller', async () => { + const { service } = makeService(); + + await expect( + service.create(orgId, callerId, Role.SUPERVISOR, dto), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + }); + + describe('remove', () => { + it('deletes the payment and recomputes the invoice summary', async () => { + const { service, prisma, timeline } = makeService({ + invoicePayment: { + findFirst: jest.fn().mockResolvedValue(paymentRow()), + }, + invoice: { + findFirst: jest + .fn() + .mockResolvedValue(invoiceForSummary({ payments: [] })), + }, + }); + + const result = await service.remove( + orgId, + callerId, + Role.ORG_ADMIN, + 'payment-1', + ); + + expect(prisma.invoicePayment.delete).toHaveBeenCalledWith({ + where: { id: 'payment-1' }, + }); + expect(timeline.emit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'invoice_payment.deleted' }), + ); + expect(result.data.invoice).toEqual({ + totalAmount: '1000.00', + paidAmount: '0.00', + status: 'open', + }); + }); + + it('throws NotFoundException for a payment outside the org', async () => { + const { service } = makeService(); + + await expect( + service.remove(orgId, callerId, Role.ORG_ADMIN, 'missing'), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('rejects a supervisor caller', async () => { + const { service } = makeService({ + invoicePayment: { + findFirst: jest.fn().mockResolvedValue(paymentRow()), + }, + }); + + await expect( + service.remove(orgId, callerId, Role.SUPERVISOR, 'payment-1'), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + }); +}); diff --git a/apps/api/src/modules/invoice-payments/invoice-payments.service.ts b/apps/api/src/modules/invoice-payments/invoice-payments.service.ts new file mode 100644 index 00000000..679d89fe --- /dev/null +++ b/apps/api/src/modules/invoice-payments/invoice-payments.service.ts @@ -0,0 +1,219 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma } from '@repo/db'; +import { PrismaService } from '@/infrastructure/prisma/prisma.service'; +import { BuildingAccessService } from '@/common/building-access/building-access.service'; +import { TimelineService } from '@/modules/timeline/timeline.service'; +import { Role } from '@/common/enums'; +import { + InvoicePaymentMethod, + InvoicePaymentResponse, + InvoiceSummarySnapshot, +} from '@repo/contracts'; +import { computeInvoiceSummary } from '@/common/invoice-summary/compute-invoice-summary'; +import { CreateInvoicePaymentDto } from './dto/create-invoice-payment.dto'; + +type InvoicePaymentRow = { + id: string; + orgId: string; + invoiceId: string; + amount: Prisma.Decimal; + method: string; + paidAt: Date; + notes: string | null; + createdAt: Date; + updatedAt: Date; +}; + +@Injectable() +export class InvoicePaymentsService { + constructor( + private readonly prisma: PrismaService, + private readonly buildingAccess: BuildingAccessService, + private readonly timeline: TimelineService, + ) {} + + private assertWriteAccess(callerRole: Role): void { + if (callerRole !== Role.ORG_ADMIN && callerRole !== Role.FINANCE) { + throw new ForbiddenException( + 'Only an org admin or finance user can write to invoice payments.', + ); + } + } + + private formatPayment(payment: InvoicePaymentRow): InvoicePaymentResponse { + return { + id: payment.id, + orgId: payment.orgId, + invoiceId: payment.invoiceId, + amount: payment.amount.toString(), + method: payment.method as InvoicePaymentMethod, + paidAt: payment.paidAt.toISOString(), + notes: payment.notes, + createdAt: payment.createdAt.toISOString(), + updatedAt: payment.updatedAt.toISOString(), + }; + } + + /** Recomputes totalAmount/paidAmount/status for the parent Invoice. */ + private async summarizeInvoice( + orgId: string, + invoiceId: string, + ): Promise { + const invoice = await this.prisma.invoice.findFirst({ + where: { id: invoiceId, orgId }, + select: { + dueDate: true, + lineItems: { select: { amount: true } }, + payments: { select: { amount: true } }, + }, + }); + if (!invoice) throw new NotFoundException('Invoice not found.'); + + const { totalAmount, paidAmount, status } = computeInvoiceSummary( + invoice.lineItems.map((li) => ({ amount: li.amount.toNumber() })), + invoice.payments.map((p) => ({ amount: p.amount.toNumber() })), + invoice.dueDate, + new Date(), + ); + + return { + totalAmount: totalAmount.toFixed(2), + paidAmount: paidAmount.toFixed(2), + status, + }; + } + + async findAll( + orgId: string, + callerId: string, + callerRole: Role, + invoiceId?: string, + ): Promise<{ data: InvoicePaymentResponse[] }> { + const allowedBuildingIds = await this.buildingAccess.getAllowedBuildingIds( + orgId, + callerId, + callerRole, + ); + + const payments = await this.prisma.invoicePayment.findMany({ + where: { + orgId, + ...(invoiceId && { invoiceId }), + ...(allowedBuildingIds && { + invoice: { buildingId: { in: allowedBuildingIds } }, + }), + }, + orderBy: { paidAt: 'desc' }, + }); + + return { data: payments.map((p) => this.formatPayment(p)) }; + } + + async findOne( + orgId: string, + callerId: string, + callerRole: Role, + paymentId: string, + ): Promise<{ data: InvoicePaymentResponse }> { + const payment = await this.prisma.invoicePayment.findFirst({ + where: { id: paymentId, orgId }, + include: { invoice: { select: { buildingId: true } } }, + }); + if (!payment) throw new NotFoundException('Invoice payment not found.'); + + await this.buildingAccess.assertBuildingAccess( + orgId, + callerId, + callerRole, + payment.invoice.buildingId, + ); + + return { data: this.formatPayment(payment) }; + } + + async create( + orgId: string, + actorId: string, + callerRole: Role, + dto: CreateInvoicePaymentDto, + ): Promise<{ + data: { payment: InvoicePaymentResponse; invoice: InvoiceSummarySnapshot }; + }> { + this.assertWriteAccess(callerRole); + + if ( + !dto.invoiceId || + dto.amount === undefined || + !dto.method || + !dto.paidAt + ) { + throw new BadRequestException( + 'invoiceId, amount, method, and paidAt are required.', + ); + } + + const invoice = await this.prisma.invoice.findFirst({ + where: { id: dto.invoiceId, orgId }, + select: { id: true }, + }); + if (!invoice) throw new NotFoundException('Invoice not found.'); + + const payment = await this.prisma.invoicePayment.create({ + data: { + orgId, + invoiceId: invoice.id, + amount: dto.amount, + method: dto.method, + paidAt: new Date(dto.paidAt), + notes: dto.notes, + }, + }); + + await this.timeline.emit({ + orgId, + actorId, + action: 'invoice_payment.created', + targetType: 'InvoicePayment', + targetId: payment.id, + metadata: { invoiceId: invoice.id, amount: payment.amount.toString() }, + }); + + const summary = await this.summarizeInvoice(orgId, invoice.id); + + return { data: { payment: this.formatPayment(payment), invoice: summary } }; + } + + async remove( + orgId: string, + actorId: string, + callerRole: Role, + paymentId: string, + ): Promise<{ data: { id: string; invoice: InvoiceSummarySnapshot } }> { + this.assertWriteAccess(callerRole); + + const existing = await this.prisma.invoicePayment.findFirst({ + where: { id: paymentId, orgId }, + }); + if (!existing) throw new NotFoundException('Invoice payment not found.'); + + await this.prisma.invoicePayment.delete({ where: { id: paymentId } }); + + await this.timeline.emit({ + orgId, + actorId, + action: 'invoice_payment.deleted', + targetType: 'InvoicePayment', + targetId: paymentId, + metadata: { invoiceId: existing.invoiceId }, + }); + + const summary = await this.summarizeInvoice(orgId, existing.invoiceId); + + return { data: { id: paymentId, invoice: summary } }; + } +} diff --git a/apps/api/src/modules/invoices/dto/create-invoice.dto.ts b/apps/api/src/modules/invoices/dto/create-invoice.dto.ts new file mode 100644 index 00000000..937281c3 --- /dev/null +++ b/apps/api/src/modules/invoices/dto/create-invoice.dto.ts @@ -0,0 +1,54 @@ +import { + ArrayMinSize, + IsArray, + IsDateString, + IsEnum, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + Min, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { InvoiceLineItemCategory } from '@repo/db'; + +export class InvoiceLineItemInputDto { + @ApiProperty({ enum: InvoiceLineItemCategory }) + @IsEnum(InvoiceLineItemCategory) + category: InvoiceLineItemCategory; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + description?: string; + + @ApiProperty() + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0) + amount: number; +} + +export class CreateInvoiceDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + leaseId: string; + + @ApiProperty() + @IsDateString() + dueDate: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; + + @ApiProperty({ type: [InvoiceLineItemInputDto] }) + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => InvoiceLineItemInputDto) + lineItems: InvoiceLineItemInputDto[]; +} diff --git a/apps/api/src/modules/invoices/dto/update-invoice.dto.ts b/apps/api/src/modules/invoices/dto/update-invoice.dto.ts new file mode 100644 index 00000000..e130af13 --- /dev/null +++ b/apps/api/src/modules/invoices/dto/update-invoice.dto.ts @@ -0,0 +1,31 @@ +import { + ArrayMinSize, + IsArray, + IsDateString, + IsOptional, + IsString, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { InvoiceLineItemInputDto } from './create-invoice.dto'; + +export class UpdateInvoiceDto { + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + dueDate?: string; + + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + @IsString() + notes?: string | null; + + @ApiPropertyOptional({ type: [InvoiceLineItemInputDto] }) + @IsOptional() + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => InvoiceLineItemInputDto) + lineItems?: InvoiceLineItemInputDto[]; +} diff --git a/apps/api/src/modules/invoices/invoices.controller.ts b/apps/api/src/modules/invoices/invoices.controller.ts new file mode 100644 index 00000000..f5ee03f5 --- /dev/null +++ b/apps/api/src/modules/invoices/invoices.controller.ts @@ -0,0 +1,75 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { InvoicesService } from './invoices.service'; +import { OrgScopeService } from '@/common/org-scope/org-scope.service'; +import { CurrentUser, Roles } from '@/common/decorators'; +import { AuthenticatedUser } from '@/common/types/authenticated-user.type'; +import { Role } from '@/common/enums'; +import { CreateInvoiceDto } from './dto/create-invoice.dto'; +import { UpdateInvoiceDto } from './dto/update-invoice.dto'; + +@ApiTags('invoices') +@ApiBearerAuth() +@Controller('invoices') +export class InvoicesController { + constructor( + private readonly invoicesService: InvoicesService, + private readonly orgScope: OrgScopeService, + ) {} + + @Roles(Role.ORG_ADMIN, Role.FINANCE, Role.SUPERVISOR) + @Get() + async getInvoices(@CurrentUser() user: AuthenticatedUser) { + const { orgId, role } = await this.orgScope.resolveForCaller(user); + return this.invoicesService.findAll(orgId, user.sub, role); + } + + @Roles(Role.ORG_ADMIN, Role.FINANCE, Role.SUPERVISOR) + @Get(':id') + async getInvoice( + @CurrentUser() user: AuthenticatedUser, + @Param('id') id: string, + ) { + const { orgId, role } = await this.orgScope.resolveForCaller(user); + return this.invoicesService.findOne(orgId, user.sub, role, id); + } + + @Roles(Role.ORG_ADMIN, Role.FINANCE) + @Post() + async createInvoice( + @CurrentUser() user: AuthenticatedUser, + @Body() dto: CreateInvoiceDto, + ) { + const { orgId, role } = await this.orgScope.resolveForCaller(user); + return this.invoicesService.create(orgId, user.sub, role, dto); + } + + @Roles(Role.ORG_ADMIN, Role.FINANCE) + @Patch(':id') + async updateInvoice( + @CurrentUser() user: AuthenticatedUser, + @Param('id') id: string, + @Body() dto: UpdateInvoiceDto, + ) { + const { orgId, role } = await this.orgScope.resolveForCaller(user); + return this.invoicesService.update(orgId, user.sub, role, id, dto); + } + + @Roles(Role.ORG_ADMIN, Role.FINANCE) + @Delete(':id') + async deleteInvoice( + @CurrentUser() user: AuthenticatedUser, + @Param('id') id: string, + ) { + const { orgId, role } = await this.orgScope.resolveForCaller(user); + return this.invoicesService.remove(orgId, user.sub, role, id); + } +} diff --git a/apps/api/src/modules/invoices/invoices.module.ts b/apps/api/src/modules/invoices/invoices.module.ts new file mode 100644 index 00000000..058d12f8 --- /dev/null +++ b/apps/api/src/modules/invoices/invoices.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { InvoicesController } from './invoices.controller'; +import { InvoicesService } from './invoices.service'; + +@Module({ + controllers: [InvoicesController], + providers: [InvoicesService], + exports: [InvoicesService], +}) +export class InvoicesModule {} diff --git a/apps/api/src/modules/invoices/invoices.service.spec.ts b/apps/api/src/modules/invoices/invoices.service.spec.ts new file mode 100644 index 00000000..b02a2d2c --- /dev/null +++ b/apps/api/src/modules/invoices/invoices.service.spec.ts @@ -0,0 +1,377 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { InvoicesService } from './invoices.service'; +import { Role } from '@/common/enums'; + +describe('InvoicesService', () => { + const orgId = 'org-1'; + const callerId = 'caller-1'; + const buildingId = 'building-1'; + + function makeService( + overrides: { + invoice?: Partial>; + lease?: Partial>; + invoiceLineItem?: Partial>; + invoicePayment?: Partial>; + buildingAccess?: Partial>; + } = {}, + ) { + const prisma: any = { + invoice: { + findFirst: jest.fn().mockResolvedValue(null), + findMany: jest.fn().mockResolvedValue([]), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + ...overrides.invoice, + }, + lease: { + findFirst: jest.fn().mockResolvedValue(null), + ...overrides.lease, + }, + invoiceLineItem: { + deleteMany: jest.fn().mockResolvedValue(undefined), + ...overrides.invoiceLineItem, + }, + invoicePayment: { + count: jest.fn().mockResolvedValue(0), + ...overrides.invoicePayment, + }, + }; + prisma.$transaction = jest.fn(async (cb: (tx: unknown) => unknown) => + cb(prisma), + ); + const buildingAccess = { + getAllowedBuildingIds: jest.fn().mockResolvedValue(null), + assertBuildingAccess: jest.fn().mockResolvedValue(undefined), + ...overrides.buildingAccess, + }; + const timeline = { emit: jest.fn().mockResolvedValue(undefined) }; + const service = new InvoicesService( + prisma, + buildingAccess as any, + timeline as any, + ); + return { service, prisma, buildingAccess, timeline }; + } + + const decimal = (value: string) => ({ + toString: () => value, + toNumber: () => Number(value), + }); + + const invoiceRow = (overrides: Partial> = {}) => ({ + id: 'invoice-1', + orgId, + buildingId, + leaseId: 'lease-1', + dueDate: new Date('2099-02-01T00:00:00.000Z'), + notes: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + lineItems: [ + { + id: 'li-1', + invoiceId: 'invoice-1', + category: 'rent', + description: null, + amount: decimal('1000.00'), + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + }, + ], + payments: [], + lease: { + renterId: 'renter-1', + renter: { fullName: 'Jane Tenant' }, + apartment: { unitNumber: '101' }, + }, + ...overrides, + }); + + describe('findAll', () => { + it('returns invoices with computed totalAmount/paidAmount/status for an org-wide role', async () => { + const { service, prisma, buildingAccess } = makeService({ + invoice: { findMany: jest.fn().mockResolvedValue([invoiceRow()]) }, + }); + + const result = await service.findAll(orgId, callerId, Role.ORG_ADMIN); + + expect(buildingAccess.getAllowedBuildingIds).toHaveBeenCalledWith( + orgId, + callerId, + Role.ORG_ADMIN, + ); + expect(prisma.invoice.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { orgId } }), + ); + expect(result.data).toEqual([ + expect.objectContaining({ + id: 'invoice-1', + leaseId: 'lease-1', + totalAmount: '1000.00', + paidAmount: '0.00', + status: 'open', + renterId: 'renter-1', + renterName: 'Jane Tenant', + apartmentUnitNumber: '101', + }), + ]); + }); + + it('sees the full org regardless of building for a finance caller', async () => { + const { service, prisma, buildingAccess } = makeService(); + + await service.findAll(orgId, callerId, Role.FINANCE); + + expect(buildingAccess.getAllowedBuildingIds).toHaveBeenCalledWith( + orgId, + callerId, + Role.FINANCE, + ); + expect(prisma.invoice.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { orgId } }), + ); + }); + + it('filters to allowed building ids for a supervisor', async () => { + const { service, prisma } = makeService({ + buildingAccess: { + getAllowedBuildingIds: jest.fn().mockResolvedValue([buildingId]), + }, + }); + + await service.findAll(orgId, callerId, Role.SUPERVISOR); + + expect(prisma.invoice.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { orgId, buildingId: { in: [buildingId] } }, + }), + ); + }); + }); + + describe('findOne', () => { + it('returns the invoice when it belongs to the caller org', async () => { + const { service, prisma } = makeService({ + invoice: { findFirst: jest.fn().mockResolvedValue(invoiceRow()) }, + }); + + const result = await service.findOne( + orgId, + callerId, + Role.ORG_ADMIN, + 'invoice-1', + ); + + expect(prisma.invoice.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'invoice-1', orgId } }), + ); + expect(result.data).toEqual(expect.objectContaining({ id: 'invoice-1' })); + }); + + it('throws NotFoundException for an invoice in a different org', async () => { + const { service } = makeService(); + + await expect( + service.findOne(orgId, callerId, Role.ORG_ADMIN, 'missing'), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('throws ForbiddenException for a supervisor not assigned to the building', async () => { + const { service } = makeService({ + invoice: { findFirst: jest.fn().mockResolvedValue(invoiceRow()) }, + buildingAccess: { + assertBuildingAccess: jest + .fn() + .mockRejectedValue(new ForbiddenException()), + }, + }); + + await expect( + service.findOne(orgId, callerId, Role.SUPERVISOR, 'invoice-1'), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + }); + + describe('create', () => { + const dto = { + leaseId: 'lease-1', + dueDate: '2099-02-01', + notes: undefined, + lineItems: [{ category: 'rent' as const, amount: 1000 }], + }; + + it('creates an invoice with the denormalized buildingId from the lease', async () => { + const { service, prisma, timeline } = makeService({ + lease: { + findFirst: jest.fn().mockResolvedValue({ id: 'lease-1', buildingId }), + }, + invoice: { create: jest.fn().mockResolvedValue(invoiceRow()) }, + }); + + const result = await service.create(orgId, callerId, Role.ORG_ADMIN, dto); + + expect(prisma.invoice.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + orgId, + buildingId, + leaseId: 'lease-1', + }), + }), + ); + expect(timeline.emit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'invoice.created' }), + ); + expect(result.data.id).toBe('invoice-1'); + }); + + it('rejects when line items are empty', async () => { + const { service } = makeService({ + lease: { + findFirst: jest.fn().mockResolvedValue({ id: 'lease-1', buildingId }), + }, + }); + + await expect( + service.create(orgId, callerId, Role.ORG_ADMIN, { + ...dto, + lineItems: [], + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rejects when a required field is missing', async () => { + const { service } = makeService(); + + await expect( + service.create(orgId, callerId, Role.ORG_ADMIN, { + ...dto, + leaseId: '', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('throws NotFoundException when the lease does not exist in the org', async () => { + const { service } = makeService({ + lease: { findFirst: jest.fn().mockResolvedValue(null) }, + }); + + await expect( + service.create(orgId, callerId, Role.ORG_ADMIN, dto), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('rejects a supervisor caller', async () => { + const { service } = makeService(); + + await expect( + service.create(orgId, callerId, Role.SUPERVISOR, dto), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + }); + + describe('update', () => { + it('replaces the line-item set wholesale when line items are provided', async () => { + const { service, prisma, timeline } = makeService({ + invoice: { + findFirst: jest.fn().mockResolvedValue(invoiceRow()), + update: jest.fn().mockResolvedValue(invoiceRow()), + }, + }); + + await service.update(orgId, callerId, Role.FINANCE, 'invoice-1', { + lineItems: [{ category: 'utilities', amount: 250 }], + }); + + expect(prisma.invoiceLineItem.deleteMany).toHaveBeenCalledWith({ + where: { invoiceId: 'invoice-1' }, + }); + expect(prisma.invoice.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + lineItems: { create: [expect.objectContaining({ amount: 250 })] }, + }), + }), + ); + expect(timeline.emit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'invoice.updated' }), + ); + }); + + it('patches dueDate/notes independently when line items are not provided', async () => { + const { service, prisma } = makeService({ + invoice: { + findFirst: jest.fn().mockResolvedValue(invoiceRow()), + update: jest.fn().mockResolvedValue(invoiceRow()), + }, + }); + + await service.update(orgId, callerId, Role.ORG_ADMIN, 'invoice-1', { + notes: 'updated notes', + }); + + expect(prisma.invoiceLineItem.deleteMany).not.toHaveBeenCalled(); + expect(prisma.invoice.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: { notes: 'updated notes' }, + }), + ); + }); + + it('throws NotFoundException for an invoice outside the org', async () => { + const { service } = makeService(); + + await expect( + service.update(orgId, callerId, Role.ORG_ADMIN, 'missing', { + notes: 'x', + }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + }); + + describe('remove', () => { + it('deletes the invoice and its line items', async () => { + const { service, prisma, timeline } = makeService({ + invoice: { findFirst: jest.fn().mockResolvedValue(invoiceRow()) }, + }); + + await service.remove(orgId, callerId, Role.ORG_ADMIN, 'invoice-1'); + + expect(prisma.invoice.delete).toHaveBeenCalledWith({ + where: { id: 'invoice-1' }, + }); + expect(timeline.emit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'invoice.deleted' }), + ); + }); + + it('rejects a supervisor caller', async () => { + const { service } = makeService({ + invoice: { findFirst: jest.fn().mockResolvedValue(invoiceRow()) }, + }); + + await expect( + service.remove(orgId, callerId, Role.SUPERVISOR, 'invoice-1'), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('rejects deletion when an InvoicePayment references the invoice', async () => { + const { service, prisma } = makeService({ + invoice: { findFirst: jest.fn().mockResolvedValue(invoiceRow()) }, + invoicePayment: { count: jest.fn().mockResolvedValue(1) }, + }); + + await expect( + service.remove(orgId, callerId, Role.ORG_ADMIN, 'invoice-1'), + ).rejects.toBeInstanceOf(ConflictException); + expect(prisma.invoice.delete).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/api/src/modules/invoices/invoices.service.ts b/apps/api/src/modules/invoices/invoices.service.ts new file mode 100644 index 00000000..1f514fad --- /dev/null +++ b/apps/api/src/modules/invoices/invoices.service.ts @@ -0,0 +1,309 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma } from '@repo/db'; +import { PrismaService } from '@/infrastructure/prisma/prisma.service'; +import { BuildingAccessService } from '@/common/building-access/building-access.service'; +import { TimelineService } from '@/modules/timeline/timeline.service'; +import { Role } from '@/common/enums'; +import { InvoiceLineItemCategory, InvoiceResponse } from '@repo/contracts'; +import { computeInvoiceSummary } from '@/common/invoice-summary/compute-invoice-summary'; +import { CreateInvoiceDto } from './dto/create-invoice.dto'; +import { UpdateInvoiceDto } from './dto/update-invoice.dto'; + +type InvoiceRow = { + id: string; + orgId: string; + buildingId: string; + leaseId: string; + dueDate: Date; + notes: string | null; + createdAt: Date; + updatedAt: Date; + lineItems: { + id: string; + invoiceId: string; + category: string; + description: string | null; + amount: Prisma.Decimal; + createdAt: Date; + updatedAt: Date; + }[]; + payments: { amount: Prisma.Decimal }[]; + lease: { + renterId: string; + renter: { fullName: string }; + apartment: { unitNumber: string }; + }; +}; + +const INVOICE_INCLUDE = { + lineItems: true, + payments: { select: { amount: true } }, + lease: { + include: { + renter: { select: { fullName: true } }, + apartment: { select: { unitNumber: true } }, + }, + }, +} satisfies Prisma.InvoiceInclude; + +@Injectable() +export class InvoicesService { + constructor( + private readonly prisma: PrismaService, + private readonly buildingAccess: BuildingAccessService, + private readonly timeline: TimelineService, + ) {} + + private assertWriteAccess(callerRole: Role): void { + if (callerRole !== Role.ORG_ADMIN && callerRole !== Role.FINANCE) { + throw new ForbiddenException( + 'Only an org admin or finance user can write to invoices.', + ); + } + } + + private formatInvoice(invoice: InvoiceRow): InvoiceResponse { + const { totalAmount, paidAmount, status } = computeInvoiceSummary( + invoice.lineItems.map((li) => ({ amount: li.amount.toNumber() })), + invoice.payments.map((p) => ({ amount: p.amount.toNumber() })), + invoice.dueDate, + new Date(), + ); + + return { + id: invoice.id, + orgId: invoice.orgId, + buildingId: invoice.buildingId, + leaseId: invoice.leaseId, + dueDate: invoice.dueDate.toISOString(), + notes: invoice.notes, + lineItems: invoice.lineItems.map((li) => ({ + id: li.id, + invoiceId: li.invoiceId, + category: li.category as InvoiceLineItemCategory, + description: li.description, + amount: li.amount.toString(), + createdAt: li.createdAt.toISOString(), + updatedAt: li.updatedAt.toISOString(), + })), + totalAmount: totalAmount.toFixed(2), + paidAmount: paidAmount.toFixed(2), + status, + renterId: invoice.lease.renterId, + renterName: invoice.lease.renter.fullName, + apartmentUnitNumber: invoice.lease.apartment.unitNumber, + createdAt: invoice.createdAt.toISOString(), + updatedAt: invoice.updatedAt.toISOString(), + }; + } + + async findAll( + orgId: string, + callerId: string, + callerRole: Role, + ): Promise<{ data: InvoiceResponse[] }> { + const allowedBuildingIds = await this.buildingAccess.getAllowedBuildingIds( + orgId, + callerId, + callerRole, + ); + + const invoices = await this.prisma.invoice.findMany({ + where: { + orgId, + ...(allowedBuildingIds && { buildingId: { in: allowedBuildingIds } }), + }, + include: INVOICE_INCLUDE, + orderBy: { dueDate: 'desc' }, + }); + + return { data: invoices.map((i) => this.formatInvoice(i)) }; + } + + async findOne( + orgId: string, + callerId: string, + callerRole: Role, + invoiceId: string, + ): Promise<{ data: InvoiceResponse }> { + const invoice = await this.prisma.invoice.findFirst({ + where: { id: invoiceId, orgId }, + include: INVOICE_INCLUDE, + }); + if (!invoice) throw new NotFoundException('Invoice not found.'); + + await this.buildingAccess.assertBuildingAccess( + orgId, + callerId, + callerRole, + invoice.buildingId, + ); + + return { data: this.formatInvoice(invoice) }; + } + + // ── CRUD (write) ────────────────────────────────────────────────────────── + + private validateLineItems( + lineItems: { category?: string; amount?: number }[] | undefined, + ): void { + if (!lineItems || lineItems.length === 0) { + throw new BadRequestException('At least one line item is required.'); + } + for (const li of lineItems) { + if (!li.category || li.amount === undefined || li.amount === null) { + throw new BadRequestException( + 'Each line item requires a category and an amount.', + ); + } + } + } + + async create( + orgId: string, + actorId: string, + callerRole: Role, + dto: CreateInvoiceDto, + ): Promise<{ data: InvoiceResponse }> { + this.assertWriteAccess(callerRole); + + if (!dto.leaseId || !dto.dueDate) { + throw new BadRequestException('leaseId and dueDate are required.'); + } + this.validateLineItems(dto.lineItems); + + const lease = await this.prisma.lease.findFirst({ + where: { id: dto.leaseId, orgId }, + select: { id: true, buildingId: true }, + }); + if (!lease) throw new NotFoundException('Lease not found.'); + + const invoice = await this.prisma.invoice.create({ + data: { + orgId, + buildingId: lease.buildingId, + leaseId: lease.id, + dueDate: new Date(dto.dueDate), + notes: dto.notes, + lineItems: { + create: dto.lineItems.map((li) => ({ + category: li.category, + description: li.description, + amount: li.amount, + })), + }, + }, + include: INVOICE_INCLUDE, + }); + + await this.timeline.emit({ + orgId, + actorId, + action: 'invoice.created', + targetType: 'Invoice', + targetId: invoice.id, + metadata: { + leaseId: invoice.leaseId, + lineItemCount: dto.lineItems.length, + }, + }); + + return { data: this.formatInvoice(invoice) }; + } + + async update( + orgId: string, + actorId: string, + callerRole: Role, + invoiceId: string, + dto: UpdateInvoiceDto, + ): Promise<{ data: InvoiceResponse }> { + this.assertWriteAccess(callerRole); + + const existing = await this.prisma.invoice.findFirst({ + where: { id: invoiceId, orgId }, + }); + if (!existing) throw new NotFoundException('Invoice not found.'); + + if (dto.lineItems !== undefined) { + this.validateLineItems(dto.lineItems); + } + + const invoice = await this.prisma.$transaction(async (tx) => { + if (dto.lineItems !== undefined) { + await tx.invoiceLineItem.deleteMany({ where: { invoiceId } }); + } + + return tx.invoice.update({ + where: { id: invoiceId }, + data: { + ...(dto.dueDate !== undefined && { dueDate: new Date(dto.dueDate) }), + ...(dto.notes !== undefined && { notes: dto.notes }), + ...(dto.lineItems !== undefined && { + lineItems: { + create: dto.lineItems.map((li) => ({ + category: li.category, + description: li.description, + amount: li.amount, + })), + }, + }), + }, + include: INVOICE_INCLUDE, + }); + }); + + await this.timeline.emit({ + orgId, + actorId, + action: 'invoice.updated', + targetType: 'Invoice', + targetId: invoiceId, + metadata: { changes: Object.keys(dto) }, + }); + + return { data: this.formatInvoice(invoice) }; + } + + async remove( + orgId: string, + actorId: string, + callerRole: Role, + invoiceId: string, + ): Promise<{ data: { id: string } }> { + this.assertWriteAccess(callerRole); + + const existing = await this.prisma.invoice.findFirst({ + where: { id: invoiceId, orgId }, + }); + if (!existing) throw new NotFoundException('Invoice not found.'); + + const paymentCount = await this.prisma.invoicePayment.count({ + where: { invoiceId }, + }); + if (paymentCount > 0) { + throw new ConflictException( + 'Cannot delete an invoice that has recorded payments.', + ); + } + + await this.prisma.invoice.delete({ where: { id: invoiceId } }); + + await this.timeline.emit({ + orgId, + actorId, + action: 'invoice.deleted', + targetType: 'Invoice', + targetId: invoiceId, + metadata: { leaseId: existing.leaseId }, + }); + + return { data: { id: invoiceId } }; + } +} diff --git a/apps/api/src/modules/leases/leases-overview.controller.ts b/apps/api/src/modules/leases/leases-overview.controller.ts new file mode 100644 index 00000000..859301c6 --- /dev/null +++ b/apps/api/src/modules/leases/leases-overview.controller.ts @@ -0,0 +1,30 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { LeasesService } from './leases.service'; +import { OrgScopeService } from '@/common/org-scope/org-scope.service'; +import { CurrentUser, Roles } from '@/common/decorators'; +import { AuthenticatedUser } from '@/common/types/authenticated-user.type'; +import { Role } from '@/common/enums'; + +/** + * Org-wide, flat leases list (Sprint U1) — feeds the top-level + * /dashboard/leases page. Complements (does not replace) the nested + * buildings/:buildingId/floors/:floorId/apartments/:apartmentId/leases + * controller, which stays scoped to a single apartment. + */ +@ApiTags('leases') +@ApiBearerAuth() +@Controller('leases') +export class LeasesOverviewController { + constructor( + private readonly leasesService: LeasesService, + private readonly orgScope: OrgScopeService, + ) {} + + @Roles(Role.ORG_ADMIN, Role.SUPERVISOR, Role.FINANCE, Role.MAINTENANCE) + @Get() + async getLeases(@CurrentUser() user: AuthenticatedUser) { + const { orgId, role } = await this.orgScope.resolveForCaller(user); + return this.leasesService.findAllForOrg(orgId, user.sub, role); + } +} diff --git a/apps/api/src/modules/leases/leases.module.ts b/apps/api/src/modules/leases/leases.module.ts index 5124f961..588310b0 100644 --- a/apps/api/src/modules/leases/leases.module.ts +++ b/apps/api/src/modules/leases/leases.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; import { LeasesController } from './leases.controller'; +import { LeasesOverviewController } from './leases-overview.controller'; import { LeasesService } from './leases.service'; @Module({ - controllers: [LeasesController], + controllers: [LeasesController, LeasesOverviewController], providers: [LeasesService], exports: [LeasesService], }) diff --git a/apps/api/src/modules/leases/leases.service.spec.ts b/apps/api/src/modules/leases/leases.service.spec.ts index e8d1e3b5..9bae2a03 100644 --- a/apps/api/src/modules/leases/leases.service.spec.ts +++ b/apps/api/src/modules/leases/leases.service.spec.ts @@ -24,6 +24,9 @@ describe('LeasesService', () => { lease?: Partial>; apartment?: Partial>; renter?: Partial>; + invoice?: Partial>; + building?: Partial>; + floor?: Partial>; buildingAccess?: Partial>; } = {}, ) { @@ -44,13 +47,27 @@ describe('LeasesService', () => { floorId, status: 'vacant', }), + findMany: jest.fn().mockResolvedValue([]), update: jest.fn().mockResolvedValue({}), ...overrides.apartment, }, renter: { findFirst: jest.fn().mockResolvedValue({ id: renterId, orgId }), + findMany: jest.fn().mockResolvedValue([]), ...overrides.renter, }, + invoice: { + count: jest.fn().mockResolvedValue(0), + ...overrides.invoice, + }, + building: { + findMany: jest.fn().mockResolvedValue([]), + ...overrides.building, + }, + floor: { + findMany: jest.fn().mockResolvedValue([]), + ...overrides.floor, + }, }; prisma.$transaction = jest.fn(async (cb: (tx: unknown) => unknown) => cb(prisma), @@ -320,6 +337,25 @@ describe('LeasesService', () => { expect.objectContaining({ action: 'lease.deleted' }), ); }); + + it('rejects deletion when an Invoice references the lease', async () => { + const { service, prisma } = makeService({ + lease: { findFirst: jest.fn().mockResolvedValue(leaseRow()) }, + invoice: { count: jest.fn().mockResolvedValue(1) }, + }); + + await expect( + service.remove( + orgId, + actorId, + buildingId, + floorId, + apartmentId, + 'lease-1', + ), + ).rejects.toBeInstanceOf(ConflictException); + expect(prisma.lease.delete).not.toHaveBeenCalled(); + }); }); describe('findAll', () => { @@ -533,4 +569,109 @@ describe('LeasesService', () => { ).rejects.toBeInstanceOf(NotFoundException); }); }); + + describe('findAllForOrg', () => { + it('returns all org leases enriched with display names for org_admin', async () => { + const { service, prisma, buildingAccess } = makeService({ + lease: { findMany: jest.fn().mockResolvedValue([leaseRow()]) }, + building: { + findMany: jest + .fn() + .mockResolvedValue([{ id: buildingId, name: 'Tower A' }]), + }, + floor: { + findMany: jest + .fn() + .mockResolvedValue([{ id: floorId, name: 'Floor 1' }]), + }, + apartment: { + findMany: jest + .fn() + .mockResolvedValue([{ id: apartmentId, unitNumber: '101' }]), + }, + renter: { + findMany: jest + .fn() + .mockResolvedValue([{ id: renterId, fullName: 'Jane Doe' }]), + }, + buildingAccess: { + getAllowedBuildingIds: jest.fn().mockResolvedValue(null), + }, + }); + + const result = await service.findAllForOrg( + orgId, + 'caller-1', + Role.ORG_ADMIN, + ); + + expect(buildingAccess.getAllowedBuildingIds).toHaveBeenCalledWith( + orgId, + 'caller-1', + Role.ORG_ADMIN, + ); + expect(prisma.lease.findMany).toHaveBeenCalledWith({ + where: { orgId }, + orderBy: { startDate: 'desc' }, + }); + expect(result.data).toEqual([ + expect.objectContaining({ + id: 'lease-1', + buildingName: 'Tower A', + floorName: 'Floor 1', + unitNumber: '101', + renterName: 'Jane Doe', + }), + ]); + }); + + it('constrains supervisor to their assigned buildings', async () => { + const { service, prisma, buildingAccess } = makeService({ + lease: { findMany: jest.fn().mockResolvedValue([]) }, + buildingAccess: { + getAllowedBuildingIds: jest.fn().mockResolvedValue(['building-2']), + }, + }); + + await service.findAllForOrg(orgId, 'caller-1', Role.SUPERVISOR); + + expect(buildingAccess.getAllowedBuildingIds).toHaveBeenCalledWith( + orgId, + 'caller-1', + Role.SUPERVISOR, + ); + expect(prisma.lease.findMany).toHaveBeenCalledWith({ + where: { orgId, buildingId: { in: ['building-2'] } }, + orderBy: { startDate: 'desc' }, + }); + }); + + it('falls back to empty strings when a related record is missing', async () => { + const { service } = makeService({ + lease: { findMany: jest.fn().mockResolvedValue([leaseRow()]) }, + building: { findMany: jest.fn().mockResolvedValue([]) }, + floor: { findMany: jest.fn().mockResolvedValue([]) }, + apartment: { findMany: jest.fn().mockResolvedValue([]) }, + renter: { findMany: jest.fn().mockResolvedValue([]) }, + buildingAccess: { + getAllowedBuildingIds: jest.fn().mockResolvedValue(null), + }, + }); + + const result = await service.findAllForOrg( + orgId, + 'caller-1', + Role.ORG_ADMIN, + ); + + expect(result.data).toEqual([ + expect.objectContaining({ + buildingName: '', + floorName: '', + unitNumber: '', + renterName: '', + }), + ]); + }); + }); }); diff --git a/apps/api/src/modules/leases/leases.service.ts b/apps/api/src/modules/leases/leases.service.ts index 650b8d0b..3df5a02b 100644 --- a/apps/api/src/modules/leases/leases.service.ts +++ b/apps/api/src/modules/leases/leases.service.ts @@ -8,7 +8,7 @@ import { TimelineService } from '@/modules/timeline/timeline.service'; import { BuildingAccessService } from '@/common/building-access/building-access.service'; import { LeaseStatusService } from '@/common/lease-status/lease-status.service'; import { Role } from '@/common/enums'; -import { LeaseResponse } from '@repo/contracts'; +import { LeaseResponse, LeaseListRow } from '@repo/contracts'; import { CreateLeaseDto } from './dto/create-lease.dto'; import { UpdateLeaseDto } from './dto/update-lease.dto'; import { RenewLeaseDto } from './dto/renew-lease.dto'; @@ -81,6 +81,77 @@ export class LeasesService { return { data: leases.map((l) => this.formatLease(l)) }; } + /** + * Org-wide, flat leases list (Sprint U1) — used by the top-level + * /dashboard/leases page. Access is scoped the same way as the nested + * read path: org_admin/finance see all org leases, supervisor/maintenance + * are constrained to their assigned buildings. Each row is enriched with + * display names (building/floor/unit/renter); missing relations fall back + * to '' rather than throwing. + */ + async findAllForOrg( + orgId: string, + callerId: string, + callerRole: Role, + ): Promise<{ data: LeaseListRow[] }> { + const allowedBuildingIds = await this.buildingAccess.getAllowedBuildingIds( + orgId, + callerId, + callerRole, + ); + + const leases = await this.prisma.lease.findMany({ + where: { + orgId, + ...(allowedBuildingIds + ? { buildingId: { in: allowedBuildingIds } } + : {}), + }, + orderBy: { startDate: 'desc' }, + }); + + const buildingIds = [...new Set(leases.map((l) => l.buildingId))]; + const floorIds = [...new Set(leases.map((l) => l.floorId))]; + const apartmentIds = [...new Set(leases.map((l) => l.apartmentId))]; + const renterIds = [...new Set(leases.map((l) => l.renterId))]; + + const [buildings, floors, apartments, renters] = await Promise.all([ + this.prisma.building.findMany({ + where: { id: { in: buildingIds }, orgId }, + select: { id: true, name: true }, + }), + this.prisma.floor.findMany({ + where: { id: { in: floorIds }, orgId }, + select: { id: true, name: true }, + }), + this.prisma.apartment.findMany({ + where: { id: { in: apartmentIds }, orgId }, + select: { id: true, unitNumber: true }, + }), + this.prisma.renter.findMany({ + where: { id: { in: renterIds }, orgId }, + select: { id: true, fullName: true }, + }), + ]); + + const buildingNameById = new Map(buildings.map((b) => [b.id, b.name])); + const floorNameById = new Map(floors.map((f) => [f.id, f.name])); + const unitNumberById = new Map( + apartments.map((a) => [a.id, a.unitNumber]), + ); + const renterNameById = new Map(renters.map((r) => [r.id, r.fullName])); + + return { + data: leases.map((lease) => ({ + ...this.formatLease(lease), + buildingName: buildingNameById.get(lease.buildingId) ?? '', + floorName: floorNameById.get(lease.floorId) ?? '', + unitNumber: unitNumberById.get(lease.apartmentId) ?? '', + renterName: renterNameById.get(lease.renterId) ?? '', + })), + }; + } + async findOne( orgId: string, callerId: string, @@ -330,6 +401,15 @@ export class LeasesService { }); if (!existing) throw new NotFoundException('Lease not found.'); + const invoiceCount = await this.prisma.invoice.count({ + where: { leaseId }, + }); + if (invoiceCount > 0) { + throw new ConflictException( + 'Cannot delete a lease that is referenced by an invoice.', + ); + } + await this.prisma.lease.delete({ where: { id: leaseId } }); await this.timeline.emit({ diff --git a/apps/api/src/modules/notifications/n1-email-e2e.spec.ts b/apps/api/src/modules/notifications/n1-email-e2e.spec.ts new file mode 100644 index 00000000..d55ff8fd --- /dev/null +++ b/apps/api/src/modules/notifications/n1-email-e2e.spec.ts @@ -0,0 +1,199 @@ +/** + * Sprint N1 — deterministic runtime evidence that the notification EMAIL step + * actually delivers over SMTP to mailpit. + * + * Guarded: only runs when N1_E2E=1 (needs a real SMTP server), so the normal + * `jest` suite skips it. It drives the REAL delivery code end-to-end over a REAL + * SMTP transport: + * + * NotificationEmailService.deliver (flag gate + KC `sub`→email + best-effort) + * → MailService.sendMail → SMTP → mailpit + * + * Only KeycloakAdminService.getUser is stubbed (KC `sub`→email resolution is + * unit-proven in notification-email.service.spec.ts). The BullMQ enqueue → + * worker → persist half is covered by notifications.processor.spec.ts / + * notifications.service.spec.ts and was additionally exercised live against the + * real queue + DB during this sprint (see the checkpoint notes); this spec keeps + * the automated check deterministic by isolating the one thing that needs a real + * server: the SMTP send landing in an inbox. + * + * Requires isolated, additive infra (see the run command below): + * - mailpit on 127.0.0.1:2025 SMTP / :8126 API+UI (fm-n1-mailpit) + * + * Run: + * N1_E2E=1 SMTP_PORT=2025 MAILPIT_API=http://127.0.0.1:8126 \ + * npx jest n1-email-e2e --runInBand --forceExit + */ +import 'reflect-metadata'; +import { INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { ConfigModule } from '@nestjs/config'; +import { mailConfig } from '@/config/mail.config'; +import { KeycloakAdminService } from '@/infrastructure/keycloak/keycloak-admin.service'; +import { MailService } from '@/infrastructure/mail/mail.service'; +import { NotificationEmailService } from './notification-email.service'; +import { NotificationJobData } from './notifications.constants'; + +const RUN = process.env.N1_E2E === '1'; +const d = RUN ? describe : describe.skip; + +const SMTP_PORT = Number(process.env.SMTP_PORT ?? 2025); +const MAILPIT_API = process.env.MAILPIT_API ?? 'http://127.0.0.1:8126'; + +interface MailpitMessage { + ID: string; + From: { Address: string; Name: string }; + To: { Address: string; Name: string }[]; + Subject: string; + Snippet: string; + Created: string; +} + +async function mailpitMessages(): Promise { + const res = await fetch(`${MAILPIT_API}/api/v1/messages`); + const body = (await res.json()) as { messages: MailpitMessage[] }; + return body.messages; +} + +async function poll( + fn: () => Promise, + { timeoutMs = 15000, intervalMs = 400 } = {}, +): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const result = await fn(); + if (result) return result; + if (Date.now() > deadline) throw new Error('poll timed out'); + await new Promise((r) => setTimeout(r, intervalMs)); + } +} + +/** + * Build a NotificationEmailService with a real MailService (→ mailpit) and a + * stub Keycloak. `enabled` toggles the NOTIFICATIONS_EMAIL flag so both the + * on and off paths are exercised against the same real transport. + */ +async function buildApp( + enabled: boolean, + recipientEmail: string | null, +): Promise { + process.env.NOTIFICATIONS_EMAIL = enabled ? 'on' : 'off'; + process.env.SMTP_HOST = '127.0.0.1'; + process.env.SMTP_PORT = String(SMTP_PORT); + + const moduleRef = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + ignoreEnvFile: true, + load: [mailConfig], + }), + ], + providers: [ + NotificationEmailService, + MailService, + { + provide: KeycloakAdminService, + useValue: { + getUser: jest.fn(async (sub: string) => + recipientEmail ? { id: sub, email: recipientEmail } : { id: sub }, + ), + }, + }, + ], + }).compile(); + + const app = moduleRef.createNestApplication(); + await app.init(); + return app; +} + +function makeJob(userId: string): NotificationJobData { + return { + orgId: 'n1-e2e-org', + userId, + type: 'support_ticket.acknowledged', + title: 'Support ticket received', + body: "We've received your support ticket and will follow up shortly.", + data: { ticketId: 'n1-e2e-ticket' }, + }; +} + +d('Sprint N1 — notification email delivery (real SMTP → mailpit)', () => { + it('delivers the notification as an email to mailpit when the flag is on', async () => { + const recipient = `tenant.on.${Date.now()}@example.com`; + const app = await buildApp(true, recipient); + try { + const svc = app.get(NotificationEmailService); + const job = makeJob(`n1-e2e-on-${Date.now()}`); + + await svc.deliver(job); // real gate + KC stub + MailService + SMTP + + const msg = await poll(async () => { + const messages = await mailpitMessages(); + return ( + messages.find( + (m) => + m.Subject === job.title && + m.To.some((t) => t.Address === recipient), + ) ?? null + ); + }); + + expect(msg.To[0].Address).toBe(recipient); + expect(msg.Subject).toBe(job.title); + expect(msg.Snippet).toContain('received your support ticket'); + + // eslint-disable-next-line no-console + console.log( + '\n=== N1 EVIDENCE: mailpit message ===\n' + + JSON.stringify( + { + id: msg.ID, + from: msg.From.Address, + to: msg.To.map((t) => t.Address), + subject: msg.Subject, + snippet: msg.Snippet, + }, + null, + 2, + ) + + '\n====================================\n', + ); + } finally { + await app.close(); + } + }, 30000); + + it('sends NO email when NOTIFICATIONS_EMAIL is off (flag gate)', async () => { + const recipient = `tenant.off.${Date.now()}@example.com`; + const app = await buildApp(false, recipient); + try { + const kc = app.get<{ getUser: jest.Mock }>(KeycloakAdminService); + const svc = app.get(NotificationEmailService); + await svc.deliver(makeJob(`n1-e2e-off-${Date.now()}`)); + + // Off = zero work: no Keycloak lookup, and nothing lands in mailpit. + expect(kc.getUser).not.toHaveBeenCalled(); + await new Promise((r) => setTimeout(r, 1500)); + const messages = await mailpitMessages(); + expect(messages.some((m) => m.To.some((t) => t.Address === recipient))).toBe( + false, + ); + } finally { + await app.close(); + } + }, 30000); + + it('best-effort: a recipient with no email on record sends nothing and never throws', async () => { + const app = await buildApp(true, null); // KC returns a user without an email + try { + const svc = app.get(NotificationEmailService); + await expect( + svc.deliver(makeJob(`n1-e2e-noemail-${Date.now()}`)), + ).resolves.toBeUndefined(); + } finally { + await app.close(); + } + }, 30000); +}); diff --git a/apps/api/src/modules/notifications/notification-email.service.spec.ts b/apps/api/src/modules/notifications/notification-email.service.spec.ts new file mode 100644 index 00000000..3ab55a25 --- /dev/null +++ b/apps/api/src/modules/notifications/notification-email.service.spec.ts @@ -0,0 +1,107 @@ +import { NotificationEmailService } from './notification-email.service'; +import { NotificationJobData } from './notifications.constants'; + +describe('NotificationEmailService', () => { + const job: NotificationJobData = { + orgId: 'org-1', + userId: 'sub-123', + type: 'support_ticket.acknowledged', + title: 'Support ticket received', + body: "We've received your ticket.", + data: { ticketId: 't-1' }, + }; + + function makeService( + opts: { enabled: boolean; email?: unknown } = { enabled: true }, + ) { + const config: any = { + // Only `mail.enabled` is read by this service. + get: jest.fn((key: string) => + key === 'mail.enabled' ? opts.enabled : undefined, + ), + }; + const keycloak: any = { + getUser: jest + .fn() + .mockResolvedValue( + 'email' in opts + ? { id: job.userId, email: opts.email } + : { id: job.userId, email: 'tenant@example.com' }, + ), + }; + const mail: any = { sendMail: jest.fn().mockResolvedValue(undefined) }; + const service = new NotificationEmailService(config, keycloak, mail); + return { service, config, keycloak, mail }; + } + + describe('flag gating (NOTIFICATIONS_EMAIL)', () => { + it('does no work when disabled — no Keycloak lookup, no send', async () => { + const { service, keycloak, mail } = makeService({ enabled: false }); + await service.deliver(job); + expect(keycloak.getUser).not.toHaveBeenCalled(); + expect(mail.sendMail).not.toHaveBeenCalled(); + }); + + it('resolves the recipient and sends when enabled', async () => { + const { service, keycloak, mail } = makeService({ + enabled: true, + email: 'tenant@example.com', + }); + await service.deliver(job); + expect(keycloak.getUser).toHaveBeenCalledWith('sub-123'); + expect(mail.sendMail).toHaveBeenCalledWith({ + to: 'tenant@example.com', + subject: 'Support ticket received', + text: "We've received your ticket.", + }); + }); + + it('falls back to the title as body text when the job has no body', async () => { + const { service, mail } = makeService({ + enabled: true, + email: 'a@b.com', + }); + await service.deliver({ ...job, body: undefined }); + expect(mail.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ text: 'Support ticket received' }), + ); + }); + }); + + describe('best-effort (never throws, never blocks the in-app notification)', () => { + it('skips sending when the recipient has no email on record', async () => { + const { service, mail } = makeService({ + enabled: true, + email: undefined, + }); + await expect(service.deliver(job)).resolves.toBeUndefined(); + expect(mail.sendMail).not.toHaveBeenCalled(); + }); + + it('does not throw when Keycloak lookup fails', async () => { + const { service, mail } = makeService({ enabled: true }); + service['keycloak'].getUser = jest + .fn() + .mockRejectedValue(new Error('keycloak down')); + await expect(service.deliver(job)).resolves.toBeUndefined(); + expect(mail.sendMail).not.toHaveBeenCalled(); + }); + + it('does not throw when SMTP send fails', async () => { + const { service, mail } = makeService({ + enabled: true, + email: 'a@b.com', + }); + mail.sendMail.mockRejectedValue(new Error('ECONNREFUSED 1025')); + await expect(service.deliver(job)).resolves.toBeUndefined(); + expect(mail.sendMail).toHaveBeenCalled(); + }); + + it('treats a null Keycloak user as no email (no send, no throw)', async () => { + const { service, keycloak, mail } = makeService({ enabled: true }); + keycloak.getUser.mockResolvedValue(null); + await expect(service.deliver(job)).resolves.toBeUndefined(); + expect(mail.sendMail).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/api/src/modules/notifications/notification-email.service.ts b/apps/api/src/modules/notifications/notification-email.service.ts new file mode 100644 index 00000000..35bca359 --- /dev/null +++ b/apps/api/src/modules/notifications/notification-email.service.ts @@ -0,0 +1,63 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { KeycloakAdminService } from '@/infrastructure/keycloak/keycloak-admin.service'; +import { MailService } from '@/infrastructure/mail/mail.service'; +import { NotificationJobData } from './notifications.constants'; + +/** + * Delivers a notification as an email — the "email step" of the notifications + * worker. It is deliberately BEST-EFFORT and never throws: the in-app + * Notification row is the source of truth, so a missing recipient email, an + * unreachable Keycloak, or an SMTP failure must neither fail the queue job nor + * block the in-app notification (mirrors TimelineService.emit / + * NotificationsService.enqueue). + * + * Gated behind NOTIFICATIONS_EMAIL=on (config `mail.enabled`), which defaults + * OFF so the app runs without an SMTP server (e.g. when mailpit is absent). + */ +@Injectable() +export class NotificationEmailService { + private readonly logger = new Logger(NotificationEmailService.name); + + constructor( + private readonly config: ConfigService, + private readonly keycloak: KeycloakAdminService, + private readonly mail: MailService, + ) {} + + async deliver(job: NotificationJobData): Promise { + // Flag gate first — when off we do no work at all (no KC lookup, no SMTP). + if (!this.config.get('mail.enabled')) { + return; + } + + try { + const email = await this.resolveEmail(job.userId); + if (!email) { + this.logger.warn( + `No email on record for ${job.userId}; skipping email for '${job.type}'.`, + ); + return; + } + + await this.mail.sendMail({ + to: email, + subject: job.title, + text: job.body ?? job.title, + }); + this.logger.debug(`Emailed notification '${job.type}' to ${email}`); + } catch (error) { + // Best-effort: swallow so the in-app notification still succeeds. + this.logger.error( + `Failed to email notification '${job.type}' to ${job.userId}: ${String(error)}`, + ); + } + } + + /** Resolve a recipient's email from Keycloak by `sub`. */ + private async resolveEmail(sub: string): Promise { + const user = await this.keycloak.getUser(sub); + const email = user?.['email']; + return typeof email === 'string' && email.length > 0 ? email : null; + } +} diff --git a/apps/api/src/modules/notifications/notifications-dead-letter.service.spec.ts b/apps/api/src/modules/notifications/notifications-dead-letter.service.spec.ts new file mode 100644 index 00000000..04a500d9 --- /dev/null +++ b/apps/api/src/modules/notifications/notifications-dead-letter.service.spec.ts @@ -0,0 +1,96 @@ +import { NotificationsDeadLetterService } from './notifications-dead-letter.service'; +import { + DEAD_LETTER_JOB, + NotificationJobData, +} from './notifications.constants'; + +const payload: NotificationJobData = { + orgId: 'org-1', + userId: 'sub-123', + type: 'support_ticket.acknowledged', + title: 'Support ticket received', +}; + +function makeService( + job: { attemptsMade: number; attempts: number; data?: NotificationJobData } | null, +) { + const queue: any = { + getJob: jest.fn().mockResolvedValue( + job === null + ? undefined + : { + data: job.data ?? payload, + attemptsMade: job.attemptsMade, + opts: { attempts: job.attempts }, + }, + ), + }; + const deadLetter: any = { add: jest.fn().mockResolvedValue(undefined) }; + const config: any = { get: jest.fn(() => 'redis://127.0.0.1:6380') }; + const service = new NotificationsDeadLetterService(queue, deadLetter, config); + return { service, queue, deadLetter }; +} + +describe('NotificationsDeadLetterService.handleFailed', () => { + it('dead-letters a job that has exhausted all attempts', async () => { + const { service, queue, deadLetter } = makeService({ + attemptsMade: 3, + attempts: 3, + }); + await service.handleFailed('job-1', 'SMTP exploded'); + + expect(queue.getJob).toHaveBeenCalledWith('job-1'); + expect(deadLetter.add).toHaveBeenCalledTimes(1); + expect(deadLetter.add).toHaveBeenCalledWith( + DEAD_LETTER_JOB, + { + payload, + originalJobId: 'job-1', + failedReason: 'SMTP exploded', + attemptsMade: 3, + }, + // must persist — the DLQ has no worker, so jobs must not be auto-removed + { removeOnComplete: false, removeOnFail: false }, + ); + }); + + it('does NOT dead-letter while retry attempts remain', async () => { + const { service, deadLetter } = makeService({ + attemptsMade: 1, + attempts: 3, + }); + await service.handleFailed('job-1', 'transient'); + expect(deadLetter.add).not.toHaveBeenCalled(); + }); + + it('does not dead-letter (and never throws) when the job is gone', async () => { + const { service, deadLetter } = makeService(null); + await expect( + service.handleFailed('missing', 'reason'), + ).resolves.toBeUndefined(); + expect(deadLetter.add).not.toHaveBeenCalled(); + }); + + it('swallows errors from the queue lookup (never throws from the event handler)', async () => { + const { service, queue, deadLetter } = makeService({ + attemptsMade: 3, + attempts: 3, + }); + queue.getJob.mockRejectedValue(new Error('redis down')); + await expect( + service.handleFailed('job-1', 'reason'), + ).resolves.toBeUndefined(); + expect(deadLetter.add).not.toHaveBeenCalled(); + }); + + it('swallows errors from the dead-letter add (best-effort)', async () => { + const { service, deadLetter } = makeService({ + attemptsMade: 3, + attempts: 3, + }); + deadLetter.add.mockRejectedValue(new Error('dlq unavailable')); + await expect( + service.handleFailed('job-1', 'reason'), + ).resolves.toBeUndefined(); + }); +}); diff --git a/apps/api/src/modules/notifications/notifications-dead-letter.service.ts b/apps/api/src/modules/notifications/notifications-dead-letter.service.ts new file mode 100644 index 00000000..18ec5611 --- /dev/null +++ b/apps/api/src/modules/notifications/notifications-dead-letter.service.ts @@ -0,0 +1,109 @@ +import { InjectQueue } from '@nestjs/bullmq'; +import { + Injectable, + Logger, + OnModuleDestroy, + OnModuleInit, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Queue, QueueEvents } from 'bullmq'; +import { buildRedisConnection, DEFAULT_REDIS_URL } from '@/config/redis.config'; +import { + DEAD_LETTER_JOB, + DeadLetterJobData, + NOTIFICATIONS_DEAD_LETTER_QUEUE, + NOTIFICATIONS_QUEUE, +} from './notifications.constants'; + +/** + * Dead-letter / alert path for the notifications queue. + * + * Subscribes to queue-level `failed` events (via a dedicated {@link QueueEvents} + * connection — the recommended pattern, since QueueEvents uses blocking Redis + * commands). Every failure is logged; a job that has exhausted all its retry + * attempts is additionally copied onto the dead-letter queue so ops can inspect + * or replay it, rather than it silently disappearing after `removeOnFail`. + * + * Entirely additive and best-effort: it observes the existing queue and never + * changes the enqueue/persist contract, and it never throws from the event + * handler (a bad Redis moment must not crash the app). + */ +@Injectable() +export class NotificationsDeadLetterService + implements OnModuleInit, OnModuleDestroy +{ + private readonly logger = new Logger(NotificationsDeadLetterService.name); + private queueEvents?: QueueEvents; + + constructor( + @InjectQueue(NOTIFICATIONS_QUEUE) private readonly queue: Queue, + @InjectQueue(NOTIFICATIONS_DEAD_LETTER_QUEUE) + private readonly deadLetter: Queue, + private readonly config: ConfigService, + ) {} + + onModuleInit(): void { + const url = this.config.get('redis.url') ?? DEFAULT_REDIS_URL; + this.queueEvents = new QueueEvents(NOTIFICATIONS_QUEUE, { + connection: buildRedisConnection(url), + }); + + this.queueEvents.on('failed', ({ jobId, failedReason }) => { + void this.handleFailed(jobId, failedReason); + }); + + // Surface (but never rethrow) the listener's own connection errors so a + // Redis outage is visible in logs without taking the process down. + this.queueEvents.on('error', (error) => { + this.logger.error( + `notifications QueueEvents connection error: ${String(error)}`, + ); + }); + } + + async onModuleDestroy(): Promise { + try { + await this.queueEvents?.close(); + } catch { + // shutting down — nothing actionable + } + } + + /** + * Handle a single `failed` event. Public so it can be exercised directly in + * tests without a live Redis. Never throws. + */ + async handleFailed(jobId: string, failedReason: string): Promise { + try { + const job = await this.queue.getJob(jobId); + const attemptsMade = job?.attemptsMade ?? 0; + const maxAttempts = job?.opts?.attempts ?? 1; + const terminal = attemptsMade >= maxAttempts; + + const line = `notification job ${jobId} failed (attempt ${attemptsMade}/${maxAttempts}): ${failedReason}`; + if (terminal) { + this.logger.error(`${line} — moving to dead-letter queue`); + } else { + this.logger.warn(`${line} — will retry`); + } + + if (!terminal || !job) return; + + const deadLetter: DeadLetterJobData = { + payload: job.data, + originalJobId: jobId, + failedReason, + attemptsMade, + }; + // Keep the record durably; the DLQ has no worker, so it stays `waiting`. + await this.deadLetter.add(DEAD_LETTER_JOB, deadLetter, { + removeOnComplete: false, + removeOnFail: false, + }); + } catch (error) { + this.logger.error( + `dead-letter handling failed for job ${jobId}: ${String(error)}`, + ); + } + } +} diff --git a/apps/api/src/modules/notifications/notifications.constants.ts b/apps/api/src/modules/notifications/notifications.constants.ts new file mode 100644 index 00000000..30ce807e --- /dev/null +++ b/apps/api/src/modules/notifications/notifications.constants.ts @@ -0,0 +1,45 @@ +/** Name of the BullMQ queue that delivers in-app notifications. */ +export const NOTIFICATIONS_QUEUE = 'notifications'; + +/** + * Dead-letter queue. Notification jobs that exhaust all retry attempts are + * copied here (payload + failure reason) so a repeatedly-failing delivery lands + * somewhere actionable for ops instead of vanishing. It has no worker — jobs + * sit durably as `waiting` for inspection / manual replay. + */ +export const NOTIFICATIONS_DEAD_LETTER_QUEUE = 'notifications-dead-letter'; + +/** Job name used when enqueuing a delivery job. */ +export const DELIVER_NOTIFICATION_JOB = 'deliver'; + +/** Job name used when copying a terminally-failed job to the dead-letter queue. */ +export const DEAD_LETTER_JOB = 'dead-letter'; + +/** Payload stored on the dead-letter queue for a terminally-failed job. */ +export interface DeadLetterJobData { + /** The original notification payload that failed to deliver. */ + payload: NotificationJobData; + /** BullMQ id of the original (failed) job. */ + originalJobId: string; + /** Last failure reason reported by BullMQ. */ + failedReason: string; + /** How many attempts were made before giving up. */ + attemptsMade: number; +} + +/** + * Payload enqueued onto the notifications queue. The worker persists it as a + * Notification row (and, in future, fans out to email / push). + */ +export interface NotificationJobData { + /** Organisation the notification belongs to. */ + orgId: string; + /** Recipient — a Keycloak `sub`. */ + userId: string; + /** Machine-readable key, e.g. 'support_ticket.acknowledged'. */ + type: string; + title: string; + body?: string; + /** Small JSON payload for deep-linking (e.g. { ticketId }). */ + data?: Record; +} diff --git a/apps/api/src/modules/notifications/notifications.controller.ts b/apps/api/src/modules/notifications/notifications.controller.ts new file mode 100644 index 00000000..f2a6e6a9 --- /dev/null +++ b/apps/api/src/modules/notifications/notifications.controller.ts @@ -0,0 +1,45 @@ +import { Controller, Get, Param, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@/common/decorators'; +import { AuthenticatedUser } from '@/common/types/authenticated-user.type'; +import { OrgScopeService } from '@/common/org-scope/org-scope.service'; +import { NotificationsService } from './notifications.service'; + +@ApiTags('notifications') +@ApiBearerAuth() +@Controller('notifications') +export class NotificationsController { + constructor( + private readonly notifications: NotificationsService, + private readonly orgScope: OrgScopeService, + ) {} + + /** List the caller's notifications plus their current unread count. */ + @Get() + async list(@CurrentUser() user: AuthenticatedUser) { + const { orgId } = await this.orgScope.resolveForCaller(user); + return this.notifications.listForUser(orgId, user.sub); + } + + /** Lightweight badge endpoint for the header bell. */ + @Get('unread-count') + async unreadCount(@CurrentUser() user: AuthenticatedUser) { + const { orgId } = await this.orgScope.resolveForCaller(user); + return this.notifications.unreadCount(orgId, user.sub); + } + + @Post('read-all') + async markAllRead(@CurrentUser() user: AuthenticatedUser) { + const { orgId } = await this.orgScope.resolveForCaller(user); + return this.notifications.markAllRead(orgId, user.sub); + } + + @Post(':id/read') + async markRead( + @CurrentUser() user: AuthenticatedUser, + @Param('id') id: string, + ) { + const { orgId } = await this.orgScope.resolveForCaller(user); + return this.notifications.markRead(orgId, user.sub, id); + } +} diff --git a/apps/api/src/modules/notifications/notifications.module.ts b/apps/api/src/modules/notifications/notifications.module.ts new file mode 100644 index 00000000..85d6926b --- /dev/null +++ b/apps/api/src/modules/notifications/notifications.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bullmq'; +import { MailModule } from '@/infrastructure/mail/mail.module'; +import { NotificationsController } from './notifications.controller'; +import { NotificationsService } from './notifications.service'; +import { NotificationsProcessor } from './notifications.processor'; +import { NotificationsDeadLetterService } from './notifications-dead-letter.service'; +import { NotificationEmailService } from './notification-email.service'; +import { + NOTIFICATIONS_DEAD_LETTER_QUEUE, + NOTIFICATIONS_QUEUE, +} from './notifications.constants'; + +@Module({ + imports: [ + BullModule.registerQueue( + { name: NOTIFICATIONS_QUEUE }, + { name: NOTIFICATIONS_DEAD_LETTER_QUEUE }, + ), + MailModule, + ], + controllers: [NotificationsController], + providers: [ + NotificationsService, + NotificationsProcessor, + NotificationsDeadLetterService, + NotificationEmailService, + ], + exports: [NotificationsService], +}) +export class NotificationsModule {} diff --git a/apps/api/src/modules/notifications/notifications.processor.spec.ts b/apps/api/src/modules/notifications/notifications.processor.spec.ts new file mode 100644 index 00000000..3a7d25a4 --- /dev/null +++ b/apps/api/src/modules/notifications/notifications.processor.spec.ts @@ -0,0 +1,42 @@ +import { Job } from 'bullmq'; +import { NotificationsProcessor } from './notifications.processor'; +import { NotificationJobData } from './notifications.constants'; + +describe('NotificationsProcessor', () => { + const jobData: NotificationJobData = { + orgId: 'org-1', + userId: 'sub-123', + type: 'support_ticket.acknowledged', + title: 'Support ticket received', + body: 'We got it.', + }; + + function makeProcessor() { + const notifications: any = { + persist: jest.fn().mockResolvedValue(undefined), + }; + const email: any = { deliver: jest.fn().mockResolvedValue(undefined) }; + const processor = new NotificationsProcessor(notifications, email); + return { processor, notifications, email }; + } + + it('persists the in-app notification and runs the email step', async () => { + const { processor, notifications, email } = makeProcessor(); + await processor.process({ data: jobData } as Job); + expect(notifications.persist).toHaveBeenCalledWith(jobData); + expect(email.deliver).toHaveBeenCalledWith(jobData); + }); + + it('persists BEFORE attempting email so an email issue cannot lose the notification', async () => { + const { processor, notifications, email } = makeProcessor(); + const order: string[] = []; + notifications.persist.mockImplementation(async () => { + order.push('persist'); + }); + email.deliver.mockImplementation(async () => { + order.push('email'); + }); + await processor.process({ data: jobData } as Job); + expect(order).toEqual(['persist', 'email']); + }); +}); diff --git a/apps/api/src/modules/notifications/notifications.processor.ts b/apps/api/src/modules/notifications/notifications.processor.ts new file mode 100644 index 00000000..a9bce3fb --- /dev/null +++ b/apps/api/src/modules/notifications/notifications.processor.ts @@ -0,0 +1,36 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Logger } from '@nestjs/common'; +import { Job } from 'bullmq'; +import { + NOTIFICATIONS_QUEUE, + NotificationJobData, +} from './notifications.constants'; +import { NotificationsService } from './notifications.service'; +import { NotificationEmailService } from './notification-email.service'; + +/** + * Consumes the notifications queue and delivers each job: + * 1. persist an in-app Notification row (source of truth — must always happen); + * 2. best-effort email delivery (gated by NOTIFICATIONS_EMAIL, never throws). + * Push can be added the same way without touching producers. + */ +@Processor(NOTIFICATIONS_QUEUE) +export class NotificationsProcessor extends WorkerHost { + private readonly logger = new Logger(NotificationsProcessor.name); + + constructor( + private readonly notifications: NotificationsService, + private readonly email: NotificationEmailService, + ) { + super(); + } + + async process(job: Job): Promise { + // In-app first so a downstream email issue can never lose the notification. + await this.notifications.persist(job.data); + await this.email.deliver(job.data); + this.logger.debug( + `Delivered notification '${job.data.type}' to ${job.data.userId}`, + ); + } +} diff --git a/apps/api/src/modules/notifications/notifications.service.spec.ts b/apps/api/src/modules/notifications/notifications.service.spec.ts new file mode 100644 index 00000000..a028fe5d --- /dev/null +++ b/apps/api/src/modules/notifications/notifications.service.spec.ts @@ -0,0 +1,138 @@ +import { NotFoundException } from '@nestjs/common'; +import { NotificationsService } from './notifications.service'; + +describe('NotificationsService', () => { + const orgId = 'org-1'; + const userId = 'user-1'; + + function makeService( + overrides: { + notification?: Partial>; + queue?: Partial>; + } = {}, + ) { + const prisma: any = { + notification: { + create: jest.fn().mockResolvedValue(undefined), + findFirst: jest.fn().mockResolvedValue(null), + findMany: jest.fn().mockResolvedValue([]), + update: jest.fn(), + updateMany: jest.fn().mockResolvedValue({ count: 0 }), + count: jest.fn().mockResolvedValue(0), + ...overrides.notification, + }, + }; + const queue: any = { + add: jest.fn().mockResolvedValue(undefined), + ...overrides.queue, + }; + const service = new NotificationsService(queue, prisma); + return { service, prisma, queue }; + } + + const notificationRow = (o: Partial> = {}) => ({ + id: 'notif-1', + orgId, + userId, + type: 'support_ticket.acknowledged', + title: 'Support ticket received', + body: 'We got it.', + data: { ticketId: 't-1' }, + readAt: null, + createdAt: new Date('2026-07-17T00:00:00.000Z'), + ...o, + }); + + describe('enqueue', () => { + it('adds a delivery job to the queue', async () => { + const { service, queue } = makeService(); + await service.enqueue({ orgId, userId, type: 'x', title: 'T' }); + expect(queue.add).toHaveBeenCalledWith( + 'deliver', + expect.objectContaining({ orgId, userId, type: 'x', title: 'T' }), + expect.any(Object), + ); + }); + + it('never throws when the queue is unavailable (best-effort delivery)', async () => { + const { service } = makeService({ + queue: { add: jest.fn().mockRejectedValue(new Error('ECONNREFUSED')) }, + }); + await expect( + service.enqueue({ orgId, userId, type: 'x', title: 'T' }), + ).resolves.toBeUndefined(); + }); + }); + + describe('persist', () => { + it('writes a notification row from a job', async () => { + const { service, prisma } = makeService(); + await service.persist({ + orgId, + userId, + type: 'support_ticket.acknowledged', + title: 'T', + body: 'B', + data: { ticketId: 't-1' }, + }); + expect(prisma.notification.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + orgId, + userId, + type: 'support_ticket.acknowledged', + title: 'T', + body: 'B', + }), + }); + }); + }); + + describe('listForUser', () => { + it('returns the caller notifications with unread count', async () => { + const { service } = makeService({ + notification: { + findMany: jest.fn().mockResolvedValue([notificationRow()]), + count: jest.fn().mockResolvedValue(1), + }, + }); + const result = await service.listForUser(orgId, userId); + expect(result.unreadCount).toBe(1); + expect(result.data).toHaveLength(1); + expect(result.data[0]).toMatchObject({ id: 'notif-1', readAt: null }); + }); + }); + + describe('markRead', () => { + it('throws when the notification is not found or not the caller', async () => { + const { service } = makeService(); + await expect(service.markRead(orgId, userId, 'missing')).rejects.toThrow( + NotFoundException, + ); + }); + + it('sets readAt when currently unread', async () => { + const updated = notificationRow({ readAt: new Date() }); + const { service, prisma } = makeService({ + notification: { + findFirst: jest.fn().mockResolvedValue(notificationRow()), + update: jest.fn().mockResolvedValue(updated), + }, + }); + const result = await service.markRead(orgId, userId, 'notif-1'); + expect(prisma.notification.update).toHaveBeenCalled(); + expect(result.data.readAt).not.toBeNull(); + }); + }); + + describe('markAllRead', () => { + it('returns the number of notifications marked read', async () => { + const { service } = makeService({ + notification: { + updateMany: jest.fn().mockResolvedValue({ count: 3 }), + }, + }); + const result = await service.markAllRead(orgId, userId); + expect(result).toEqual({ count: 3 }); + }); + }); +}); diff --git a/apps/api/src/modules/notifications/notifications.service.ts b/apps/api/src/modules/notifications/notifications.service.ts new file mode 100644 index 00000000..cc1d0870 --- /dev/null +++ b/apps/api/src/modules/notifications/notifications.service.ts @@ -0,0 +1,139 @@ +import { InjectQueue } from '@nestjs/bullmq'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Queue } from 'bullmq'; +import { Prisma } from '@repo/db'; +import { PrismaService } from '@/infrastructure/prisma/prisma.service'; +import { NotificationResponse } from '@repo/contracts'; +import { + DELIVER_NOTIFICATION_JOB, + NOTIFICATIONS_QUEUE, + NotificationJobData, +} from './notifications.constants'; + +type NotificationRow = { + id: string; + orgId: string; + userId: string; + type: string; + title: string; + body: string | null; + data: Prisma.JsonValue; + readAt: Date | null; + createdAt: Date; +}; + +@Injectable() +export class NotificationsService { + private readonly logger = new Logger(NotificationsService.name); + + constructor( + @InjectQueue(NOTIFICATIONS_QUEUE) private readonly queue: Queue, + private readonly prisma: PrismaService, + ) {} + + /** + * Best-effort enqueue of a notification for asynchronous delivery. Never + * throws: delivery is decoupled from the caller, so a Redis hiccup must not + * fail the originating request (mirrors TimelineService.emit). + */ + async enqueue(job: NotificationJobData): Promise { + try { + await this.queue.add(DELIVER_NOTIFICATION_JOB, job, { + attempts: 3, + backoff: { type: 'exponential', delay: 2000 }, + removeOnComplete: 1000, + removeOnFail: 500, + }); + } catch (error) { + this.logger.error( + `Failed to enqueue notification '${job.type}' for ${job.userId}: ${String(error)}`, + ); + } + } + + /** Persist a notification row — invoked by the queue worker. */ + async persist(job: NotificationJobData): Promise { + await this.prisma.notification.create({ + data: { + orgId: job.orgId, + userId: job.userId, + type: job.type, + title: job.title, + body: job.body ?? null, + data: (job.data ?? {}) as Prisma.InputJsonValue, + }, + }); + } + + private format(n: NotificationRow): NotificationResponse { + return { + id: n.id, + orgId: n.orgId, + userId: n.userId, + type: n.type, + title: n.title, + body: n.body, + data: (n.data ?? {}) as Record, + readAt: n.readAt ? n.readAt.toISOString() : null, + createdAt: n.createdAt.toISOString(), + }; + } + + async listForUser( + orgId: string, + userId: string, + ): Promise<{ data: NotificationResponse[]; unreadCount: number }> { + const [items, unreadCount] = await Promise.all([ + this.prisma.notification.findMany({ + where: { orgId, userId }, + orderBy: { createdAt: 'desc' }, + take: 50, + }), + this.prisma.notification.count({ + where: { orgId, userId, readAt: null }, + }), + ]); + return { data: items.map((i) => this.format(i)), unreadCount }; + } + + async unreadCount( + orgId: string, + userId: string, + ): Promise<{ unreadCount: number }> { + const unreadCount = await this.prisma.notification.count({ + where: { orgId, userId, readAt: null }, + }); + return { unreadCount }; + } + + async markRead( + orgId: string, + userId: string, + id: string, + ): Promise<{ data: NotificationResponse }> { + const existing = await this.prisma.notification.findFirst({ + where: { id, orgId, userId }, + }); + if (!existing) throw new NotFoundException('Notification not found.'); + + const updated = existing.readAt + ? existing + : await this.prisma.notification.update({ + where: { id }, + data: { readAt: new Date() }, + }); + + return { data: this.format(updated) }; + } + + async markAllRead( + orgId: string, + userId: string, + ): Promise<{ count: number }> { + const result = await this.prisma.notification.updateMany({ + where: { orgId, userId, readAt: null }, + data: { readAt: new Date() }, + }); + return { count: result.count }; + } +} diff --git a/apps/api/src/modules/notifications/o1-deadletter-e2e.spec.ts b/apps/api/src/modules/notifications/o1-deadletter-e2e.spec.ts new file mode 100644 index 00000000..5611f63f --- /dev/null +++ b/apps/api/src/modules/notifications/o1-deadletter-e2e.spec.ts @@ -0,0 +1,118 @@ +import { Queue, Worker } from 'bullmq'; +import { buildRedisConnection } from '@/config/redis.config'; +import { NotificationsDeadLetterService } from './notifications-dead-letter.service'; +import { + DEAD_LETTER_JOB, + DELIVER_NOTIFICATION_JOB, + DeadLetterJobData, + NOTIFICATIONS_DEAD_LETTER_QUEUE, + NOTIFICATIONS_QUEUE, + NotificationJobData, +} from './notifications.constants'; + +/** + * O1 dead-letter END-TO-END drive (the one O1 piece the previous session left + * UNVERIFIED). Guarded by `O1_E2E=1` so it is SKIPPED in normal `jest` / CI — + * it needs a real, isolated Redis. Drive it with a throwaway instance: + * + * O1_E2E=1 REDIS_URL=redis://127.0.0.1:6401 \ + * npx jest o1-deadletter-e2e --runInBand --forceExit + * + * It exercises the REAL {@link NotificationsDeadLetterService}: a notifications + * job that throws on every attempt must, after exhausting its retries, land on + * the `notifications-dead-letter` queue (which has no worker) carrying the + * original payload + failure reason. No mocks — real BullMQ Queue/Worker + + * QueueEvents against real Redis. + */ +const RUN = process.env.O1_E2E === '1'; +const URL = process.env.REDIS_URL ?? 'redis://127.0.0.1:6401'; +const connection = buildRedisConnection(URL); + +const payload: NotificationJobData = { + orgId: 'o1-e2e-org', + userId: 'o1-e2e-sub', + type: 'support_ticket.acknowledged', + title: 'Support ticket received', +}; + +/** Minimal ConfigService stand-in — the service only reads `redis.url`. */ +const fakeConfig = { get: () => URL } as never; + +(RUN ? describe : describe.skip)('O1 dead-letter e2e (real Redis)', () => { + let queue: Queue; + let deadLetter: Queue; + let worker: Worker; + let service: NotificationsDeadLetterService; + + beforeAll(async () => { + queue = new Queue(NOTIFICATIONS_QUEUE, { connection }); + deadLetter = new Queue(NOTIFICATIONS_DEAD_LETTER_QUEUE, { connection }); + // Clean slate so a prior run can't mask a real result. + await queue.obliterate({ force: true }).catch(() => undefined); + await deadLetter.obliterate({ force: true }).catch(() => undefined); + + // The REAL service under test — attaches its QueueEvents `failed` listener. + service = new NotificationsDeadLetterService(queue, deadLetter, fakeConfig); + service.onModuleInit(); + + // A worker that ALWAYS fails, so the job exhausts every attempt. + worker = new Worker( + NOTIFICATIONS_QUEUE, + async () => { + throw new Error('boom: simulated permanent delivery failure'); + }, + { connection }, + ); + await worker.waitUntilReady(); + }); + + afterAll(async () => { + await worker?.close(); + await service?.onModuleDestroy(); + await queue?.obliterate({ force: true }).catch(() => undefined); + await deadLetter?.obliterate({ force: true }).catch(() => undefined); + await queue?.close(); + await deadLetter?.close(); + }); + + it('routes a job that exhausts all retries to the dead-letter queue', async () => { + const job = await queue.add(DELIVER_NOTIFICATION_JOB, payload, { + attempts: 3, + backoff: { type: 'fixed', delay: 50 }, + removeOnComplete: false, + removeOnFail: false, + }); + + // Poll the DLQ until the terminally-failed job is copied across. + const deadline = Date.now() + 25_000; + let dlqJobs = await deadLetter.getJobs(['waiting', 'wait', 'paused']); + while (dlqJobs.length === 0 && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 250)); + dlqJobs = await deadLetter.getJobs(['waiting', 'wait', 'paused']); + } + + expect(dlqJobs).toHaveLength(1); + const dl = dlqJobs[0]; + expect(dl.name).toBe(DEAD_LETTER_JOB); + + const data = dl.data as DeadLetterJobData; + expect(data.originalJobId).toBe(job.id); + expect(data.attemptsMade).toBe(3); + expect(data.failedReason).toContain('boom'); + expect(data.payload).toMatchObject({ + orgId: payload.orgId, + userId: payload.userId, + type: payload.type, + title: payload.title, + }); + + // The DLQ has no worker → the job stays put for inspection/replay. + const waitingCount = await deadLetter.getWaitingCount(); + expect(waitingCount).toBe(1); + + // eslint-disable-next-line no-console + console.log( + `[O1-E2E] dead-lettered job ${data.originalJobId} after ${data.attemptsMade} attempts; reason="${data.failedReason}"`, + ); + }, 30_000); +}); diff --git a/apps/api/src/modules/renters/dto/create-renter.dto.ts b/apps/api/src/modules/renters/dto/create-renter.dto.ts index 026587c4..5796350b 100644 --- a/apps/api/src/modules/renters/dto/create-renter.dto.ts +++ b/apps/api/src/modules/renters/dto/create-renter.dto.ts @@ -31,4 +31,13 @@ export class CreateRenterDto { @IsOptional() @IsString() notes?: string; + + @ApiPropertyOptional({ + description: + "Keycloak `sub` of the tenant user to link, enabling their self-service portal. Pass null to unlink.", + nullable: true, + }) + @IsOptional() + @IsString() + renterUserId?: string | null; } diff --git a/apps/api/src/modules/renters/renters.service.ts b/apps/api/src/modules/renters/renters.service.ts index 99c39d3c..6153d222 100644 --- a/apps/api/src/modules/renters/renters.service.ts +++ b/apps/api/src/modules/renters/renters.service.ts @@ -27,6 +27,7 @@ type RenterRow = { emergencyContactName: string | null; emergencyContactPhone: string | null; notes: string | null; + renterUserId: string | null; createdAt: Date; updatedAt: Date; }; @@ -56,6 +57,7 @@ export class RentersService { emergencyContactName: renter.emergencyContactName, emergencyContactPhone: renter.emergencyContactPhone, notes: renter.notes, + renterUserId: renter.renterUserId, effectiveStatus: !mostRecentLease ? 'none' : this.leaseStatus.isEffectivelyActive( @@ -155,6 +157,7 @@ export class RentersService { emergencyContactName: dto.emergencyContactName, emergencyContactPhone: dto.emergencyContactPhone, notes: dto.notes, + renterUserId: dto.renterUserId, }, }); @@ -194,6 +197,9 @@ export class RentersService { emergencyContactPhone: dto.emergencyContactPhone, }), ...(dto.notes !== undefined && { notes: dto.notes }), + ...(dto.renterUserId !== undefined && { + renterUserId: dto.renterUserId, + }), }, }); diff --git a/apps/api/src/modules/reports/reports.controller.ts b/apps/api/src/modules/reports/reports.controller.ts new file mode 100644 index 00000000..6bb55952 --- /dev/null +++ b/apps/api/src/modules/reports/reports.controller.ts @@ -0,0 +1,48 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { ReportsService } from './reports.service'; +import { OrgScopeService } from '@/common/org-scope/org-scope.service'; +import { CurrentUser, Roles } from '@/common/decorators'; +import { AuthenticatedUser } from '@/common/types/authenticated-user.type'; +import { Role } from '@/common/enums'; + +/** + * Read-only financial/operational reports. Locked to org-wide roles + * (org_admin, finance) — supervisor/maintenance/tenant are rejected by the + * RolesGuard, mirroring the FE `reports` permission matrix. + */ +@ApiTags('reports') +@ApiBearerAuth() +@Controller('reports') +@Roles(Role.ORG_ADMIN, Role.FINANCE) +export class ReportsController { + constructor( + private readonly reportsService: ReportsService, + private readonly orgScope: OrgScopeService, + ) {} + + @Get('summary') + async getSummary( + @CurrentUser() user: AuthenticatedUser, + @Query('from') from?: string, + @Query('to') to?: string, + ) { + const { orgId } = await this.orgScope.resolveForCaller(user); + return this.reportsService.getSummary(orgId, { from, to }); + } + + @Get('rent-roll') + async getRentRoll(@CurrentUser() user: AuthenticatedUser) { + const { orgId } = await this.orgScope.resolveForCaller(user); + return this.reportsService.getRentRoll(orgId); + } + + @Get('overdue') + async getOverdue( + @CurrentUser() user: AuthenticatedUser, + @Query('asOf') asOf?: string, + ) { + const { orgId } = await this.orgScope.resolveForCaller(user); + return this.reportsService.getOverdue(orgId, asOf); + } +} diff --git a/apps/api/src/modules/reports/reports.module.ts b/apps/api/src/modules/reports/reports.module.ts new file mode 100644 index 00000000..00595b52 --- /dev/null +++ b/apps/api/src/modules/reports/reports.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ReportsController } from './reports.controller'; +import { ReportsService } from './reports.service'; + +@Module({ + controllers: [ReportsController], + providers: [ReportsService], + exports: [ReportsService], +}) +export class ReportsModule {} diff --git a/apps/api/src/modules/reports/reports.service.spec.ts b/apps/api/src/modules/reports/reports.service.spec.ts new file mode 100644 index 00000000..d904c76a --- /dev/null +++ b/apps/api/src/modules/reports/reports.service.spec.ts @@ -0,0 +1,302 @@ +import { BadRequestException } from '@nestjs/common'; +import { ReportsService } from './reports.service'; + +describe('ReportsService', () => { + const orgId = 'org-1'; + // Fixed clock so month/year window boundaries are deterministic. + const now = new Date('2026-07-17T10:00:00.000Z'); + const MONTH_START = '2026-07-01T00:00:00.000Z'; + const YEAR_START = '2026-01-01T00:00:00.000Z'; + + const decimal = (value: string) => ({ toNumber: () => Number(value) }); + + function makeService( + overrides: { + lease?: Partial>; + apartment?: Partial>; + invoice?: Partial>; + invoicePayment?: Partial>; + expense?: Partial>; + } = {}, + ) { + const prisma: any = { + lease: { + count: jest.fn().mockResolvedValue(0), + findMany: jest.fn().mockResolvedValue([]), + ...overrides.lease, + }, + apartment: { + count: jest.fn().mockResolvedValue(0), + ...overrides.apartment, + }, + invoice: { + findMany: jest.fn().mockResolvedValue([]), + ...overrides.invoice, + }, + invoicePayment: { + aggregate: jest.fn().mockResolvedValue({ _sum: { amount: null } }), + ...overrides.invoicePayment, + }, + expense: { + aggregate: jest.fn().mockResolvedValue({ _sum: { amount: null } }), + ...overrides.expense, + }, + }; + const service = new ReportsService(prisma); + return { service, prisma }; + } + + describe('getSummary', () => { + it('computes occupancy, active leases, and MTD/YTD income/expenses/net', async () => { + const { service, prisma } = makeService({ + lease: { count: jest.fn().mockResolvedValue(5) }, + apartment: { + count: jest + .fn() + .mockImplementation(({ where }: { where: { status?: string } }) => + Promise.resolve(where.status ? 7 : 10), + ), + }, + invoicePayment: { + aggregate: jest + .fn() + .mockImplementation( + ({ where }: { where: { paidAt: { gte: Date } } }) => { + const gte = where.paidAt.gte.toISOString(); + if (gte === MONTH_START) + return Promise.resolve({ _sum: { amount: decimal('1000') } }); + if (gte === YEAR_START) + return Promise.resolve({ _sum: { amount: decimal('5000') } }); + return Promise.resolve({ _sum: { amount: null } }); + }, + ), + }, + expense: { + aggregate: jest + .fn() + .mockImplementation( + ({ where }: { where: { incurredAt: { gte: Date } } }) => { + const gte = where.incurredAt.gte.toISOString(); + if (gte === MONTH_START) + return Promise.resolve({ _sum: { amount: decimal('400') } }); + if (gte === YEAR_START) + return Promise.resolve({ _sum: { amount: decimal('2000') } }); + return Promise.resolve({ _sum: { amount: null } }); + }, + ), + }, + }); + + const { data } = await service.getSummary(orgId, {}, now); + + expect(prisma.lease.count).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ orgId, status: 'active' }), + }), + ); + expect(data).toEqual({ + activeLeases: 5, + totalApartments: 10, + occupiedApartments: 7, + occupancyPct: 70, + mtdIncome: '1000.00', + ytdIncome: '5000.00', + mtdExpenses: '400.00', + ytdExpenses: '2000.00', + mtdNet: '600.00', + ytdNet: '3000.00', + range: null, + }); + }); + + it('reports 0% occupancy and zeroed money when the org is empty', async () => { + const { service } = makeService(); + + const { data } = await service.getSummary(orgId, {}, now); + + expect(data.occupancyPct).toBe(0); + expect(data.mtdIncome).toBe('0.00'); + expect(data.ytdNet).toBe('0.00'); + expect(data.range).toBeNull(); + }); + + it('computes a custom date-range window when from+to are provided', async () => { + const { service } = makeService({ + invoicePayment: { + aggregate: jest + .fn() + .mockImplementation( + ({ where }: { where: { paidAt: { gte: Date } } }) => { + const gte = where.paidAt.gte.toISOString(); + if (gte === MONTH_START || gte === YEAR_START) + return Promise.resolve({ _sum: { amount: null } }); + return Promise.resolve({ _sum: { amount: decimal('250') } }); + }, + ), + }, + expense: { + aggregate: jest + .fn() + .mockImplementation( + ({ where }: { where: { incurredAt: { gte: Date } } }) => { + const gte = where.incurredAt.gte.toISOString(); + if (gte === MONTH_START || gte === YEAR_START) + return Promise.resolve({ _sum: { amount: null } }); + return Promise.resolve({ _sum: { amount: decimal('100') } }); + }, + ), + }, + }); + + const { data } = await service.getSummary( + orgId, + { from: '2026-06-01', to: '2026-06-30' }, + now, + ); + + expect(data.range).toEqual({ + from: '2026-06-01T00:00:00.000Z', + to: '2026-06-30T00:00:00.000Z', + income: '250.00', + expenses: '100.00', + net: '150.00', + }); + }); + + it('rejects when only one of from/to is provided', async () => { + const { service } = makeService(); + + await expect( + service.getSummary(orgId, { from: '2026-06-01' }, now), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rejects an invalid range date', async () => { + const { service } = makeService(); + + await expect( + service.getSummary( + orgId, + { from: 'not-a-date', to: '2026-06-30' }, + now, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); + + describe('getRentRoll', () => { + it('returns one row per active lease with lifetime invoiced/paid/balance', async () => { + const leaseRow = { + id: 'lease-1', + rentAmount: decimal('1500'), + renter: { fullName: 'Jane Tenant' }, + apartment: { unitNumber: '101' }, + invoices: [ + { + lineItems: [ + { amount: decimal('1000') }, + { amount: decimal('200') }, + ], + payments: [{ amount: decimal('500') }], + }, + { + lineItems: [{ amount: decimal('1000') }], + payments: [{ amount: decimal('300') }], + }, + ], + }; + const { service, prisma } = makeService({ + lease: { findMany: jest.fn().mockResolvedValue([leaseRow]) }, + }); + + const { data } = await service.getRentRoll(orgId, now); + + expect(prisma.lease.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ orgId, status: 'active' }), + }), + ); + expect(data).toEqual([ + { + leaseId: 'lease-1', + unitNumber: '101', + renterName: 'Jane Tenant', + rent: '1500.00', + invoiced: '2200.00', + paid: '800.00', + balance: '1400.00', + }, + ]); + }); + }); + + describe('getOverdue', () => { + const overdueInvoice = { + id: 'inv-overdue', + dueDate: new Date('2026-07-10T00:00:00.000Z'), + lineItems: [{ amount: decimal('1000') }], + payments: [{ amount: decimal('200') }], + lease: { + renter: { fullName: 'Late Larry' }, + apartment: { unitNumber: '202' }, + }, + }; + const paidInvoice = { + id: 'inv-paid', + dueDate: new Date('2026-06-01T00:00:00.000Z'), + lineItems: [{ amount: decimal('500') }], + payments: [{ amount: decimal('500') }], + lease: { + renter: { fullName: 'Paid Paula' }, + apartment: { unitNumber: '303' }, + }, + }; + + it('returns only past-due invoices with an outstanding balance', async () => { + const { service } = makeService({ + invoice: { + findMany: jest.fn().mockResolvedValue([overdueInvoice, paidInvoice]), + }, + }); + + const { data } = await service.getOverdue(orgId, undefined, now); + + expect(data).toHaveLength(1); + expect(data[0]).toEqual({ + invoiceId: 'inv-overdue', + unitNumber: '202', + renterName: 'Late Larry', + dueDate: '2026-07-10T00:00:00.000Z', + invoiced: '1000.00', + paid: '200.00', + balance: '800.00', + daysOverdue: 7, + }); + }); + + it('honors an explicit asOf date', async () => { + const { service, prisma } = makeService({ + invoice: { findMany: jest.fn().mockResolvedValue([]) }, + }); + + await service.getOverdue(orgId, '2026-07-15', now); + + expect(prisma.invoice.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + orgId, + dueDate: { lt: new Date('2026-07-15') }, + }), + }), + ); + }); + + it('rejects an invalid asOf date', async () => { + const { service } = makeService(); + + await expect( + service.getOverdue(orgId, 'nope', now), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); +}); diff --git a/apps/api/src/modules/reports/reports.service.ts b/apps/api/src/modules/reports/reports.service.ts new file mode 100644 index 00000000..091d3f5f --- /dev/null +++ b/apps/api/src/modules/reports/reports.service.ts @@ -0,0 +1,256 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { ApartmentStatus, LeaseStatus } from '@repo/db'; +import { PrismaService } from '@/infrastructure/prisma/prisma.service'; +import { computeInvoiceSummary } from '@/common/invoice-summary/compute-invoice-summary'; +import type { + OverdueInvoiceRow, + RentRollRow, + ReportRange, + ReportSummary, +} from '@repo/contracts'; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +type DecimalLike = { toNumber(): number } | null | undefined; + +/** + * Read-only reporting aggregates. Every method is scoped by `orgId` only: + * the controller restricts these endpoints to org-wide roles (org_admin, + * finance), so — unlike the Invoices module — no per-building narrowing is + * needed here. Monetary values are derived through {@link computeInvoiceSummary} + * (the same helper the Invoices module uses) so the numbers reconcile exactly, + * and are serialized as fixed(2) strings to match the rest of the contract. + */ +@Injectable() +export class ReportsService { + constructor(private readonly prisma: PrismaService) {} + + private toNum(value: DecimalLike): number { + return value ? value.toNumber() : 0; + } + + private money(value: number): string { + return value.toFixed(2); + } + + /** UTC start-of-month for `now` (MTD window lower bound). */ + private startOfMonthUtc(now: Date): Date { + return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); + } + + /** UTC start-of-year for `now` (YTD window lower bound). */ + private startOfYearUtc(now: Date): Date { + return new Date(Date.UTC(now.getUTCFullYear(), 0, 1)); + } + + private parseRequiredDate(value: string, field: string): Date { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + throw new BadRequestException(`Invalid "${field}" date.`); + } + return parsed; + } + + private async sumInvoicePayments( + orgId: string, + gte: Date, + lte: Date, + ): Promise { + const { _sum } = await this.prisma.invoicePayment.aggregate({ + _sum: { amount: true }, + where: { orgId, paidAt: { gte, lte } }, + }); + return this.toNum(_sum.amount); + } + + private async sumExpenses( + orgId: string, + gte: Date, + lte: Date, + ): Promise { + const { _sum } = await this.prisma.expense.aggregate({ + _sum: { amount: true }, + where: { orgId, incurredAt: { gte, lte } }, + }); + return this.toNum(_sum.amount); + } + + async getSummary( + orgId: string, + opts: { from?: string; to?: string } = {}, + now: Date = new Date(), + ): Promise<{ data: ReportSummary }> { + const { from, to } = opts; + if ((from && !to) || (to && !from)) { + throw new BadRequestException( + 'Provide both "from" and "to", or neither.', + ); + } + + const monthStart = this.startOfMonthUtc(now); + const yearStart = this.startOfYearUtc(now); + + const [ + activeLeases, + totalApartments, + occupiedApartments, + mtdIncome, + ytdIncome, + mtdExpenses, + ytdExpenses, + ] = await Promise.all([ + this.prisma.lease.count({ + where: { orgId, status: LeaseStatus.active, endDate: { gte: now } }, + }), + this.prisma.apartment.count({ where: { orgId } }), + this.prisma.apartment.count({ + where: { orgId, status: ApartmentStatus.occupied }, + }), + this.sumInvoicePayments(orgId, monthStart, now), + this.sumInvoicePayments(orgId, yearStart, now), + this.sumExpenses(orgId, monthStart, now), + this.sumExpenses(orgId, yearStart, now), + ]); + + const occupancyPct = + totalApartments > 0 + ? Math.round((occupiedApartments / totalApartments) * 1000) / 10 + : 0; + + let range: ReportRange | null = null; + if (from && to) { + const gte = this.parseRequiredDate(from, 'from'); + const lte = this.parseRequiredDate(to, 'to'); + const [income, expenses] = await Promise.all([ + this.sumInvoicePayments(orgId, gte, lte), + this.sumExpenses(orgId, gte, lte), + ]); + range = { + from: gte.toISOString(), + to: lte.toISOString(), + income: this.money(income), + expenses: this.money(expenses), + net: this.money(income - expenses), + }; + } + + return { + data: { + activeLeases, + totalApartments, + occupiedApartments, + occupancyPct, + mtdIncome: this.money(mtdIncome), + ytdIncome: this.money(ytdIncome), + mtdExpenses: this.money(mtdExpenses), + ytdExpenses: this.money(ytdExpenses), + mtdNet: this.money(mtdIncome - mtdExpenses), + ytdNet: this.money(ytdIncome - ytdExpenses), + range, + }, + }; + } + + async getRentRoll( + orgId: string, + now: Date = new Date(), + ): Promise<{ data: RentRollRow[] }> { + const leases = await this.prisma.lease.findMany({ + where: { orgId, status: LeaseStatus.active, endDate: { gte: now } }, + include: { + renter: { select: { fullName: true } }, + apartment: { select: { unitNumber: true } }, + invoices: { + select: { + lineItems: { select: { amount: true } }, + payments: { select: { amount: true } }, + }, + }, + }, + orderBy: { apartment: { unitNumber: 'asc' } }, + }); + + const rows = leases.map((lease) => { + const lineItems = lease.invoices.flatMap((inv) => + inv.lineItems.map((li) => ({ amount: li.amount.toNumber() })), + ); + const payments = lease.invoices.flatMap((inv) => + inv.payments.map((p) => ({ amount: p.amount.toNumber() })), + ); + // dueDate/now feed only the derived status, which the rent roll ignores — + // pass `now` for both. totalAmount/paidAmount are all we consume here. + const { totalAmount, paidAmount } = computeInvoiceSummary( + lineItems, + payments, + now, + now, + ); + return { + leaseId: lease.id, + unitNumber: lease.apartment.unitNumber, + renterName: lease.renter.fullName, + rent: this.money(lease.rentAmount.toNumber()), + invoiced: this.money(totalAmount), + paid: this.money(paidAmount), + balance: this.money(totalAmount - paidAmount), + }; + }); + + return { data: rows }; + } + + async getOverdue( + orgId: string, + asOf?: string, + now: Date = new Date(), + ): Promise<{ data: OverdueInvoiceRow[] }> { + const asOfDate = asOf ? this.parseRequiredDate(asOf, 'asOf') : now; + + const invoices = await this.prisma.invoice.findMany({ + where: { orgId, dueDate: { lt: asOfDate } }, + include: { + lineItems: { select: { amount: true } }, + payments: { select: { amount: true } }, + lease: { + select: { + renter: { select: { fullName: true } }, + apartment: { select: { unitNumber: true } }, + }, + }, + }, + orderBy: { dueDate: 'asc' }, + }); + + const rows: OverdueInvoiceRow[] = []; + for (const invoice of invoices) { + const { totalAmount, paidAmount, status } = computeInvoiceSummary( + invoice.lineItems.map((li) => ({ amount: li.amount.toNumber() })), + invoice.payments.map((p) => ({ amount: p.amount.toNumber() })), + invoice.dueDate, + asOfDate, + ); + // status === 'overdue' ⟺ past due AND not fully paid (outstanding balance). + if (status !== 'overdue') continue; + + const daysOverdue = Math.max( + 0, + Math.floor( + (asOfDate.getTime() - invoice.dueDate.getTime()) / MS_PER_DAY, + ), + ); + rows.push({ + invoiceId: invoice.id, + unitNumber: invoice.lease.apartment.unitNumber, + renterName: invoice.lease.renter.fullName, + dueDate: invoice.dueDate.toISOString(), + invoiced: this.money(totalAmount), + paid: this.money(paidAmount), + balance: this.money(totalAmount - paidAmount), + daysOverdue, + }); + } + + rows.sort((a, b) => b.daysOverdue - a.daysOverdue); + return { data: rows }; + } +} diff --git a/apps/api/src/modules/support-tickets/dto/create-support-ticket.dto.ts b/apps/api/src/modules/support-tickets/dto/create-support-ticket.dto.ts new file mode 100644 index 00000000..ab18d924 --- /dev/null +++ b/apps/api/src/modules/support-tickets/dto/create-support-ticket.dto.ts @@ -0,0 +1,28 @@ +import { + IsEnum, + IsNotEmpty, + IsOptional, + IsString, + MaxLength, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { SupportTicketCategory } from '@repo/db'; + +export class CreateSupportTicketDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + @MaxLength(200) + subject: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + @MaxLength(5000) + description: string; + + @ApiPropertyOptional({ enum: SupportTicketCategory }) + @IsOptional() + @IsEnum(SupportTicketCategory) + category?: SupportTicketCategory; +} diff --git a/apps/api/src/modules/support-tickets/dto/update-support-ticket.dto.ts b/apps/api/src/modules/support-tickets/dto/update-support-ticket.dto.ts new file mode 100644 index 00000000..58ae79a4 --- /dev/null +++ b/apps/api/src/modules/support-tickets/dto/update-support-ticket.dto.ts @@ -0,0 +1,10 @@ +import { IsEnum, IsOptional } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { SupportTicketStatus } from '@repo/db'; + +export class UpdateSupportTicketDto { + @ApiPropertyOptional({ enum: SupportTicketStatus }) + @IsOptional() + @IsEnum(SupportTicketStatus) + status?: SupportTicketStatus; +} diff --git a/apps/api/src/modules/support-tickets/support-tickets.controller.ts b/apps/api/src/modules/support-tickets/support-tickets.controller.ts new file mode 100644 index 00000000..27820bcc --- /dev/null +++ b/apps/api/src/modules/support-tickets/support-tickets.controller.ts @@ -0,0 +1,56 @@ +import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { SupportTicketsService } from './support-tickets.service'; +import { OrgScopeService } from '@/common/org-scope/org-scope.service'; +import { CurrentUser, Roles } from '@/common/decorators'; +import { AuthenticatedUser } from '@/common/types/authenticated-user.type'; +import { Role } from '@/common/enums'; +import { CreateSupportTicketDto } from './dto/create-support-ticket.dto'; +import { UpdateSupportTicketDto } from './dto/update-support-ticket.dto'; + +@ApiTags('support-tickets') +@ApiBearerAuth() +@Controller('support-tickets') +export class SupportTicketsController { + constructor( + private readonly supportTickets: SupportTicketsService, + private readonly orgScope: OrgScopeService, + ) {} + + // Any authenticated, provisioned user (including tenants) may list their own + // tickets; staff see all org tickets (enforced in the service). + @Get() + async list(@CurrentUser() user: AuthenticatedUser) { + const { orgId, role } = await this.orgScope.resolveForCaller(user); + return this.supportTickets.findAll(orgId, user.sub, role); + } + + @Get(':id') + async getOne( + @CurrentUser() user: AuthenticatedUser, + @Param('id') id: string, + ) { + const { orgId, role } = await this.orgScope.resolveForCaller(user); + return this.supportTickets.findOne(orgId, user.sub, role, id); + } + + @Post() + async create( + @CurrentUser() user: AuthenticatedUser, + @Body() dto: CreateSupportTicketDto, + ) { + const { orgId } = await this.orgScope.resolveForCaller(user); + return this.supportTickets.create(orgId, user.sub, dto); + } + + @Roles(Role.ORG_ADMIN, Role.SUPERVISOR) + @Patch(':id') + async update( + @CurrentUser() user: AuthenticatedUser, + @Param('id') id: string, + @Body() dto: UpdateSupportTicketDto, + ) { + const { orgId, role } = await this.orgScope.resolveForCaller(user); + return this.supportTickets.updateStatus(orgId, user.sub, role, id, dto); + } +} diff --git a/apps/api/src/modules/support-tickets/support-tickets.module.ts b/apps/api/src/modules/support-tickets/support-tickets.module.ts new file mode 100644 index 00000000..5e671aa1 --- /dev/null +++ b/apps/api/src/modules/support-tickets/support-tickets.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { SupportTicketsController } from './support-tickets.controller'; +import { SupportTicketsService } from './support-tickets.service'; +import { NotificationsModule } from '@/modules/notifications/notifications.module'; + +@Module({ + imports: [NotificationsModule], + controllers: [SupportTicketsController], + providers: [SupportTicketsService], + exports: [SupportTicketsService], +}) +export class SupportTicketsModule {} diff --git a/apps/api/src/modules/support-tickets/support-tickets.service.spec.ts b/apps/api/src/modules/support-tickets/support-tickets.service.spec.ts new file mode 100644 index 00000000..200ead92 --- /dev/null +++ b/apps/api/src/modules/support-tickets/support-tickets.service.spec.ts @@ -0,0 +1,163 @@ +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { SupportTicketsService } from './support-tickets.service'; +import { Role } from '@/common/enums'; + +describe('SupportTicketsService', () => { + const orgId = 'org-1'; + const tenantId = 'tenant-1'; + const adminId = 'admin-1'; + + function makeService( + overrides: { supportTicket?: Partial> } = {}, + ) { + const prisma: any = { + supportTicket: { + findFirst: jest.fn().mockResolvedValue(null), + findMany: jest.fn().mockResolvedValue([]), + create: jest.fn(), + update: jest.fn(), + ...overrides.supportTicket, + }, + }; + const timeline = { emit: jest.fn().mockResolvedValue(undefined) }; + const notifications = { enqueue: jest.fn().mockResolvedValue(undefined) }; + const service = new SupportTicketsService( + prisma, + timeline as any, + notifications as any, + ); + return { service, prisma, timeline, notifications }; + } + + const ticketRow = (o: Partial> = {}) => ({ + id: 'ticket-1', + orgId, + createdByUserId: tenantId, + subject: 'No hot water', + description: 'Been out for two days', + category: 'maintenance', + status: 'acknowledged', + acknowledgedAt: new Date('2026-07-17T00:00:00.000Z'), + acknowledgedByUserId: null, + createdAt: new Date('2026-07-17T00:00:00.000Z'), + updatedAt: new Date('2026-07-17T00:00:00.000Z'), + ...o, + }); + + describe('findAll', () => { + it('scopes tenants to their own tickets', async () => { + const { service, prisma } = makeService({ + supportTicket: { findMany: jest.fn().mockResolvedValue([ticketRow()]) }, + }); + await service.findAll(orgId, tenantId, Role.TENANT); + expect(prisma.supportTicket.findMany).toHaveBeenCalledWith({ + where: { orgId, createdByUserId: tenantId }, + orderBy: { createdAt: 'desc' }, + }); + }); + + it('shows all org tickets to staff', async () => { + const { service, prisma } = makeService(); + await service.findAll(orgId, adminId, Role.ORG_ADMIN); + expect(prisma.supportTicket.findMany).toHaveBeenCalledWith({ + where: { orgId }, + orderBy: { createdAt: 'desc' }, + }); + }); + }); + + describe('findOne', () => { + it('forbids a tenant from viewing a ticket they did not open', async () => { + const { service } = makeService({ + supportTicket: { + findFirst: jest + .fn() + .mockResolvedValue(ticketRow({ createdByUserId: 'someone-else' })), + }, + }); + await expect( + service.findOne(orgId, tenantId, Role.TENANT, 'ticket-1'), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('create', () => { + it('creates an auto-acknowledged ticket, emits timeline, and enqueues a notification', async () => { + const { service, prisma, timeline, notifications } = makeService({ + supportTicket: { create: jest.fn().mockResolvedValue(ticketRow()) }, + }); + + const result = await service.create(orgId, tenantId, { + subject: 'No hot water', + description: 'Been out for two days', + category: 'maintenance', + }); + + expect(prisma.supportTicket.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + orgId, + createdByUserId: tenantId, + status: 'acknowledged', + }), + }); + expect(timeline.emit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'support_ticket.created' }), + ); + expect(notifications.enqueue).toHaveBeenCalledWith( + expect.objectContaining({ + userId: tenantId, + type: 'support_ticket.acknowledged', + }), + ); + expect(result.data.status).toBe('acknowledged'); + }); + }); + + describe('updateStatus', () => { + it('forbids tenants from transitioning tickets', async () => { + const { service } = makeService(); + await expect( + service.updateStatus(orgId, tenantId, Role.TENANT, 'ticket-1', { + status: 'resolved', + }), + ).rejects.toThrow(ForbiddenException); + }); + + it('lets an admin resolve a ticket and notifies the opener', async () => { + const { service, prisma, notifications } = makeService({ + supportTicket: { + findFirst: jest.fn().mockResolvedValue(ticketRow()), + update: jest + .fn() + .mockResolvedValue(ticketRow({ status: 'resolved' })), + }, + }); + + const result = await service.updateStatus( + orgId, + adminId, + Role.ORG_ADMIN, + 'ticket-1', + { status: 'resolved' }, + ); + + expect(prisma.supportTicket.update).toHaveBeenCalled(); + expect(notifications.enqueue).toHaveBeenCalledWith( + expect.objectContaining({ + userId: tenantId, + type: 'support_ticket.resolved', + }), + ); + expect(result.data.status).toBe('resolved'); + }); + + it('throws NotFound when the ticket does not exist', async () => { + const { service } = makeService(); + await expect( + service.updateStatus(orgId, adminId, Role.ORG_ADMIN, 'missing', { + status: 'closed', + }), + ).rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/apps/api/src/modules/support-tickets/support-tickets.service.ts b/apps/api/src/modules/support-tickets/support-tickets.service.ts new file mode 100644 index 00000000..b6f04751 --- /dev/null +++ b/apps/api/src/modules/support-tickets/support-tickets.service.ts @@ -0,0 +1,210 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { PrismaService } from '@/infrastructure/prisma/prisma.service'; +import { TimelineService } from '@/modules/timeline/timeline.service'; +import { NotificationsService } from '@/modules/notifications/notifications.service'; +import { Role } from '@/common/enums'; +import { + SupportTicketCategory, + SupportTicketResponse, + SupportTicketStatus, +} from '@repo/contracts'; +import { CreateSupportTicketDto } from './dto/create-support-ticket.dto'; +import { UpdateSupportTicketDto } from './dto/update-support-ticket.dto'; + +/** Roles allowed to transition a ticket (acknowledge / resolve / close). */ +const STAFF_MANAGE_ROLES = new Set([Role.ORG_ADMIN, Role.SUPERVISOR]); + +/** + * Roles that see every ticket in the org. Tenants (and any other role not + * listed) see only the tickets they opened themselves. + */ +const ORG_WIDE_VIEW_ROLES = new Set([ + Role.ORG_ADMIN, + Role.SUPERVISOR, + Role.FINANCE, + Role.MAINTENANCE, +]); + +type SupportTicketRow = { + id: string; + orgId: string; + createdByUserId: string; + subject: string; + description: string; + category: string; + status: string; + acknowledgedAt: Date | null; + acknowledgedByUserId: string | null; + createdAt: Date; + updatedAt: Date; +}; + +@Injectable() +export class SupportTicketsService { + constructor( + private readonly prisma: PrismaService, + private readonly timeline: TimelineService, + private readonly notifications: NotificationsService, + ) {} + + private format(t: SupportTicketRow): SupportTicketResponse { + return { + id: t.id, + orgId: t.orgId, + createdByUserId: t.createdByUserId, + subject: t.subject, + description: t.description, + category: t.category as SupportTicketCategory, + status: t.status as SupportTicketStatus, + acknowledgedAt: t.acknowledgedAt ? t.acknowledgedAt.toISOString() : null, + acknowledgedByUserId: t.acknowledgedByUserId, + createdAt: t.createdAt.toISOString(), + updatedAt: t.updatedAt.toISOString(), + }; + } + + async findAll( + orgId: string, + callerId: string, + callerRole: Role, + ): Promise<{ data: SupportTicketResponse[] }> { + const where = ORG_WIDE_VIEW_ROLES.has(callerRole) + ? { orgId } + : { orgId, createdByUserId: callerId }; + + const tickets = await this.prisma.supportTicket.findMany({ + where, + orderBy: { createdAt: 'desc' }, + }); + + return { data: tickets.map((t) => this.format(t)) }; + } + + async findOne( + orgId: string, + callerId: string, + callerRole: Role, + id: string, + ): Promise<{ data: SupportTicketResponse }> { + const ticket = await this.prisma.supportTicket.findFirst({ + where: { id, orgId }, + }); + if (!ticket) throw new NotFoundException('Support ticket not found.'); + + if ( + !ORG_WIDE_VIEW_ROLES.has(callerRole) && + ticket.createdByUserId !== callerId + ) { + throw new ForbiddenException( + 'You are not permitted to view this support ticket.', + ); + } + + return { data: this.format(ticket) }; + } + + async create( + orgId: string, + callerId: string, + dto: CreateSupportTicketDto, + ): Promise<{ data: SupportTicketResponse }> { + if (!dto.subject || !dto.description) { + throw new BadRequestException('subject and description are required.'); + } + + // The platform acknowledges receipt immediately (there is no chat). The + // acknowledgment MESSAGE is then delivered to the opener asynchronously via + // the BullMQ notifications queue. + const ticket = await this.prisma.supportTicket.create({ + data: { + orgId, + createdByUserId: callerId, + subject: dto.subject, + description: dto.description, + category: dto.category ?? 'general', + status: 'acknowledged', + acknowledgedAt: new Date(), + }, + }); + + await this.timeline.emit({ + orgId, + actorId: callerId, + action: 'support_ticket.created', + targetType: 'SupportTicket', + targetId: ticket.id, + metadata: { category: ticket.category, subject: ticket.subject }, + }); + + await this.notifications.enqueue({ + orgId, + userId: callerId, + type: 'support_ticket.acknowledged', + title: 'Support ticket received', + body: `We've received your ticket "${ticket.subject}" and will follow up shortly.`, + data: { ticketId: ticket.id, category: ticket.category }, + }); + + return { data: this.format(ticket) }; + } + + async updateStatus( + orgId: string, + callerId: string, + callerRole: Role, + id: string, + dto: UpdateSupportTicketDto, + ): Promise<{ data: SupportTicketResponse }> { + if (!STAFF_MANAGE_ROLES.has(callerRole)) { + throw new ForbiddenException( + 'Only an org admin or supervisor can update a support ticket.', + ); + } + if (!dto.status) { + throw new BadRequestException('status is required.'); + } + + const existing = await this.prisma.supportTicket.findFirst({ + where: { id, orgId }, + }); + if (!existing) throw new NotFoundException('Support ticket not found.'); + + const ticket = await this.prisma.supportTicket.update({ + where: { id }, + data: { + status: dto.status, + ...(dto.status === 'acknowledged' && !existing.acknowledgedAt + ? { acknowledgedAt: new Date(), acknowledgedByUserId: callerId } + : {}), + }, + }); + + await this.timeline.emit({ + orgId, + actorId: callerId, + action: `support_ticket.${dto.status}`, + targetType: 'SupportTicket', + targetId: ticket.id, + metadata: { status: dto.status }, + }); + + // Notify the opener of the status change (unless they changed it themselves). + if (ticket.createdByUserId !== callerId) { + await this.notifications.enqueue({ + orgId, + userId: ticket.createdByUserId, + type: `support_ticket.${dto.status}`, + title: `Support ticket ${dto.status}`, + body: `Your ticket "${ticket.subject}" is now ${dto.status}.`, + data: { ticketId: ticket.id, status: dto.status }, + }); + } + + return { data: this.format(ticket) }; + } +} diff --git a/apps/api/src/modules/tenant/dto/create-tenant-maintenance-request.dto.ts b/apps/api/src/modules/tenant/dto/create-tenant-maintenance-request.dto.ts new file mode 100644 index 00000000..28049f9e --- /dev/null +++ b/apps/api/src/modules/tenant/dto/create-tenant-maintenance-request.dto.ts @@ -0,0 +1,30 @@ +import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { MaintenanceRequestPriority } from '@repo/db'; + +/** + * A tenant opening a maintenance request for their OWN apartment. Unlike the + * staff-facing CreateMaintenanceRequestDto, this carries no buildingId / + * apartmentId / renterId: those are derived server-side from the caller's + * active lease so a tenant can never target another unit. Status is forced to + * 'open'. + */ +export class CreateTenantMaintenanceRequestDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + title: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ + enum: MaintenanceRequestPriority, + description: 'Defaults to medium when not provided', + }) + @IsOptional() + @IsEnum(MaintenanceRequestPriority) + priority?: MaintenanceRequestPriority; +} diff --git a/apps/api/src/modules/tenant/tenant.controller.ts b/apps/api/src/modules/tenant/tenant.controller.ts new file mode 100644 index 00000000..f35b9674 --- /dev/null +++ b/apps/api/src/modules/tenant/tenant.controller.ts @@ -0,0 +1,41 @@ +import { Body, Controller, Get, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { TenantService } from './tenant.service'; +import { OrgScopeService } from '@/common/org-scope/org-scope.service'; +import { CurrentUser, Roles } from '@/common/decorators'; +import { AuthenticatedUser } from '@/common/types/authenticated-user.type'; +import { Role } from '@/common/enums'; +import { CreateTenantMaintenanceRequestDto } from './dto/create-tenant-maintenance-request.dto'; + +/** + * Tenant self-service portal. Locked to the `tenant` role — staff roles have + * their own richer module views and are rejected here by the RolesGuard. Scope + * is derived entirely from the caller's Keycloak `sub` inside the service (no + * id is accepted from the client), so cross-tenant access is structurally + * impossible. + */ +@ApiTags('tenant') +@ApiBearerAuth() +@Controller('tenant') +@Roles(Role.TENANT) +export class TenantController { + constructor( + private readonly tenantService: TenantService, + private readonly orgScope: OrgScopeService, + ) {} + + @Get('overview') + async getOverview(@CurrentUser() user: AuthenticatedUser) { + const { orgId } = this.orgScope.resolveForCaller(user); + return this.tenantService.getOverview(orgId, user.sub); + } + + @Post('maintenance-requests') + async createMaintenanceRequest( + @CurrentUser() user: AuthenticatedUser, + @Body() dto: CreateTenantMaintenanceRequestDto, + ) { + const { orgId } = this.orgScope.resolveForCaller(user); + return this.tenantService.createMaintenanceRequest(orgId, user.sub, dto); + } +} diff --git a/apps/api/src/modules/tenant/tenant.module.ts b/apps/api/src/modules/tenant/tenant.module.ts new file mode 100644 index 00000000..ffe27450 --- /dev/null +++ b/apps/api/src/modules/tenant/tenant.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TenantController } from './tenant.controller'; +import { TenantService } from './tenant.service'; + +/** + * Prisma, OrgScope, LeaseStatus and Timeline are all @Global, so — like + * ReportsModule — this module only declares its own controller + service. + */ +@Module({ + controllers: [TenantController], + providers: [TenantService], + exports: [TenantService], +}) +export class TenantModule {} diff --git a/apps/api/src/modules/tenant/tenant.service.spec.ts b/apps/api/src/modules/tenant/tenant.service.spec.ts new file mode 100644 index 00000000..ecd14283 --- /dev/null +++ b/apps/api/src/modules/tenant/tenant.service.spec.ts @@ -0,0 +1,281 @@ +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { TenantService } from './tenant.service'; +import { LeaseStatusService } from '@/common/lease-status/lease-status.service'; + +describe('TenantService', () => { + const orgId = 'org-1'; + const sub = 'kc-sub-tenant-1'; + const now = new Date('2026-07-17T10:00:00.000Z'); + const FAR_FUTURE = new Date('2099-01-01T00:00:00.000Z'); + const PAST = new Date('2020-01-01T00:00:00.000Z'); + + const decimal = (value: string) => ({ toNumber: () => Number(value) }); + + function makeService( + overrides: { + renter?: Partial>; + lease?: Partial>; + invoice?: Partial>; + maintenanceRequest?: Partial>; + } = {}, + ) { + const prisma: any = { + renter: { + findFirst: jest.fn().mockResolvedValue(null), + ...overrides.renter, + }, + lease: { + findMany: jest.fn().mockResolvedValue([]), + ...overrides.lease, + }, + invoice: { + findMany: jest.fn().mockResolvedValue([]), + ...overrides.invoice, + }, + maintenanceRequest: { + findMany: jest.fn().mockResolvedValue([]), + create: jest.fn(), + ...overrides.maintenanceRequest, + }, + }; + const timeline = { emit: jest.fn().mockResolvedValue(undefined) }; + const leaseStatus = new LeaseStatusService(); + const service = new TenantService( + prisma as any, + leaseStatus, + timeline as any, + ); + return { service, prisma, timeline }; + } + + const renter = { id: 'renter-1', fullName: 'Jane Doe', email: null, phone: null }; + + const leaseRow = (overrides: Record = {}) => ({ + id: 'lease-1', + buildingId: 'building-1', + apartmentId: 'apartment-1', + startDate: new Date('2026-01-01T00:00:00.000Z'), + endDate: FAR_FUTURE, + rentAmount: decimal('1500.00'), + depositAmount: decimal('1500.00'), + status: 'active', + apartment: { unitNumber: '4B', building: { name: 'Cedar Court' } }, + ...overrides, + }); + + describe('getOverview', () => { + it('returns an unlinked empty overview when no renter is bound to the caller', async () => { + const { service, prisma } = makeService(); + + const { data } = await service.getOverview(orgId, sub, now); + + expect(data.linked).toBe(false); + expect(data.renter).toBeNull(); + expect(data.lease).toBeNull(); + expect(data.invoices).toEqual([]); + expect(data.maintenanceRequests).toEqual([]); + expect(data.balance).toEqual({ + invoiced: '0.00', + paid: '0.00', + outstanding: '0.00', + }); + // The linkage lookup is scoped to org + the caller's own sub. + expect(prisma.renter.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { orgId, renterUserId: sub }, + }), + ); + }); + + it('surfaces the active lease, lifetime balance, and the tenant’s requests', async () => { + const { service } = makeService({ + renter: { findFirst: jest.fn().mockResolvedValue(renter) }, + lease: { findMany: jest.fn().mockResolvedValue([leaseRow()]) }, + invoice: { + findMany: jest.fn().mockResolvedValue([ + { + id: 'inv-1', + dueDate: PAST, // past due + unpaid -> overdue + lineItems: [{ amount: decimal('1000') }], + payments: [{ amount: decimal('400') }], + }, + { + id: 'inv-2', + dueDate: FAR_FUTURE, + lineItems: [{ amount: decimal('500') }], + payments: [{ amount: decimal('500') }], // fully paid + }, + ]), + }, + maintenanceRequest: { + findMany: jest.fn().mockResolvedValue([ + { + id: 'mr-1', + title: 'Leaky tap', + description: null, + status: 'open', + priority: 'high', + createdAt: now, + apartment: { unitNumber: '4B' }, + }, + ]), + }, + }); + + const { data } = await service.getOverview(orgId, sub, now); + + expect(data.linked).toBe(true); + expect(data.renter).toEqual({ + id: 'renter-1', + fullName: 'Jane Doe', + email: null, + phone: null, + }); + expect(data.lease).toEqual( + expect.objectContaining({ + id: 'lease-1', + unitNumber: '4B', + buildingName: 'Cedar Court', + rentAmount: '1500.00', + status: 'active', + effectiveStatus: 'active', + }), + ); + // invoiced 1000+500=1500, paid 400+500=900, outstanding 600 + expect(data.balance).toEqual({ + invoiced: '1500.00', + paid: '900.00', + outstanding: '600.00', + }); + expect(data.invoices).toHaveLength(2); + const overdue = data.invoices.find((i) => i.id === 'inv-1'); + expect(overdue).toEqual( + expect.objectContaining({ balance: '600.00', status: 'overdue' }), + ); + expect(data.maintenanceRequests).toEqual([ + expect.objectContaining({ id: 'mr-1', unitNumber: '4B' }), + ]); + }); + + it('derives effectiveStatus expired for an active lease whose endDate has passed', async () => { + const { service } = makeService({ + renter: { findFirst: jest.fn().mockResolvedValue(renter) }, + lease: { + findMany: jest + .fn() + .mockResolvedValue([leaseRow({ status: 'active', endDate: PAST })]), + }, + }); + + const { data } = await service.getOverview(orgId, sub, now); + + expect(data.lease?.status).toBe('active'); + expect(data.lease?.effectiveStatus).toBe('expired'); + }); + }); + + describe('createMaintenanceRequest', () => { + it('rejects an unlinked tenant with 403', async () => { + const { service } = makeService(); + + await expect( + service.createMaintenanceRequest(orgId, sub, { title: 'X' }, now), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('rejects when the tenant has no effectively-active lease', async () => { + const { service } = makeService({ + renter: { findFirst: jest.fn().mockResolvedValue(renter) }, + lease: { + findMany: jest + .fn() + .mockResolvedValue([leaseRow({ status: 'terminated' })]), + }, + }); + + await expect( + service.createMaintenanceRequest(orgId, sub, { title: 'X' }, now), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('creates a request scoped to the tenant’s own apartment and emits a self-actor event', async () => { + const create = jest.fn().mockResolvedValue({ + id: 'mr-9', + title: 'Broken heater', + description: 'No heat', + status: 'open', + priority: 'medium', + createdAt: now, + apartment: { unitNumber: '4B' }, + }); + const { service, prisma, timeline } = makeService({ + renter: { findFirst: jest.fn().mockResolvedValue(renter) }, + lease: { findMany: jest.fn().mockResolvedValue([leaseRow()]) }, + maintenanceRequest: { create }, + }); + + const { data } = await service.createMaintenanceRequest( + orgId, + sub, + { title: ' Broken heater ', description: 'No heat' }, + now, + ); + + // buildingId/apartmentId/renterId are derived from the active lease, never + // from client input. + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + orgId, + buildingId: 'building-1', + apartmentId: 'apartment-1', + renterId: 'renter-1', + title: 'Broken heater', + }), + }), + ); + // No explicit status/priority in the create data -> DB defaults (open/medium). + expect(create.mock.calls[0][0].data.status).toBeUndefined(); + expect(create.mock.calls[0][0].data.priority).toBeUndefined(); + expect(prisma.maintenanceRequest.create).toHaveBeenCalledTimes(1); + expect(timeline.emit).toHaveBeenCalledWith( + expect.objectContaining({ + orgId, + actorId: sub, + action: 'maintenance_request.created', + targetType: 'MaintenanceRequest', + targetId: 'mr-9', + }), + ); + expect(data).toEqual( + expect.objectContaining({ id: 'mr-9', unitNumber: '4B' }), + ); + }); + + it('forwards a provided priority to the created request', async () => { + const create = jest.fn().mockResolvedValue({ + id: 'mr-10', + title: 'Flood', + description: null, + status: 'open', + priority: 'urgent', + createdAt: now, + apartment: { unitNumber: '4B' }, + }); + const { service } = makeService({ + renter: { findFirst: jest.fn().mockResolvedValue(renter) }, + lease: { findMany: jest.fn().mockResolvedValue([leaseRow()]) }, + maintenanceRequest: { create }, + }); + + await service.createMaintenanceRequest( + orgId, + sub, + { title: 'Flood', priority: 'urgent' as any }, + now, + ); + + expect(create.mock.calls[0][0].data.priority).toBe('urgent'); + }); + }); +}); diff --git a/apps/api/src/modules/tenant/tenant.service.ts b/apps/api/src/modules/tenant/tenant.service.ts new file mode 100644 index 00000000..98e27e35 --- /dev/null +++ b/apps/api/src/modules/tenant/tenant.service.ts @@ -0,0 +1,322 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { PrismaService } from '@/infrastructure/prisma/prisma.service'; +import { LeaseStatusService } from '@/common/lease-status/lease-status.service'; +import { TimelineService } from '@/modules/timeline/timeline.service'; +import { computeInvoiceSummary } from '@/common/invoice-summary/compute-invoice-summary'; +import type { + InvoiceStatus, + LeaseStatus, + MaintenanceRequestPriority, + MaintenanceRequestStatus, + TenantInvoiceView, + TenantLeaseView, + TenantMaintenanceRequestView, + TenantOverviewResponse, +} from '@repo/contracts'; +import { CreateTenantMaintenanceRequestDto } from './dto/create-tenant-maintenance-request.dto'; + +type DecimalLike = { toNumber(): number }; + +type LeaseRow = { + id: string; + buildingId: string; + startDate: Date; + endDate: Date; + rentAmount: DecimalLike; + depositAmount: DecimalLike; + status: string; + apartment: { unitNumber: string; building: { name: string } }; +}; + +type InvoiceRow = { + id: string; + dueDate: Date; + lineItems: { amount: DecimalLike }[]; + payments: { amount: DecimalLike }[]; +}; + +type RequestRow = { + id: string; + title: string; + description: string | null; + status: string; + priority: string; + createdAt: Date; + apartment: { unitNumber: string }; +}; + +/** + * Tenant self-service. Every method resolves the caller's linked Renter row via + * `Renter.renterUserId == userId` (the Keycloak `sub`) and derives ALL scope + * from it — no id is ever accepted from the client, so one tenant can never + * reach another's lease, invoices, or requests. Monetary values are derived + * through {@link computeInvoiceSummary} (the same helper Invoices/Reports use) + * so the numbers reconcile, and are serialized as fixed(2) strings. + */ +@Injectable() +export class TenantService { + constructor( + private readonly prisma: PrismaService, + private readonly leaseStatus: LeaseStatusService, + private readonly timeline: TimelineService, + ) {} + + private money(value: number): string { + return value.toFixed(2); + } + + private emptyOverview(): TenantOverviewResponse { + return { + linked: false, + renter: null, + lease: null, + balance: { invoiced: '0.00', paid: '0.00', outstanding: '0.00' }, + invoices: [], + maintenanceRequests: [], + }; + } + + private formatLease(lease: LeaseRow, now: Date): TenantLeaseView { + return { + id: lease.id, + buildingId: lease.buildingId, + buildingName: lease.apartment.building.name, + unitNumber: lease.apartment.unitNumber, + startDate: lease.startDate.toISOString(), + endDate: lease.endDate.toISOString(), + rentAmount: this.money(lease.rentAmount.toNumber()), + depositAmount: this.money(lease.depositAmount.toNumber()), + status: lease.status as LeaseStatus, + effectiveStatus: this.leaseStatus.deriveEffectiveStatus( + { status: lease.status as LeaseStatus, endDate: lease.endDate }, + now, + ), + }; + } + + private formatRequest(request: RequestRow): TenantMaintenanceRequestView { + return { + id: request.id, + title: request.title, + description: request.description, + status: request.status as MaintenanceRequestStatus, + priority: request.priority as MaintenanceRequestPriority, + unitNumber: request.apartment.unitNumber, + createdAt: request.createdAt.toISOString(), + }; + } + + /** Resolve the Renter linked to this Keycloak user within the org, or null. */ + private async findLinkedRenter(orgId: string, userId: string) { + return this.prisma.renter.findFirst({ + where: { orgId, renterUserId: userId }, + select: { id: true, fullName: true, email: true, phone: true }, + }); + } + + /** + * Of the tenant's leases, the one to surface as "my lease": the first + * effectively-active lease (newest start), else the most recent lease. + */ + private pickCurrentLease( + leases: T[], + now: Date, + ): T | null { + const active = leases.find((l) => + this.leaseStatus.isEffectivelyActive( + { status: l.status as LeaseStatus, endDate: l.endDate }, + now, + ), + ); + return active ?? leases[0] ?? null; + } + + async getOverview( + orgId: string, + userId: string, + now: Date = new Date(), + ): Promise<{ data: TenantOverviewResponse }> { + const renter = await this.findLinkedRenter(orgId, userId); + if (!renter) { + return { data: this.emptyOverview() }; + } + + const [leases, invoices, requests] = await Promise.all([ + this.prisma.lease.findMany({ + where: { orgId, renterId: renter.id }, + select: { + id: true, + buildingId: true, + startDate: true, + endDate: true, + rentAmount: true, + depositAmount: true, + status: true, + apartment: { + select: { + unitNumber: true, + building: { select: { name: true } }, + }, + }, + }, + orderBy: { startDate: 'desc' }, + }), + this.prisma.invoice.findMany({ + where: { orgId, lease: { renterId: renter.id } }, + select: { + id: true, + dueDate: true, + lineItems: { select: { amount: true } }, + payments: { select: { amount: true } }, + }, + orderBy: { dueDate: 'desc' }, + }), + this.prisma.maintenanceRequest.findMany({ + where: { orgId, renterId: renter.id }, + select: { + id: true, + title: true, + description: true, + status: true, + priority: true, + createdAt: true, + apartment: { select: { unitNumber: true } }, + }, + orderBy: { createdAt: 'desc' }, + }), + ]); + + const current = this.pickCurrentLease(leases as LeaseRow[], now); + + // Balance spans ALL of the tenant's invoices; the list shows the latest 10. + let totalInvoiced = 0; + let totalPaid = 0; + const invoiceViews: TenantInvoiceView[] = (invoices as InvoiceRow[]).map( + (inv) => { + const { totalAmount, paidAmount, status } = computeInvoiceSummary( + inv.lineItems.map((li) => ({ amount: li.amount.toNumber() })), + inv.payments.map((p) => ({ amount: p.amount.toNumber() })), + inv.dueDate, + now, + ); + totalInvoiced += totalAmount; + totalPaid += paidAmount; + return { + id: inv.id, + dueDate: inv.dueDate.toISOString(), + invoiced: this.money(totalAmount), + paid: this.money(paidAmount), + balance: this.money(totalAmount - paidAmount), + status: status as InvoiceStatus, + }; + }, + ); + + return { + data: { + linked: true, + renter: { + id: renter.id, + fullName: renter.fullName, + email: renter.email, + phone: renter.phone, + }, + lease: current ? this.formatLease(current, now) : null, + balance: { + invoiced: this.money(totalInvoiced), + paid: this.money(totalPaid), + outstanding: this.money(totalInvoiced - totalPaid), + }, + invoices: invoiceViews.slice(0, 10), + maintenanceRequests: (requests as RequestRow[]).map((r) => + this.formatRequest(r), + ), + }, + }; + } + + async createMaintenanceRequest( + orgId: string, + userId: string, + dto: CreateTenantMaintenanceRequestDto, + now: Date = new Date(), + ): Promise<{ data: TenantMaintenanceRequestView }> { + if (!dto.title || dto.title.trim().length === 0) { + throw new BadRequestException('A title is required.'); + } + + const renter = await this.findLinkedRenter(orgId, userId); + if (!renter) { + throw new ForbiddenException( + 'Your tenant account is not linked to a lease yet. Contact your property manager.', + ); + } + + const leases = await this.prisma.lease.findMany({ + where: { orgId, renterId: renter.id }, + select: { + id: true, + buildingId: true, + apartmentId: true, + status: true, + endDate: true, + }, + orderBy: { startDate: 'desc' }, + }); + const current = this.pickCurrentLease(leases, now); + // Only an effectively-active lease can receive a request — a past/expired + // lease is not a place to raise new issues, and a tenant with no active + // lease has no apartment to scope to. + if ( + !current || + !this.leaseStatus.isEffectivelyActive( + { status: current.status as LeaseStatus, endDate: current.endDate }, + now, + ) + ) { + throw new BadRequestException( + 'You have no active lease to attach a maintenance request to.', + ); + } + + const created = await this.prisma.maintenanceRequest.create({ + data: { + orgId, + buildingId: current.buildingId, + apartmentId: current.apartmentId, + renterId: renter.id, + title: dto.title.trim(), + description: dto.description, + ...(dto.priority && { priority: dto.priority }), + }, + select: { + id: true, + title: true, + description: true, + status: true, + priority: true, + createdAt: true, + apartment: { select: { unitNumber: true } }, + }, + }); + + await this.timeline.emit({ + orgId, + actorId: userId, + action: 'maintenance_request.created', + targetType: 'MaintenanceRequest', + targetId: created.id, + metadata: { + title: created.title, + apartmentId: current.apartmentId, + viaTenantPortal: true, + }, + }); + + return { data: this.formatRequest(created as RequestRow) }; + } +} diff --git a/apps/api/src/modules/webhooks/webhooks.service.spec.ts b/apps/api/src/modules/webhooks/webhooks.service.spec.ts new file mode 100644 index 00000000..6ea48738 --- /dev/null +++ b/apps/api/src/modules/webhooks/webhooks.service.spec.ts @@ -0,0 +1,120 @@ +import type Stripe from 'stripe'; +import { WebhooksService } from './webhooks.service'; + +/** + * Focused on the checkout.session.completed atomicity contract (Sprint O1): + * the subscription upsert and the org activation must run inside ONE + * prisma.$transaction, subscription-first, so a mid-write failure rolls the + * whole thing back rather than half-activating an org. + */ +function makeService() { + const order: string[] = []; + const tx = { + subscription: { + upsert: jest.fn().mockImplementation(async () => { + order.push('subscription.upsert'); + }), + }, + organization: { + update: jest.fn().mockImplementation(async () => { + order.push('organization.update'); + }), + }, + }; + const prisma: any = { + // Top-level clients — assert these are NOT used for the activation writes + // (everything must go through the transactional `tx`). + subscription: { upsert: jest.fn(), findFirst: jest.fn() }, + organization: { update: jest.fn(), findFirst: jest.fn() }, + $transaction: jest.fn(async (fn: (t: typeof tx) => Promise) => + fn(tx), + ), + }; + const timeline: any = { emit: jest.fn().mockResolvedValue(undefined) }; + const keycloakAdmin: any = { + searchUsersByOrg: jest.fn().mockResolvedValue([]), + getUsersWithClientRole: jest.fn().mockResolvedValue([]), + setSingleClientRole: jest.fn().mockResolvedValue(undefined), + }; + const service = new WebhooksService(prisma, timeline, keycloakAdmin); + return { service, prisma, tx, timeline, keycloakAdmin, order }; +} + +function checkoutEvent(): Stripe.Event { + return { + id: 'evt_1', + type: 'checkout.session.completed', + data: { + object: { + id: 'cs_test_1', + metadata: { orgId: 'org-1', planKey: 'standard' }, + customer: 'cus_1', + subscription: 'sub_1', + }, + }, + } as unknown as Stripe.Event; +} + +describe('WebhooksService.handleCheckoutCompleted (atomic activation)', () => { + it('performs both writes inside ONE transaction, subscription-first', async () => { + const { service, prisma, tx, order } = makeService(); + + await service.handleEvent(checkoutEvent()); + + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(tx.subscription.upsert).toHaveBeenCalledTimes(1); + expect(tx.organization.update).toHaveBeenCalledTimes(1); + // Ordering guarantees the org is not flipped ACTIVE before the sub exists. + expect(order).toEqual(['subscription.upsert', 'organization.update']); + + // The activation writes never bypass the transaction. + expect(prisma.subscription.upsert).not.toHaveBeenCalled(); + expect(prisma.organization.update).not.toHaveBeenCalled(); + + // Correct payloads. + expect(tx.subscription.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { orgId: 'org-1' }, + create: expect.objectContaining({ + orgId: 'org-1', + stripeSubscriptionId: 'sub_1', + status: 'ACTIVE', + planKey: 'standard', + }), + }), + ); + expect(tx.organization.update).toHaveBeenCalledWith({ + where: { id: 'org-1' }, + data: { stripeCustomerId: 'cus_1', status: 'ACTIVE' }, + }); + }); + + it('rolls back (rejects, no side effects) when a write inside the transaction fails', async () => { + const { service, tx, timeline, keycloakAdmin } = makeService(); + tx.organization.update.mockRejectedValueOnce(new Error('DB write failed')); + + await expect(service.handleEvent(checkoutEvent())).rejects.toThrow( + 'DB write failed', + ); + + // The upsert was attempted, but because both are in one $transaction the + // real DB rolls it back. Crucially, nothing downstream of the transaction + // runs — no org_admin backstop, no "checkout.completed" timeline event — + // so a failed activation cannot leave partial post-activation effects. + expect(tx.subscription.upsert).toHaveBeenCalledTimes(1); + expect(keycloakAdmin.searchUsersByOrg).not.toHaveBeenCalled(); + expect(timeline.emit).not.toHaveBeenCalled(); + }); + + it('emits the timeline event only after the transaction commits', async () => { + const { service, timeline } = makeService(); + await service.handleEvent(checkoutEvent()); + expect(timeline.emit).toHaveBeenCalledWith( + expect.objectContaining({ + orgId: 'org-1', + action: 'checkout.completed', + targetType: 'Subscription', + }), + ); + }); +}); diff --git a/apps/api/src/modules/webhooks/webhooks.service.ts b/apps/api/src/modules/webhooks/webhooks.service.ts index b3f7b782..234fabd0 100644 --- a/apps/api/src/modules/webhooks/webhooks.service.ts +++ b/apps/api/src/modules/webhooks/webhooks.service.ts @@ -106,32 +106,37 @@ export class WebhooksService { typeof session.subscription === 'string' ? session.subscription : null; const planKey = session.metadata?.planKey ?? null; - // Persist customerId on org and activate - if (customerId) { - await this.prisma.organization.update({ - where: { id: orgId }, - data: { stripeCustomerId: customerId, status: 'ACTIVE' }, - }); - } + // Persist the subscription and activate the org atomically. Ordered + // subscription-first so that if any write fails the whole thing rolls back + // — a partial failure can never leave an org ACTIVE with no subscription + // row (i.e. a half-activated org that Stripe believes is paying). + await this.prisma.$transaction(async (tx) => { + if (subscriptionId) { + await tx.subscription.upsert({ + where: { orgId }, + create: { + orgId, + stripeSubscriptionId: subscriptionId, + stripeCustomerId: customerId ?? undefined, + status: 'ACTIVE', + ...(planKey ? { planKey } : {}), + }, + update: { + stripeSubscriptionId: subscriptionId, + stripeCustomerId: customerId ?? undefined, + status: 'ACTIVE', + ...(planKey ? { planKey } : {}), + }, + }); + } - if (subscriptionId) { - await this.prisma.subscription.upsert({ - where: { orgId }, - create: { - orgId, - stripeSubscriptionId: subscriptionId, - stripeCustomerId: customerId ?? undefined, - status: 'ACTIVE', - ...(planKey ? { planKey } : {}), - }, - update: { - stripeSubscriptionId: subscriptionId, - stripeCustomerId: customerId ?? undefined, - status: 'ACTIVE', - ...(planKey ? { planKey } : {}), - }, - }); - } + if (customerId) { + await tx.organization.update({ + where: { id: orgId }, + data: { stripeCustomerId: customerId, status: 'ACTIVE' }, + }); + } + }); // Idempotent backstop: ensure the org has an org_admin (provisioning already // assigns it). If the sole org member somehow lacks the role, promote them. diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 00000000..4278e410 --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,51 @@ +# syntax=docker/dockerfile:1 +# Property Manager — Web (Next.js standalone) image. Build context = repo root. +# docker build -f apps/web/Dockerfile \ +# --build-arg NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... \ +# -t /property-manager-web: . + +# ---------- builder ---------- +FROM node:22-slim AS builder +# openssl → Prisma engine (@repo/db generate runs in this build too). +RUN apt-get update && apt-get install -y --no-install-recommends \ + openssl ca-certificates && \ + rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY . . +RUN npm ci +# NEXT_PUBLIC_* are inlined into the client bundle at build time — must be present now. +ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY +ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY +# lib/env.ts validates server env with zod AT IMPORT TIME; `next build` imports route +# modules during page-data collection, so these must be set or the build throws. +# PLACEHOLDERS only — non-NEXT_PUBLIC vars are NOT inlined into the bundle; the real +# values are injected at RUNTIME via compose env_file (which overrides these). +ENV NEXTAUTH_SECRET=build-time-placeholder \ + API_URL=http://api:20101 \ + OIDC_ISSUER=https://prorentallb.cloud/keycloack/realms/prorentallb \ + OAUTH_CLIENT=prorentallb-web \ + OAUTH_SECRET=build-time-placeholder \ + KEYCLOAK_BASE=https://prorentallb.cloud/keycloack \ + KEYCLOAK_REALM=prorentallb +# Build shared workspace packages in EXPLICIT order first (see api Dockerfile note). +RUN npm run build --workspace=@repo/contracts +RUN npm run db:generate --workspace=@repo/db +RUN npm run db:build --workspace=@repo/db +# Next standalone build (output:'standalone'). +RUN cd apps/web && npm run build +# Fail loudly if the standalone server was not emitted where we expect it. +RUN test -f apps/web/.next/standalone/apps/web/server.js +# Ensure a public/ dir exists so the runtime COPY always succeeds (repo has none today). +RUN mkdir -p apps/web/public + +# ---------- runtime ---------- +FROM node:22-slim AS runtime +ENV NODE_ENV=production +ENV PORT=3000 +WORKDIR /app +# Next standalone output: self-contained server + traced node_modules. +COPY --from=builder /app/apps/web/.next/standalone ./ +COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static +COPY --from=builder /app/apps/web/public ./apps/web/public +EXPOSE 3000 +CMD ["node", "apps/web/server.js"] diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index c4b7818f..9edff1c7 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/package.json b/apps/web/package.json index d8beb81b..d976c7b7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,5 +1,5 @@ { - "name": "forward-mena-fe", + "name": "property-manager-fe", "version": "0.1.0", "private": true, "scripts": { diff --git a/apps/web/src/app/[lang]/(public)/_sections/hero-section.tsx b/apps/web/src/app/[lang]/(public)/_sections/hero-section.tsx index 9c2ed28f..e46d89ba 100644 --- a/apps/web/src/app/[lang]/(public)/_sections/hero-section.tsx +++ b/apps/web/src/app/[lang]/(public)/_sections/hero-section.tsx @@ -86,7 +86,7 @@ export function HeroSection({ className="text-sm font-semibold tracking-[0.15em] uppercase" style={{ color: 'rgba(201,163,91,0.85)' }} > - Forward Mena + {isRtl ? 'إدارة العقارات' : 'Property Manager'}
{/* Log in — WIRED IN WAVE 3 */} diff --git a/apps/web/src/app/[lang]/(public)/_sections/pricing-section.tsx b/apps/web/src/app/[lang]/(public)/_sections/pricing-section.tsx index 451e0800..84654efe 100644 --- a/apps/web/src/app/[lang]/(public)/_sections/pricing-section.tsx +++ b/apps/web/src/app/[lang]/(public)/_sections/pricing-section.tsx @@ -11,6 +11,7 @@ type Plan = { key: PlanKey; nameEn: string; nameAr: string; + price: number; buildings: number | null; users: number | null; highlighted?: boolean; @@ -44,6 +45,7 @@ export function PricingSection({ key: p.key, nameEn: p.displayName, nameAr: p.nameAr, + price: p.price, buildings: p.buildingsLimit, users: p.usersLimit, highlighted: p.highlighted, @@ -210,7 +212,7 @@ export function PricingSection({ className="text-4xl font-extrabold tracking-tight" style={{ color: '#F5F0E8' }} > - $20 + {`$${plan.price}`}

- Sign in to Forward Mena + Sign in to Property Manager

{error && (

diff --git a/apps/web/src/app/[lang]/(public)/page.tsx b/apps/web/src/app/[lang]/(public)/page.tsx index 12e479bf..e9aee253 100644 --- a/apps/web/src/app/[lang]/(public)/page.tsx +++ b/apps/web/src/app/[lang]/(public)/page.tsx @@ -142,7 +142,7 @@ export default async function LandingPage({ className="text-xs mt-1" style={{ color: 'rgba(245,240,232,0.2)' }} > - © {new Date().getFullYear()} Forward Mena.{' '} + © {new Date().getFullYear()} {dict.app.name}.{' '} {isAr ? 'جميع الحقوق محفوظة.' : 'All rights reserved.'}

diff --git a/apps/web/src/app/[lang]/dashboard/buildings/[id]/floors/[floorId]/apartments/[apartmentId]/page.tsx b/apps/web/src/app/[lang]/dashboard/buildings/[id]/floors/[floorId]/apartments/[apartmentId]/page.tsx index e23f13c5..75033546 100644 --- a/apps/web/src/app/[lang]/dashboard/buildings/[id]/floors/[floorId]/apartments/[apartmentId]/page.tsx +++ b/apps/web/src/app/[lang]/dashboard/buildings/[id]/floors/[floorId]/apartments/[apartmentId]/page.tsx @@ -3,6 +3,7 @@ import { normalizeRole } from '@/auth/roles'; import { canAccess, canWrite } from '@/auth/permissions'; import { redirect } from 'next/navigation'; import { isLocale } from '@/i18n/config'; +import { getDictionary } from '@/i18n/get-dictionary'; import { ApartmentDetailPage } from '@/components/dashboard/apartment-detail-page'; export default async function ApartmentDetailPageRoute({ @@ -25,6 +26,7 @@ export default async function ApartmentDetailPageRoute({ } const writeAccess = canWrite(role, 'buildings'); + const dict = await getDictionary(locale); return ( ); } diff --git a/apps/web/src/app/[lang]/dashboard/buildings/[id]/floors/[floorId]/page.tsx b/apps/web/src/app/[lang]/dashboard/buildings/[id]/floors/[floorId]/page.tsx index 280ab36c..e443065f 100644 --- a/apps/web/src/app/[lang]/dashboard/buildings/[id]/floors/[floorId]/page.tsx +++ b/apps/web/src/app/[lang]/dashboard/buildings/[id]/floors/[floorId]/page.tsx @@ -3,6 +3,7 @@ import { normalizeRole } from '@/auth/roles'; import { canAccess, canWrite } from '@/auth/permissions'; import { redirect } from 'next/navigation'; import { isLocale } from '@/i18n/config'; +import { getDictionary } from '@/i18n/get-dictionary'; import { FloorDetailPage } from '@/components/dashboard/floor-detail-page'; export default async function FloorDetailPageRoute({ @@ -20,6 +21,7 @@ export default async function FloorDetailPageRoute({ } const writeAccess = canWrite(role, 'buildings'); + const dict = await getDictionary(locale); return ( ); } diff --git a/apps/web/src/app/[lang]/dashboard/buildings/[id]/page.tsx b/apps/web/src/app/[lang]/dashboard/buildings/[id]/page.tsx index 8afcd1cd..fd822e73 100644 --- a/apps/web/src/app/[lang]/dashboard/buildings/[id]/page.tsx +++ b/apps/web/src/app/[lang]/dashboard/buildings/[id]/page.tsx @@ -3,6 +3,7 @@ import { normalizeRole } from '@/auth/roles'; import { canAccess, canWrite } from '@/auth/permissions'; import { redirect } from 'next/navigation'; import { isLocale } from '@/i18n/config'; +import { getDictionary } from '@/i18n/get-dictionary'; import { BuildingDetailPage } from '@/components/dashboard/building-detail-page'; export default async function BuildingDetailPageRoute({ @@ -20,12 +21,14 @@ export default async function BuildingDetailPageRoute({ } const writeAccess = canWrite(role, 'buildings'); + const dict = await getDictionary(locale); return ( ); } diff --git a/apps/web/src/app/[lang]/dashboard/buildings/page.tsx b/apps/web/src/app/[lang]/dashboard/buildings/page.tsx index eed7554e..ba661e23 100644 --- a/apps/web/src/app/[lang]/dashboard/buildings/page.tsx +++ b/apps/web/src/app/[lang]/dashboard/buildings/page.tsx @@ -3,6 +3,7 @@ import { normalizeRole } from '@/auth/roles'; import { canAccess, canWrite } from '@/auth/permissions'; import { redirect } from 'next/navigation'; import { isLocale } from '@/i18n/config'; +import { getDictionary } from '@/i18n/get-dictionary'; import { BuildingsPage } from '@/components/dashboard/buildings-page'; export default async function BuildingsPageRoute({ @@ -20,6 +21,7 @@ export default async function BuildingsPageRoute({ } const writeAccess = canWrite(role, 'buildings'); + const dict = await getDictionary(locale); - return ; + return ; } diff --git a/apps/web/src/app/[lang]/dashboard/expenses/page.tsx b/apps/web/src/app/[lang]/dashboard/expenses/page.tsx index 83ac0171..1f3d96af 100644 --- a/apps/web/src/app/[lang]/dashboard/expenses/page.tsx +++ b/apps/web/src/app/[lang]/dashboard/expenses/page.tsx @@ -3,6 +3,7 @@ import { normalizeRole } from '@/auth/roles'; import { canAccess, canWrite } from '@/auth/permissions'; import { redirect } from 'next/navigation'; import { isLocale } from '@/i18n/config'; +import { getDictionary } from '@/i18n/get-dictionary'; import { ExpensesPage } from '@/components/dashboard/expenses-page'; export default async function ExpensesPageRoute({ @@ -20,6 +21,7 @@ export default async function ExpensesPageRoute({ } const writeAccess = canWrite(role, 'expenses'); + const dict = await getDictionary(locale); // Only org_admin can read Maintenance Requests/Work Orders (GET excludes // finance), so the work-order picker in the expense dialog is org_admin-only. @@ -27,6 +29,7 @@ export default async function ExpensesPageRoute({ ); } diff --git a/apps/web/src/app/[lang]/dashboard/invoices/[id]/page.tsx b/apps/web/src/app/[lang]/dashboard/invoices/[id]/page.tsx new file mode 100644 index 00000000..b3f3efcb --- /dev/null +++ b/apps/web/src/app/[lang]/dashboard/invoices/[id]/page.tsx @@ -0,0 +1,33 @@ +import { requireSession } from '@/auth/guards'; +import { normalizeRole } from '@/auth/roles'; +import { canAccess, canWrite } from '@/auth/permissions'; +import { redirect } from 'next/navigation'; +import { isLocale } from '@/i18n/config'; +import { getDictionary } from '@/i18n/get-dictionary'; +import { InvoiceDetailPage } from '@/components/dashboard/invoice-detail-page'; + +export default async function InvoiceDetailPageRoute({ + params, +}: { + params: Promise<{ lang: string; id: string }>; +}) { + const { lang, id } = await params; + const locale = isLocale(lang) ? lang : 'en'; + const session = await requireSession({ locale }); + const role = normalizeRole(session.role ?? session.user?.role); + + if (!canAccess(role, 'invoices')) { + redirect(`/${locale}/dashboard`); + } + + const dict = await getDictionary(locale); + + return ( + + ); +} diff --git a/apps/web/src/app/[lang]/dashboard/invoices/page.tsx b/apps/web/src/app/[lang]/dashboard/invoices/page.tsx new file mode 100644 index 00000000..91cc691b --- /dev/null +++ b/apps/web/src/app/[lang]/dashboard/invoices/page.tsx @@ -0,0 +1,32 @@ +import { requireSession } from '@/auth/guards'; +import { normalizeRole } from '@/auth/roles'; +import { canAccess, canWrite } from '@/auth/permissions'; +import { redirect } from 'next/navigation'; +import { isLocale } from '@/i18n/config'; +import { getDictionary } from '@/i18n/get-dictionary'; +import { InvoicesPage } from '@/components/dashboard/invoices-page'; + +export default async function InvoicesPageRoute({ + params, +}: { + params: Promise<{ lang: string }>; +}) { + const { lang } = await params; + const locale = isLocale(lang) ? lang : 'en'; + const session = await requireSession({ locale }); + const role = normalizeRole(session.role ?? session.user?.role); + + if (!canAccess(role, 'invoices')) { + redirect(`/${locale}/dashboard`); + } + + const dict = await getDictionary(locale); + + return ( + + ); +} diff --git a/apps/web/src/app/[lang]/dashboard/leases/page.tsx b/apps/web/src/app/[lang]/dashboard/leases/page.tsx new file mode 100644 index 00000000..e82b126e --- /dev/null +++ b/apps/web/src/app/[lang]/dashboard/leases/page.tsx @@ -0,0 +1,34 @@ +import { requireSession } from '@/auth/guards'; +import { normalizeRole } from '@/auth/roles'; +import { canAccess, canWrite } from '@/auth/permissions'; +import { redirect } from 'next/navigation'; +import { isLocale } from '@/i18n/config'; +import { getDictionary } from '@/i18n/get-dictionary'; +import { LeasesPage } from '@/components/dashboard/leases-page'; + +export default async function LeasesPageRoute({ + params, +}: { + params: Promise<{ lang: string }>; +}) { + const { lang } = await params; + const locale = isLocale(lang) ? lang : 'en'; + const session = await requireSession({ locale }); + const role = normalizeRole(session.role ?? session.user?.role); + + if (!canAccess(role, 'leases')) { + // Match the sibling dashboard pages (buildings/invoices/reports/…), which + // bounce an unauthorized role back to their own dashboard home. + redirect(`/${locale}/dashboard`); + } + + const dict = await getDictionary(locale); + + return ( + + ); +} diff --git a/apps/web/src/app/[lang]/dashboard/notifications/page.tsx b/apps/web/src/app/[lang]/dashboard/notifications/page.tsx new file mode 100644 index 00000000..28b2dd09 --- /dev/null +++ b/apps/web/src/app/[lang]/dashboard/notifications/page.tsx @@ -0,0 +1,20 @@ +import { requireSession } from '@/auth/guards'; +import { isLocale } from '@/i18n/config'; +import { getDictionary } from '@/i18n/get-dictionary'; +import { NotificationsPage } from '@/components/dashboard/notifications-page'; + +// The notification inbox is personal — every authenticated dashboard user has +// one (mirrors the header bell, which is shown for all roles), so there is no +// per-area permission gate beyond requiring a session. +export default async function NotificationsPageRoute({ + params, +}: { + params: Promise<{ lang: string }>; +}) { + const { lang } = await params; + const locale = isLocale(lang) ? lang : 'en'; + await requireSession({ locale }); + const dict = await getDictionary(locale); + + return ; +} diff --git a/apps/web/src/app/[lang]/dashboard/page.tsx b/apps/web/src/app/[lang]/dashboard/page.tsx index ce39764d..a328262f 100644 --- a/apps/web/src/app/[lang]/dashboard/page.tsx +++ b/apps/web/src/app/[lang]/dashboard/page.tsx @@ -1,6 +1,7 @@ import { requireSession, requireActiveOrg } from '@/auth/guards'; import { normalizeRole } from '@/auth/roles'; import { isLocale } from '@/i18n/config'; +import { getDictionary } from '@/i18n/get-dictionary'; import { serverEnv } from '@/lib/env'; import { SessionRefresher } from '@/components/auth/session-refresher'; import { OrgAdminDashboard } from '@/components/dashboard/org-admin-dashboard'; @@ -31,6 +32,7 @@ export default async function DashboardPage({ }) { const { lang } = await params; const locale = isLocale(lang) ? lang : 'en'; + const dict = await getDictionary(locale); const session = await requireSession({ locale }); const token = session.accessToken; @@ -65,16 +67,16 @@ export default async function DashboardPage({ function renderDashboard() { if (role === 'org_admin') { - return ; + return ; } if (role === 'finance') { - return ; + return ; } if (role === 'supervisor' || role === 'maintenance') { - return ; + return ; } if (role === 'tenant') { - return ; + return ; } return ( diff --git a/apps/web/src/app/[lang]/dashboard/renters/[id]/page.tsx b/apps/web/src/app/[lang]/dashboard/renters/[id]/page.tsx index e830f3b6..a622a5f4 100644 --- a/apps/web/src/app/[lang]/dashboard/renters/[id]/page.tsx +++ b/apps/web/src/app/[lang]/dashboard/renters/[id]/page.tsx @@ -1,8 +1,9 @@ import { requireSession } from '@/auth/guards'; import { normalizeRole } from '@/auth/roles'; -import { canAccess } from '@/auth/permissions'; +import { canAccess, canWrite } from '@/auth/permissions'; import { redirect } from 'next/navigation'; import { isLocale } from '@/i18n/config'; +import { getDictionary } from '@/i18n/get-dictionary'; import { RenterDetailPage } from '@/components/dashboard/renter-detail-page'; export default async function RenterDetailPageRoute({ @@ -19,5 +20,14 @@ export default async function RenterDetailPageRoute({ redirect(`/${locale}/dashboard`); } - return ; + const dict = await getDictionary(locale); + + return ( + + ); } diff --git a/apps/web/src/app/[lang]/dashboard/renters/page.tsx b/apps/web/src/app/[lang]/dashboard/renters/page.tsx index aab7f9f8..3e77a044 100644 --- a/apps/web/src/app/[lang]/dashboard/renters/page.tsx +++ b/apps/web/src/app/[lang]/dashboard/renters/page.tsx @@ -3,6 +3,7 @@ import { normalizeRole } from '@/auth/roles'; import { canAccess, canWrite } from '@/auth/permissions'; import { redirect } from 'next/navigation'; import { isLocale } from '@/i18n/config'; +import { getDictionary } from '@/i18n/get-dictionary'; import { RentersPage } from '@/components/dashboard/renters-page'; export default async function RentersPageRoute({ @@ -20,6 +21,7 @@ export default async function RentersPageRoute({ } const writeAccess = canWrite(role, 'buildings'); + const dict = await getDictionary(locale); - return ; + return ; } diff --git a/apps/web/src/app/[lang]/dashboard/reports/page.tsx b/apps/web/src/app/[lang]/dashboard/reports/page.tsx index 83bf2838..3ecd2a5d 100644 --- a/apps/web/src/app/[lang]/dashboard/reports/page.tsx +++ b/apps/web/src/app/[lang]/dashboard/reports/page.tsx @@ -3,8 +3,8 @@ import { normalizeRole } from '@/auth/roles'; import { canAccess } from '@/auth/permissions'; import { redirect } from 'next/navigation'; import { isLocale } from '@/i18n/config'; -import { Badge } from '@/components/ui/badge'; -import { BarChart3Icon } from 'lucide-react'; +import { getDictionary } from '@/i18n/get-dictionary'; +import { ReportsPage } from '@/components/dashboard/reports-page'; export default async function ReportsPageRoute({ params, @@ -16,36 +16,12 @@ export default async function ReportsPageRoute({ const session = await requireSession({ locale }); const role = normalizeRole(session.role ?? session.user?.role); - // Only org_admin and finance may access reports + // Only org_admin and finance may access reports (supervisor/tenant blocked). if (!canAccess(role, 'reports')) { redirect(`/${locale}/dashboard`); } - return ( -
-
-

Reports

-

- Financial and operational reports for your organization. -

-
+ const dict = await getDictionary(locale); -
- -
-

Reports coming soon

-

- Revenue summaries, occupancy reports, and maintenance KPIs are being - built and will appear here in the next release. -

-
- - Coming soon - -
-
- ); + return ; } diff --git a/apps/web/src/app/[lang]/dashboard/support/page.tsx b/apps/web/src/app/[lang]/dashboard/support/page.tsx new file mode 100644 index 00000000..cf6d4bea --- /dev/null +++ b/apps/web/src/app/[lang]/dashboard/support/page.tsx @@ -0,0 +1,32 @@ +import { requireSession } from '@/auth/guards'; +import { normalizeRole } from '@/auth/roles'; +import { canAccess, canWrite } from '@/auth/permissions'; +import { redirect } from 'next/navigation'; +import { isLocale } from '@/i18n/config'; +import { getDictionary } from '@/i18n/get-dictionary'; +import { SupportPage } from '@/components/dashboard/support-page'; + +export default async function SupportPageRoute({ + params, +}: { + params: Promise<{ lang: string }>; +}) { + const { lang } = await params; + const locale = isLocale(lang) ? lang : 'en'; + const session = await requireSession({ locale }); + const role = normalizeRole(session.role ?? session.user?.role); + + if (!canAccess(role, 'support')) { + redirect(`/${locale}/dashboard`); + } + + const dict = await getDictionary(locale); + + return ( + + ); +} diff --git a/apps/web/src/app/[lang]/dashboard/tasks/[id]/page.tsx b/apps/web/src/app/[lang]/dashboard/tasks/[id]/page.tsx index 803f7668..ed05d997 100644 --- a/apps/web/src/app/[lang]/dashboard/tasks/[id]/page.tsx +++ b/apps/web/src/app/[lang]/dashboard/tasks/[id]/page.tsx @@ -3,6 +3,7 @@ import { normalizeRole } from '@/auth/roles'; import { canAccess } from '@/auth/permissions'; import { redirect } from 'next/navigation'; import { isLocale } from '@/i18n/config'; +import { getDictionary } from '@/i18n/get-dictionary'; import { MaintenanceRequestDetailPage } from '@/components/dashboard/maintenance-request-detail-page'; export default async function MaintenanceRequestDetailPageRoute({ @@ -19,6 +20,8 @@ export default async function MaintenanceRequestDetailPageRoute({ redirect(`/${locale}/dashboard`); } + const dict = await getDictionary(locale); + // Work Order create/reassign/delete is org_admin only; maintenance may // only update status/resolutionNotes on a Work Order assigned to them // (enforced in WorkOrdersService) — neither is the plain 'tasks' @@ -29,6 +32,7 @@ export default async function MaintenanceRequestDetailPageRoute({ locale={locale} canWrite={role === 'org_admin'} isMaintenanceCaller={role === 'maintenance'} + dict={dict} /> ); } diff --git a/apps/web/src/app/[lang]/dashboard/tasks/page.tsx b/apps/web/src/app/[lang]/dashboard/tasks/page.tsx index 3df68135..4371ae92 100644 --- a/apps/web/src/app/[lang]/dashboard/tasks/page.tsx +++ b/apps/web/src/app/[lang]/dashboard/tasks/page.tsx @@ -3,6 +3,7 @@ import { normalizeRole } from '@/auth/roles'; import { canAccess } from '@/auth/permissions'; import { redirect } from 'next/navigation'; import { isLocale } from '@/i18n/config'; +import { getDictionary } from '@/i18n/get-dictionary'; import { TasksPage } from '@/components/dashboard/tasks-page'; export default async function TasksPageRoute({ @@ -19,8 +20,12 @@ export default async function TasksPageRoute({ redirect(`/${locale}/dashboard`); } + const dict = await getDictionary(locale); + // Maintenance Request writes are org_admin only — 'tasks' is 'full' for // maintenance at the page-permission level, but that access doesn't // extend to MR create/edit/delete (enforced in MaintenanceRequestsService). - return ; + return ( + + ); } diff --git a/apps/web/src/app/[lang]/dashboard/users/page.tsx b/apps/web/src/app/[lang]/dashboard/users/page.tsx index 3f16d720..028ad68e 100644 --- a/apps/web/src/app/[lang]/dashboard/users/page.tsx +++ b/apps/web/src/app/[lang]/dashboard/users/page.tsx @@ -26,11 +26,5 @@ export default async function UsersPageRoute({ // supervisor gets read-only view; org_admin gets full CRUD const readonly = role !== 'org_admin'; - return ( - } - readonly={readonly} - /> - ); + return ; } diff --git a/apps/web/src/app/[lang]/dashboard/vendors/page.tsx b/apps/web/src/app/[lang]/dashboard/vendors/page.tsx index 84609004..5375362d 100644 --- a/apps/web/src/app/[lang]/dashboard/vendors/page.tsx +++ b/apps/web/src/app/[lang]/dashboard/vendors/page.tsx @@ -3,6 +3,7 @@ import { normalizeRole } from '@/auth/roles'; import { canAccess, canWrite } from '@/auth/permissions'; import { redirect } from 'next/navigation'; import { isLocale } from '@/i18n/config'; +import { getDictionary } from '@/i18n/get-dictionary'; import { VendorsPage } from '@/components/dashboard/vendors-page'; export default async function VendorsPageRoute({ @@ -20,6 +21,7 @@ export default async function VendorsPageRoute({ } const writeAccess = canWrite(role, 'vendors'); + const dict = await getDictionary(locale); - return ; + return ; } diff --git a/apps/web/src/app/[lang]/layout.tsx b/apps/web/src/app/[lang]/layout.tsx index 441bc03c..69beeecd 100644 --- a/apps/web/src/app/[lang]/layout.tsx +++ b/apps/web/src/app/[lang]/layout.tsx @@ -26,7 +26,7 @@ const notoKufiArabic = Noto_Kufi_Arabic({ }); export const metadata: Metadata = { - title: 'Forward Mena — Property Management', + title: 'Property Manager — Rental Management', description: 'Multi-org property management SaaS for modern landlords', }; diff --git a/apps/web/src/app/api/invoice-payments/[id]/route.ts b/apps/web/src/app/api/invoice-payments/[id]/route.ts new file mode 100644 index 00000000..5b39e14d --- /dev/null +++ b/apps/web/src/app/api/invoice-payments/[id]/route.ts @@ -0,0 +1,8 @@ +import { forwardRoute } from '@/lib/api/forward'; + +export const runtime = 'nodejs'; + +export const GET = forwardRoute((params) => `/invoice-payments/${params.id}`); +export const DELETE = forwardRoute( + (params) => `/invoice-payments/${params.id}`, +); diff --git a/apps/web/src/app/api/invoice-payments/route.ts b/apps/web/src/app/api/invoice-payments/route.ts new file mode 100644 index 00000000..0d05c161 --- /dev/null +++ b/apps/web/src/app/api/invoice-payments/route.ts @@ -0,0 +1,6 @@ +import { forwardRoute } from '@/lib/api/forward'; + +export const runtime = 'nodejs'; + +export const GET = forwardRoute('/invoice-payments'); +export const POST = forwardRoute('/invoice-payments'); diff --git a/apps/web/src/app/api/invoices/[id]/route.ts b/apps/web/src/app/api/invoices/[id]/route.ts new file mode 100644 index 00000000..d1b39fcb --- /dev/null +++ b/apps/web/src/app/api/invoices/[id]/route.ts @@ -0,0 +1,7 @@ +import { forwardRoute } from '@/lib/api/forward'; + +export const runtime = 'nodejs'; + +export const GET = forwardRoute((params) => `/invoices/${params.id}`); +export const PATCH = forwardRoute((params) => `/invoices/${params.id}`); +export const DELETE = forwardRoute((params) => `/invoices/${params.id}`); diff --git a/apps/web/src/app/api/invoices/route.ts b/apps/web/src/app/api/invoices/route.ts new file mode 100644 index 00000000..a834534d --- /dev/null +++ b/apps/web/src/app/api/invoices/route.ts @@ -0,0 +1,6 @@ +import { forwardRoute } from '@/lib/api/forward'; + +export const runtime = 'nodejs'; + +export const GET = forwardRoute('/invoices'); +export const POST = forwardRoute('/invoices'); diff --git a/apps/web/src/app/api/leases/route.ts b/apps/web/src/app/api/leases/route.ts new file mode 100644 index 00000000..d2995f9d --- /dev/null +++ b/apps/web/src/app/api/leases/route.ts @@ -0,0 +1,5 @@ +import { forwardRoute } from '@/lib/api/forward'; + +export const runtime = 'nodejs'; + +export const GET = forwardRoute('/leases'); diff --git a/apps/web/src/app/api/notifications/[id]/read/route.ts b/apps/web/src/app/api/notifications/[id]/read/route.ts new file mode 100644 index 00000000..b0b7423d --- /dev/null +++ b/apps/web/src/app/api/notifications/[id]/read/route.ts @@ -0,0 +1,5 @@ +import { forwardRoute } from '@/lib/api/forward'; + +export const runtime = 'nodejs'; + +export const POST = forwardRoute((params) => `/notifications/${params.id}/read`); diff --git a/apps/web/src/app/api/notifications/read-all/route.ts b/apps/web/src/app/api/notifications/read-all/route.ts new file mode 100644 index 00000000..25a6ad83 --- /dev/null +++ b/apps/web/src/app/api/notifications/read-all/route.ts @@ -0,0 +1,5 @@ +import { forwardRoute } from '@/lib/api/forward'; + +export const runtime = 'nodejs'; + +export const POST = forwardRoute('/notifications/read-all'); diff --git a/apps/web/src/app/api/notifications/route.ts b/apps/web/src/app/api/notifications/route.ts new file mode 100644 index 00000000..cab05d6c --- /dev/null +++ b/apps/web/src/app/api/notifications/route.ts @@ -0,0 +1,5 @@ +import { forwardRoute } from '@/lib/api/forward'; + +export const runtime = 'nodejs'; + +export const GET = forwardRoute('/notifications'); diff --git a/apps/web/src/app/api/notifications/unread-count/route.ts b/apps/web/src/app/api/notifications/unread-count/route.ts new file mode 100644 index 00000000..e571b6ae --- /dev/null +++ b/apps/web/src/app/api/notifications/unread-count/route.ts @@ -0,0 +1,5 @@ +import { forwardRoute } from '@/lib/api/forward'; + +export const runtime = 'nodejs'; + +export const GET = forwardRoute('/notifications/unread-count'); diff --git a/apps/web/src/app/api/reports/overdue/route.ts b/apps/web/src/app/api/reports/overdue/route.ts new file mode 100644 index 00000000..ae68e6c2 --- /dev/null +++ b/apps/web/src/app/api/reports/overdue/route.ts @@ -0,0 +1,5 @@ +import { forwardRoute } from '@/lib/api/forward'; + +export const runtime = 'nodejs'; + +export const GET = forwardRoute('/reports/overdue'); diff --git a/apps/web/src/app/api/reports/rent-roll/route.ts b/apps/web/src/app/api/reports/rent-roll/route.ts new file mode 100644 index 00000000..57267855 --- /dev/null +++ b/apps/web/src/app/api/reports/rent-roll/route.ts @@ -0,0 +1,5 @@ +import { forwardRoute } from '@/lib/api/forward'; + +export const runtime = 'nodejs'; + +export const GET = forwardRoute('/reports/rent-roll'); diff --git a/apps/web/src/app/api/reports/summary/route.ts b/apps/web/src/app/api/reports/summary/route.ts new file mode 100644 index 00000000..18ad7295 --- /dev/null +++ b/apps/web/src/app/api/reports/summary/route.ts @@ -0,0 +1,5 @@ +import { forwardRoute } from '@/lib/api/forward'; + +export const runtime = 'nodejs'; + +export const GET = forwardRoute('/reports/summary'); diff --git a/apps/web/src/app/api/support-tickets/[id]/route.ts b/apps/web/src/app/api/support-tickets/[id]/route.ts new file mode 100644 index 00000000..c34c817c --- /dev/null +++ b/apps/web/src/app/api/support-tickets/[id]/route.ts @@ -0,0 +1,6 @@ +import { forwardRoute } from '@/lib/api/forward'; + +export const runtime = 'nodejs'; + +export const GET = forwardRoute((params) => `/support-tickets/${params.id}`); +export const PATCH = forwardRoute((params) => `/support-tickets/${params.id}`); diff --git a/apps/web/src/app/api/support-tickets/route.ts b/apps/web/src/app/api/support-tickets/route.ts new file mode 100644 index 00000000..0c5bb8d2 --- /dev/null +++ b/apps/web/src/app/api/support-tickets/route.ts @@ -0,0 +1,6 @@ +import { forwardRoute } from '@/lib/api/forward'; + +export const runtime = 'nodejs'; + +export const GET = forwardRoute('/support-tickets'); +export const POST = forwardRoute('/support-tickets'); diff --git a/apps/web/src/app/api/tenant/maintenance-requests/route.ts b/apps/web/src/app/api/tenant/maintenance-requests/route.ts new file mode 100644 index 00000000..0c523531 --- /dev/null +++ b/apps/web/src/app/api/tenant/maintenance-requests/route.ts @@ -0,0 +1,5 @@ +import { forwardRoute } from '@/lib/api/forward'; + +export const runtime = 'nodejs'; + +export const POST = forwardRoute('/tenant/maintenance-requests'); diff --git a/apps/web/src/app/api/tenant/overview/route.ts b/apps/web/src/app/api/tenant/overview/route.ts new file mode 100644 index 00000000..c96db9de --- /dev/null +++ b/apps/web/src/app/api/tenant/overview/route.ts @@ -0,0 +1,5 @@ +import { forwardRoute } from '@/lib/api/forward'; + +export const runtime = 'nodejs'; + +export const GET = forwardRoute('/tenant/overview'); diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 83a6976e..0f90fdab 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -48,7 +48,7 @@ } :root { - /* Forward-Mena brand tokens — premium charcoal-navy + warm gold */ + /* Property Manager brand tokens — premium charcoal-navy + warm gold */ --fm-navy: #0D1B2A; --fm-navy-mid: #142233; --fm-navy-lt: #1C2F42; diff --git a/apps/web/src/auth/permissions.ts b/apps/web/src/auth/permissions.ts index 685879d6..41f7cc9c 100644 --- a/apps/web/src/auth/permissions.ts +++ b/apps/web/src/auth/permissions.ts @@ -19,6 +19,16 @@ * "expenses" → /dashboard/expenses (not building-scoped at the area * level either — a supervisor's building-scoping for * Expense is enforced server-side, not via this matrix) + * "invoices" → /dashboard/invoices (covers both Invoices and Invoice + * Payments together, same as "tasks" covering Maintenance + * Requests + Work Orders; distinct from "payments"/"billing", + * which are the platform's own Stripe subscription billing) + * "leases" → /dashboard/leases (org-wide, top-level lease list; mirrors + * "buildings" for access — full lease CRUD still lives under + * /dashboard/buildings/:id/floors/:id/apartments/:id) + * "notifications" → /dashboard/notifications (a personal inbox, like the + * header bell — every role gets 'full', there is no + * restricted view of someone else's notifications) */ import type { Role } from '@/auth/roles'; @@ -33,7 +43,11 @@ export type DashboardArea = | 'timeline' | 'tasks' | 'vendors' - | 'expenses'; + | 'expenses' + | 'invoices' + | 'support' + | 'leases' + | 'notifications'; /** * Per-role access level for an area. @@ -57,6 +71,10 @@ export const PERMISSION_MATRIX: PermissionMatrix = { tasks: 'full', vendors: 'full', expenses: 'full', + invoices: 'full', + support: 'full', + leases: 'full', + notifications: 'full', }, supervisor: { dashboard: 'readonly', @@ -69,6 +87,10 @@ export const PERMISSION_MATRIX: PermissionMatrix = { tasks: 'readonly', vendors: 'readonly', expenses: 'readonly', + invoices: 'readonly', + support: 'full', + leases: 'readonly', + notifications: 'full', }, finance: { dashboard: 'readonly', @@ -81,6 +103,10 @@ export const PERMISSION_MATRIX: PermissionMatrix = { tasks: 'none', vendors: 'readonly', expenses: 'full', + invoices: 'full', + support: 'readonly', + leases: 'readonly', + notifications: 'full', }, maintenance: { dashboard: 'readonly', @@ -93,6 +119,10 @@ export const PERMISSION_MATRIX: PermissionMatrix = { tasks: 'full', vendors: 'readonly', expenses: 'none', + invoices: 'none', + support: 'readonly', + leases: 'readonly', + notifications: 'full', }, tenant: { dashboard: 'readonly', @@ -105,6 +135,13 @@ export const PERMISSION_MATRIX: PermissionMatrix = { tasks: 'none', vendors: 'none', expenses: 'none', + invoices: 'none', + // readonly = the tenant sees the Support area and can open tickets (the + // "New ticket" button is unconditional), but NOT the staff-only status + // transition actions (acknowledge/resolve/close), which the API 403s anyway. + support: 'readonly', + leases: 'none', + notifications: 'full', }, }; diff --git a/apps/web/src/auth/roles.ts b/apps/web/src/auth/roles.ts index 6c483243..d2730ce1 100644 --- a/apps/web/src/auth/roles.ts +++ b/apps/web/src/auth/roles.ts @@ -1,4 +1,4 @@ -// Forward-Mena roles — matches Keycloak realm roles in prorentallb realm. +// Property Manager roles — matches Keycloak realm roles in prorentallb realm. // org_admin is the highest; tenant is the most restricted (self-only). export const ROLES = [ 'org_admin', diff --git a/apps/web/src/components/billing/billing-shell.tsx b/apps/web/src/components/billing/billing-shell.tsx index 9dd80793..fc0b6364 100644 --- a/apps/web/src/components/billing/billing-shell.tsx +++ b/apps/web/src/components/billing/billing-shell.tsx @@ -27,6 +27,7 @@ type PlanDisplay = { key: PlanKey; nameEn: string; nameAr: string; + price: number; buildings: number | null; users: number | null; highlighted?: boolean; @@ -56,6 +57,7 @@ export function BillingShell({ key: p.key, nameEn: p.displayName, nameAr: p.nameAr, + price: p.price, buildings: p.buildingsLimit, users: p.usersLimit, highlighted: p.highlighted, @@ -194,7 +196,7 @@ export function BillingShell({ className="text-2xl font-extrabold tracking-tight" style={{ color: '#F5F0E8' }} > - $20 + {`$${plan.price}`}
- Occupied + {labels.occupied} ); case 'maintenance': @@ -83,20 +90,26 @@ function ApartmentStatusBadge({ status }: { status: ApartmentStatus }) { variant="outline" className="bg-amber-50 text-amber-800 border-amber-200" > - Maintenance + {labels.maintenance} ); case 'unavailable': - return Unavailable; + return {labels.unavailable}; case 'vacant': default: - return Vacant; + return {labels.vacant}; } } -// ── Lease effective-status badge ──────────────────────────────────────────── +// ── Lease effective-status badge (reuses dict.leases.status) ─────────────── -function LeaseStatusBadge({ status }: { status: LeaseStatus }) { +function LeaseStatusBadge({ + status, + labels, +}: { + status: LeaseStatus; + labels: Dictionary['leases']['status']; +}) { switch (status) { case 'active': return ( @@ -104,7 +117,7 @@ function LeaseStatusBadge({ status }: { status: LeaseStatus }) { variant="default" className="bg-green-100 text-green-800 border-green-200" > - Active + {labels.active} ); case 'expired': @@ -113,42 +126,48 @@ function LeaseStatusBadge({ status }: { status: LeaseStatus }) { variant="outline" className="bg-amber-50 text-amber-800 border-amber-200" > - Expired + {labels.expired} ); case 'terminated': - return Terminated; + return {labels.terminated}; case 'draft': default: - return Draft; + return {labels.draft}; } } -// ── Zod schema ─────────────────────────────────────────────────────────────── +// ── Zod schemas (built from dict so error messages are localized) ────────── -const numericField = (label: string) => - z - .string() - .refine((v) => v.trim() !== '' && !Number.isNaN(Number(v)), { - message: `${label} must be a number`, +type DialogDict = Dictionary['apartments']['dialog']; + +function buildLeaseSchema(t: DialogDict) { + const numericField = (label: string) => + z + .string() + .refine((v) => v.trim() !== '' && !Number.isNaN(Number(v)), { + message: t.errors.mustBeNumber.replace('{label}', label), + }) + .refine((v) => Number(v) >= 0, { + message: t.errors.cannotBeNegative.replace('{label}', label), + }); + + return z + .object({ + renterId: z.string().min(1, t.errors.renter), + startDate: z.string().min(1, t.errors.startDate), + endDate: z.string().min(1, t.errors.endDate), + rentAmount: numericField(t.fields.rentAmount), + depositAmount: numericField(t.fields.depositAmount), + renewalTerms: z.string().optional(), + notes: z.string().optional(), }) - .refine((v) => Number(v) >= 0, { message: `${label} cannot be negative` }); - -const leaseSchema = z - .object({ - renterId: z.string().min(1, 'Renter is required'), - startDate: z.string().min(1, 'Start date is required'), - endDate: z.string().min(1, 'End date is required'), - rentAmount: numericField('Rent amount'), - depositAmount: numericField('Deposit amount'), - renewalTerms: z.string().optional(), - notes: z.string().optional(), - }) - .refine((v) => v.endDate >= v.startDate, { - message: 'End date must be on or after the start date', - path: ['endDate'], - }); -type LeaseFormValues = z.infer; + .refine((v) => v.endDate >= v.startDate, { + message: t.errors.endAfterStart, + path: ['endDate'], + }); +} +type LeaseFormValues = z.infer>; const DEFAULT_VALUES: LeaseFormValues = { renterId: '', @@ -160,20 +179,32 @@ const DEFAULT_VALUES: LeaseFormValues = { notes: '', }; -const renewSchema = z - .object({ - startDate: z.string().min(1, 'Start date is required'), - endDate: z.string().min(1, 'End date is required'), - rentAmount: numericField('Rent amount'), - depositAmount: numericField('Deposit amount'), - renewalTerms: z.string().optional(), - notes: z.string().optional(), - }) - .refine((v) => v.endDate >= v.startDate, { - message: 'End date must be on or after the start date', - path: ['endDate'], - }); -type RenewFormValues = z.infer; +function buildRenewSchema(t: DialogDict) { + const numericField = (label: string) => + z + .string() + .refine((v) => v.trim() !== '' && !Number.isNaN(Number(v)), { + message: t.errors.mustBeNumber.replace('{label}', label), + }) + .refine((v) => Number(v) >= 0, { + message: t.errors.cannotBeNegative.replace('{label}', label), + }); + + return z + .object({ + startDate: z.string().min(1, t.errors.startDate), + endDate: z.string().min(1, t.errors.endDate), + rentAmount: numericField(t.fields.rentAmount), + depositAmount: numericField(t.fields.depositAmount), + renewalTerms: z.string().optional(), + notes: z.string().optional(), + }) + .refine((v) => v.endDate >= v.startDate, { + message: t.errors.endAfterStart, + path: ['endDate'], + }); +} +type RenewFormValues = z.infer>; // ── Main component ──────────────────────────────────────────────────────────── @@ -183,6 +214,7 @@ interface ApartmentDetailPageProps { apartmentId: string; canWrite: boolean; locale: string; + dict: Dictionary; } export function ApartmentDetailPage({ @@ -191,7 +223,9 @@ export function ApartmentDetailPage({ apartmentId, canWrite, locale, + dict, }: ApartmentDetailPageProps) { + const t = dict.apartments; const { data: building } = useGetBuildingQuery(buildingId); const { data: floor } = useGetFloorQuery({ buildingId, floorId }); const { data: apartment, isLoading: apartmentLoading } = useGetApartmentQuery( @@ -213,6 +247,9 @@ export function ApartmentDetailPage({ ); const [renewTarget, setRenewTarget] = useState(null); + const leaseSchema = useMemo(() => buildLeaseSchema(t.dialog), [t.dialog]); + const renewSchema = useMemo(() => buildRenewSchema(t.dialog), [t.dialog]); + const { register, handleSubmit, @@ -249,12 +286,12 @@ export function ApartmentDetailPage({ notes: values.notes || undefined, }, }).unwrap(); - toast.success('Lease created.'); + toast.success(t.dialog.create.success); setCreateOpen(false); reset(DEFAULT_VALUES); } catch (err: unknown) { const apiErr = err as { data?: { message?: string } }; - toast.error(apiErr?.data?.message ?? 'Failed to create lease.'); + toast.error(apiErr?.data?.message ?? t.dialog.create.genericError); } } @@ -268,11 +305,11 @@ export function ApartmentDetailPage({ leaseId: terminateTarget.id, body: { status: 'terminated' }, }).unwrap(); - toast.success('Lease terminated.'); + toast.success(t.dialog.terminate.success); setTerminateTarget(null); } catch (err: unknown) { const apiErr = err as { data?: { message?: string } }; - toast.error(apiErr?.data?.message ?? 'Failed to terminate lease.'); + toast.error(apiErr?.data?.message ?? t.dialog.terminate.genericError); } } @@ -305,11 +342,11 @@ export function ApartmentDetailPage({ notes: values.notes || undefined, }, }).unwrap(); - toast.success('Lease renewed.'); + toast.success(t.dialog.renew.success); setRenewTarget(null); } catch (err: unknown) { const apiErr = err as { data?: { message?: string } }; - toast.error(apiErr?.data?.message ?? 'Failed to renew lease.'); + toast.error(apiErr?.data?.message ?? t.dialog.renew.genericError); } } @@ -330,34 +367,37 @@ export function ApartmentDetailPage({ className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground w-fit" > - {floor?.name ?? 'Back to floor'} + {floor?.name ?? t.header.backToFloor}

- Unit {apartment?.unitNumber ?? ''} + {t.header.unit} {apartment?.unitNumber ?? ''}

- {apartment && } + {apartment && ( + + )}
-

Building

+

{t.info.building}

{building?.name ?? '—'}

-

Bed / Bath

+

{t.info.bedBath}

- {apartment?.bedrooms} bd / {Number(apartment?.bathrooms)} ba + {apartment?.bedrooms} {t.info.bd} / {Number(apartment?.bathrooms)}{' '} + {t.info.ba}

-

Sqft

+

{t.info.sqft}

{apartment?.sqft ?? '—'}

-

Notes

+

{t.info.notes}

{apartment?.notes ?? '—'}

@@ -367,11 +407,11 @@ export function ApartmentDetailPage({ {/* Leases section */}
-

Leases

+

+ {t.lease.title} +

- {canWrite - ? 'Manage leases for this apartment.' - : 'Lease history for this apartment.'} + {canWrite ? t.lease.subtitle : t.lease.subtitleReadOnly}

{canWrite && ( @@ -382,7 +422,7 @@ export function ApartmentDetailPage({ }} > - New lease + {t.lease.newLease} )}
@@ -391,10 +431,10 @@ export function ApartmentDetailPage({ - Renter - Dates - Rent - Status + {t.lease.table.renter} + {t.lease.table.dates} + {t.lease.table.rent} + {t.lease.table.status} {canWrite && } @@ -426,7 +466,7 @@ export function ApartmentDetailPage({ className="text-center py-10 text-muted-foreground" > - No leases yet. + {canWrite ? t.lease.emptyWrite : t.lease.empty} ) : ( @@ -435,9 +475,12 @@ export function ApartmentDetailPage({ return ( - + {renter?.fullName ?? lease.renterId} - + {new Date(lease.startDate).toLocaleDateString()} –{' '} @@ -447,7 +490,10 @@ export function ApartmentDetailPage({ {lease.rentAmount} - + {canWrite && ( @@ -458,7 +504,7 @@ export function ApartmentDetailPage({ @@ -469,14 +515,14 @@ export function ApartmentDetailPage({ onClick={() => openRenew(lease)} > - Renew + {t.lease.renewAction} setTerminateTarget(lease)} > - Terminate + {t.lease.terminateAction} @@ -495,7 +541,7 @@ export function ApartmentDetailPage({ - New lease + {t.dialog.create.title}
(