diff --git a/AGENTS.md b/AGENTS.md index 14cef52e..3eb361de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,9 +82,10 @@ 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** (3700 symbols, 7360 relationships, 139 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **bootcamp-starter** (4026 symbols, 7977 relationships, 149 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. @@ -105,22 +106,22 @@ This project is indexed by GitNexus as **bootcamp-starter** (3700 symbols, 7360 ## 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 index 7d74d0c3..7c6e078c 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -1,20 +1,36 @@ # syntax=docker/dockerfile:1 # Property Manager — API (NestJS) image. Build context = repo root. # docker build -f apps/api/Dockerfile -t /property-manager-api: . +# +# Slim, turbo-pruned build. `turbo prune property-manager-be --docker` carves out +# ONLY the api plus its internal workspace deps (@repo/db, @repo/contracts) and a +# pruned lockfile — so the WEB workspace's ~700MB of deps (next, @next, @img, +# lucide-react, …) never enter this image. A final `npm prune --omit=dev` then +# strips the build toolchain (typescript, nest cli, prisma CLI, jest, …). The +# generated Prisma client is preserved across that prune (see note below). -# ---------- builder ---------- +# ---------- pruner: carve the api subset out of the monorepo ---------- +FROM node:22-slim AS pruner +WORKDIR /app +COPY . . +# node_modules is dockerignored here, so fetch a pinned turbo to run the prune +# (prune only reads package.json files + the lockfile — no install needed). +RUN npx --yes turbo@2.10.0 prune property-manager-be --docker + +# ---------- builder: install pruned deps + build ---------- 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.) +# openssl → Prisma engine. RUN apt-get update && apt-get install -y --no-install-recommends \ openssl ca-certificates && \ rm -rf /var/lib/apt/lists/* WORKDIR /app -COPY . . +# Install against the pruned lockfile first (keeps this layer cacheable). +COPY --from=pruner /app/out/json/ . 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. +# Bring in the pruned source, then build shared packages in EXPLICIT order. +# (apps/api now declares @repo/contracts, so `turbo build` would order this too, +# but the explicit sequence keeps the image build self-contained and legible.) +COPY --from=pruner /app/out/full/ . RUN npm run build --workspace=@repo/contracts RUN npm run db:generate --workspace=@repo/db RUN npm run db:build --workspace=@repo/db @@ -22,6 +38,13 @@ RUN npm run db:build --workspace=@repo/db RUN cd apps/api && npm run build # Fail loudly if the Nest build did not emit. RUN test -f apps/api/dist/main.js +# Strip devDependencies. `npm prune` also deletes the UNTRACKED generated Prisma +# client at node_modules/.prisma, so back it up and restore it afterwards +# (@prisma/client itself is a prod dep and survives). tsconfig-paths is a prod +# dep — start:prod registers it at runtime. +RUN cp -r node_modules/.prisma /tmp/dot-prisma +RUN npm prune --omit=dev +RUN rm -rf node_modules/.prisma && cp -r /tmp/dot-prisma node_modules/.prisma && rm -rf /tmp/dot-prisma # ---------- runtime ---------- FROM node:22-slim AS runtime @@ -30,9 +53,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ 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 the pruned+built monorepo subset (api + @repo/db + @repo/contracts, prod deps). COPY --from=builder /app ./ EXPOSE 20101 # start:prod = node -e "require('tsconfig-paths').register({baseUrl:'./dist',...}); require('./dist/main')" diff --git a/apps/api/package.json b/apps/api/package.json index 48ed4a09..57ceb086 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -32,6 +32,7 @@ "@nestjs/terminus": "^11.1.1", "@nestjs/throttler": "^6.5.0", "@prisma/adapter-pg": "^7.8.0", + "@repo/contracts": "*", "@repo/db": "*", "axios": "^1.15.2", "bullmq": "^5.80.6", @@ -52,7 +53,8 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "stripe": "^22.1.1", - "swagger-ui-express": "^5.0.1" + "swagger-ui-express": "^5.0.1", + "tsconfig-paths": "^4.2.0" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -82,7 +84,6 @@ "ts-jest": "^29.2.5", "ts-loader": "^9.5.2", "ts-node": "^10.9.2", - "tsconfig-paths": "^4.2.0", "typescript": "^5.7.3", "typescript-eslint": "^8.20.0" }, diff --git a/apps/api/src/modules/invoice-payments/invoice-payments.controller.ts b/apps/api/src/modules/invoice-payments/invoice-payments.controller.ts index 4726fe6b..d268e221 100644 --- a/apps/api/src/modules/invoice-payments/invoice-payments.controller.ts +++ b/apps/api/src/modules/invoice-payments/invoice-payments.controller.ts @@ -24,19 +24,63 @@ export class InvoicePaymentsController { private readonly orgScope: OrgScopeService, ) {} + /** + * Org-wide rent-payment register (paginated). Also serves the invoice-detail + * payment list via `invoiceId`. A supervisor is narrowed to their assigned + * buildings inside the service. + */ @Roles(Role.ORG_ADMIN, Role.FINANCE, Role.SUPERVISOR) @Get() async getInvoicePayments( @CurrentUser() user: AuthenticatedUser, @Query('invoiceId') invoiceId?: string, + @Query('buildingId') buildingId?: string, + @Query('renterId') renterId?: string, + @Query('method') method?: string, + @Query('from') from?: string, + @Query('to') to?: string, + @Query('q') q?: string, + @Query('page') page?: string, + @Query('limit') limit?: string, ) { const { orgId, role } = await this.orgScope.resolveForCaller(user); - return this.invoicePaymentsService.findAll( - orgId, - user.sub, - role, + return this.invoicePaymentsService.findAll(orgId, user.sub, role, { invoiceId, - ); + buildingId, + renterId, + method, + from, + to, + q, + page: page ? Number(page) : undefined, + limit: limit ? Number(limit) : undefined, + }); + } + + /** + * Declared BEFORE `:id` — Nest matches routes in declaration order, so the + * dynamic param would otherwise swallow `/summary`. + */ + @Roles(Role.ORG_ADMIN, Role.FINANCE, Role.SUPERVISOR) + @Get('summary') + async getInvoicePaymentSummary( + @CurrentUser() user: AuthenticatedUser, + @Query('buildingId') buildingId?: string, + @Query('renterId') renterId?: string, + @Query('method') method?: string, + @Query('from') from?: string, + @Query('to') to?: string, + @Query('q') q?: string, + ) { + const { orgId, role } = await this.orgScope.resolveForCaller(user); + return this.invoicePaymentsService.summary(orgId, user.sub, role, { + buildingId, + renterId, + method, + from, + to, + q, + }); } @Roles(Role.ORG_ADMIN, Role.FINANCE, Role.SUPERVISOR) 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 index 761ed460..8705c052 100644 --- a/apps/api/src/modules/invoice-payments/invoice-payments.service.spec.ts +++ b/apps/api/src/modules/invoice-payments/invoice-payments.service.spec.ts @@ -15,6 +15,7 @@ describe('InvoicePaymentsService', () => { overrides: { invoicePayment?: Partial>; invoice?: Partial>; + building?: Partial>; buildingAccess?: Partial>; notifications?: Partial>; } = {}, @@ -23,14 +24,24 @@ describe('InvoicePaymentsService', () => { invoicePayment: { findFirst: jest.fn().mockResolvedValue(null), findMany: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + aggregate: jest + .fn() + .mockResolvedValue({ _sum: { amount: null }, _count: { _all: 0 } }), + groupBy: jest.fn().mockResolvedValue([]), create: jest.fn(), delete: jest.fn(), ...overrides.invoicePayment, }, invoice: { findFirst: jest.fn().mockResolvedValue(null), + findMany: jest.fn().mockResolvedValue([]), ...overrides.invoice, }, + building: { + findMany: jest.fn().mockResolvedValue([]), + ...overrides.building, + }, }; const buildingAccess = { getAllowedBuildingIds: jest.fn().mockResolvedValue(null), @@ -78,11 +89,30 @@ describe('InvoicePaymentsService', () => { ...overrides, }); + /** A payment row as returned with ENRICHED_INCLUDE (the register list shape). */ + const enrichedRow = (overrides: Partial> = {}) => ({ + ...paymentRow(), + invoice: { + buildingId, + leaseId: 'lease-1', + dueDate: new Date('2099-02-01T00:00:00.000Z'), + lineItems: [{ amount: decimal('1000.00') }], + payments: [{ amount: decimal('500.00') }], + lease: { + renterId: 'renter-1', + renter: { fullName: 'Sara Haddad' }, + apartment: { unitNumber: 'G-01' }, + }, + }, + ...overrides, + }); + describe('findAll', () => { it('sees the full org for a finance caller', async () => { const { service, prisma, buildingAccess } = makeService({ invoicePayment: { - findMany: jest.fn().mockResolvedValue([paymentRow()]), + findMany: jest.fn().mockResolvedValue([enrichedRow()]), + count: jest.fn().mockResolvedValue(1), }, }); @@ -120,7 +150,9 @@ describe('InvoicePaymentsService', () => { it('filters by invoiceId when provided', async () => { const { service, prisma } = makeService(); - await service.findAll(orgId, callerId, Role.ORG_ADMIN, 'invoice-1'); + await service.findAll(orgId, callerId, Role.ORG_ADMIN, { + invoiceId: 'invoice-1', + }); expect(prisma.invoicePayment.findMany).toHaveBeenCalledWith( expect.objectContaining({ @@ -128,6 +160,307 @@ describe('InvoicePaymentsService', () => { }), ); }); + + it('returns the paginated { items, total, page, limit } envelope', async () => { + const { service } = makeService({ + invoicePayment: { + findMany: jest.fn().mockResolvedValue([enrichedRow()]), + count: jest.fn().mockResolvedValue(7), + }, + }); + + const result = await service.findAll(orgId, callerId, Role.ORG_ADMIN, { + page: 2, + limit: 5, + }); + + expect(result.total).toBe(7); + expect(result.page).toBe(2); + expect(result.limit).toBe(5); + expect(result.items).toHaveLength(1); + }); + + it('clamps limit to the maximum and page to at least 1', async () => { + const { service, prisma } = makeService(); + + const result = await service.findAll(orgId, callerId, Role.ORG_ADMIN, { + page: 0, + limit: 5000, + }); + + expect(result.page).toBe(1); + expect(result.limit).toBe(100); + expect(prisma.invoicePayment.findMany).toHaveBeenCalledWith( + expect.objectContaining({ skip: 0, take: 100 }), + ); + }); + + it('enriches each row with renter, unit, building and parent-invoice state', async () => { + const { service } = makeService({ + invoicePayment: { + findMany: jest.fn().mockResolvedValue([enrichedRow()]), + count: jest.fn().mockResolvedValue(1), + }, + building: { + findMany: jest + .fn() + .mockResolvedValue([{ id: buildingId, name: 'Al Manar' }]), + }, + }); + + const [row] = (await service.findAll(orgId, callerId, Role.ORG_ADMIN, {})) + .items; + + expect(row).toEqual( + expect.objectContaining({ + id: 'payment-1', + amount: '500.00', + method: 'cash', + leaseId: 'lease-1', + renterId: 'renter-1', + renterName: 'Sara Haddad', + buildingId, + buildingName: 'Al Manar', + apartmentUnitNumber: 'G-01', + invoiceTotalAmount: '1000.00', + invoicePaidAmount: '500.00', + invoiceStatus: 'partially_paid', + }), + ); + }); + + it('falls back to the building id when the building name cannot be resolved', async () => { + const { service } = makeService({ + invoicePayment: { + findMany: jest.fn().mockResolvedValue([enrichedRow()]), + count: jest.fn().mockResolvedValue(1), + }, + }); + + const [row] = (await service.findAll(orgId, callerId, Role.ORG_ADMIN, {})) + .items; + + expect(row.buildingName).toBe(buildingId); + }); + + it('applies method, renter, date-range and free-text filters', async () => { + const { service, prisma } = makeService(); + + await service.findAll(orgId, callerId, Role.ORG_ADMIN, { + method: 'bank_transfer', + renterId: 'renter-9', + from: '2026-01-01', + to: '2026-01-31', + q: 'sara', + }); + + const { where } = prisma.invoicePayment.findMany.mock.calls[0][0]; + expect(where.method).toBe('bank_transfer'); + expect(where.invoice).toEqual({ lease: { renterId: 'renter-9' } }); + expect(where.paidAt).toEqual({ + gte: new Date('2026-01-01'), + lte: new Date('2026-01-31'), + }); + expect(where.OR).toHaveLength(3); + }); + + it('narrows an out-of-scope building filter to nothing for a supervisor', async () => { + const { service, prisma } = makeService({ + buildingAccess: { + getAllowedBuildingIds: jest.fn().mockResolvedValue([buildingId]), + }, + }); + + await service.findAll(orgId, callerId, Role.SUPERVISOR, { + buildingId: 'building-not-mine', + }); + + const { where } = prisma.invoicePayment.findMany.mock.calls[0][0]; + expect(where.invoice).toEqual({ buildingId: { in: [] } }); + }); + + it('honours an in-scope building filter for a supervisor', async () => { + const { service, prisma } = makeService({ + buildingAccess: { + getAllowedBuildingIds: jest + .fn() + .mockResolvedValue([buildingId, 'building-2']), + }, + }); + + await service.findAll(orgId, callerId, Role.SUPERVISOR, { buildingId }); + + const { where } = prisma.invoicePayment.findMany.mock.calls[0][0]; + expect(where.invoice).toEqual({ buildingId }); + }); + + it('keeps the building scope alongside a free-text search (OR must not widen it)', async () => { + const { service, prisma } = makeService({ + buildingAccess: { + getAllowedBuildingIds: jest.fn().mockResolvedValue([buildingId]), + }, + }); + + await service.findAll(orgId, callerId, Role.SUPERVISOR, { q: 'cash' }); + + const { where } = prisma.invoicePayment.findMany.mock.calls[0][0]; + // top-level `invoice` and `OR` are ANDed by Prisma → scope survives + expect(where.invoice).toEqual({ buildingId: { in: [buildingId] } }); + expect(where.OR).toHaveLength(3); + }); + + it('rejects an unknown payment method', async () => { + const { service } = makeService(); + + await expect( + service.findAll(orgId, callerId, Role.ORG_ADMIN, { method: 'crypto' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rejects an inverted date range', async () => { + const { service } = makeService(); + + await expect( + service.findAll(orgId, callerId, Role.ORG_ADMIN, { + from: '2026-03-01', + to: '2026-01-01', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rejects an unparseable date', async () => { + const { service } = makeService(); + + await expect( + service.findAll(orgId, callerId, Role.ORG_ADMIN, { + from: 'not-a-date', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); + + describe('summary', () => { + const now = new Date('2026-02-15T00:00:00.000Z'); + + it('totals collected, MTD and the per-method breakdown', async () => { + const { service } = makeService({ + invoicePayment: { + aggregate: jest + .fn() + .mockResolvedValueOnce({ + _sum: { amount: decimal('1500.00') }, + _count: { _all: 3 }, + }) + .mockResolvedValueOnce({ + _sum: { amount: decimal('500.00') }, + _count: { _all: 1 }, + }), + groupBy: jest.fn().mockResolvedValue([ + { + method: 'cash', + _sum: { amount: decimal('400.00') }, + _count: { _all: 1 }, + }, + { + method: 'bank_transfer', + _sum: { amount: decimal('1100.00') }, + _count: { _all: 2 }, + }, + ]), + }, + }); + + const { data } = await service.summary( + orgId, + callerId, + Role.FINANCE, + {}, + now, + ); + + expect(data.totalCollected).toBe('1500.00'); + expect(data.count).toBe(3); + expect(data.mtdCollected).toBe('500.00'); + expect(data.mtdCount).toBe(1); + // sorted by amount desc + expect(data.byMethod.map((m) => m.method)).toEqual([ + 'bank_transfer', + 'cash', + ]); + expect(data.byMethod[0]).toEqual({ + method: 'bank_transfer', + amount: '1100.00', + count: 2, + }); + }); + + it('sums only unpaid balances into outstandingTotal', async () => { + const { service } = makeService({ + invoice: { + findMany: jest.fn().mockResolvedValue([ + // 1000 billed, 400 paid → 600 outstanding + { + dueDate: new Date('2026-03-01T00:00:00.000Z'), + lineItems: [{ amount: decimal('1000.00') }], + payments: [{ amount: decimal('400.00') }], + }, + // fully settled → contributes nothing + { + dueDate: new Date('2026-03-01T00:00:00.000Z'), + lineItems: [{ amount: decimal('800.00') }], + payments: [{ amount: decimal('800.00') }], + }, + ]), + }, + }); + + const { data } = await service.summary( + orgId, + callerId, + Role.ORG_ADMIN, + {}, + now, + ); + + expect(data.outstandingTotal).toBe('600.00'); + expect(data.outstandingInvoices).toBe(1); + }); + + it('computes MTD from the current UTC month regardless of the picked range', async () => { + const { service, prisma } = makeService(); + + await service.summary( + orgId, + callerId, + Role.ORG_ADMIN, + { from: '2025-01-01', to: '2025-12-31' }, + now, + ); + + const mtdWhere = prisma.invoicePayment.aggregate.mock.calls[1][0].where; + expect(mtdWhere.paidAt).toEqual({ + gte: new Date('2026-02-01T00:00:00.000Z'), + }); + }); + + it('scopes the outstanding balance to a supervisor allowed buildings', async () => { + const { service, prisma } = makeService({ + buildingAccess: { + getAllowedBuildingIds: jest.fn().mockResolvedValue([buildingId]), + }, + }); + + await service.summary(orgId, callerId, Role.SUPERVISOR, {}, now); + + expect(prisma.invoice.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + orgId, + buildingId: { in: [buildingId] }, + }), + }), + ); + }); }); describe('findOne', () => { diff --git a/apps/api/src/modules/invoice-payments/invoice-payments.service.ts b/apps/api/src/modules/invoice-payments/invoice-payments.service.ts index 0d965698..2af9a9c5 100644 --- a/apps/api/src/modules/invoice-payments/invoice-payments.service.ts +++ b/apps/api/src/modules/invoice-payments/invoice-payments.service.ts @@ -11,9 +11,13 @@ import { TimelineService } from '@/modules/timeline/timeline.service'; import { NotificationsService } from '@/modules/notifications/notifications.service'; import { Role } from '@/common/enums'; import { + InvoicePaymentListItem, InvoicePaymentMethod, InvoicePaymentResponse, InvoiceSummarySnapshot, + PaginatedResponse, + RentPaymentMethodBreakdown, + RentPaymentSummaryResponse, } from '@repo/contracts'; import { computeInvoiceSummary } from '@/common/invoice-summary/compute-invoice-summary'; import { CreateInvoicePaymentDto } from './dto/create-invoice-payment.dto'; @@ -30,6 +34,65 @@ type InvoicePaymentRow = { updatedAt: Date; }; +/** A payment row joined with everything the org-wide register displays. */ +type InvoicePaymentEnrichedRow = InvoicePaymentRow & { + invoice: { + buildingId: string; + leaseId: string; + dueDate: Date; + lineItems: { amount: Prisma.Decimal }[]; + payments: { amount: Prisma.Decimal }[]; + lease: { + renterId: string; + renter: { fullName: string }; + apartment: { unitNumber: string }; + }; + }; +}; + +const ENRICHED_INCLUDE = { + invoice: { + select: { + buildingId: true, + leaseId: true, + dueDate: true, + lineItems: { select: { amount: true } }, + payments: { select: { amount: true } }, + lease: { + select: { + renterId: true, + renter: { select: { fullName: true } }, + apartment: { select: { unitNumber: true } }, + }, + }, + }, + }, +} satisfies Prisma.InvoicePaymentInclude; + +/** Filters accepted by the org-wide rent-payments register. */ +export type InvoicePaymentListFilters = { + invoiceId?: string; + buildingId?: string; + renterId?: string; + method?: string; + from?: string; + to?: string; + q?: string; + page?: number; + limit?: number; +}; + +const DEFAULT_LIMIT = 25; +const MAX_LIMIT = 100; + +const PAYMENT_METHODS: InvoicePaymentMethod[] = [ + 'cash', + 'check', + 'bank_transfer', + 'card', + 'other', +]; + @Injectable() export class InvoicePaymentsService { constructor( @@ -61,6 +124,40 @@ export class InvoicePaymentsService { }; } + /** + * Enriches a payment row with its renter / unit / building / parent-invoice + * state for the org-wide register. `buildingNameById` is resolved by the + * caller in one query — Invoice.buildingId is a bare column (no Building + * relation in the schema), so it cannot be joined in Prisma. + */ + private formatListItem( + payment: InvoicePaymentEnrichedRow, + buildingNameById: Map, + ): InvoicePaymentListItem { + const { invoice } = payment; + 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 { + ...this.formatPayment(payment), + leaseId: invoice.leaseId, + renterId: invoice.lease.renterId, + renterName: invoice.lease.renter.fullName, + buildingId: invoice.buildingId, + buildingName: + buildingNameById.get(invoice.buildingId) ?? invoice.buildingId, + apartmentUnitNumber: invoice.lease.apartment.unitNumber, + invoiceDueDate: invoice.dueDate.toISOString(), + invoiceTotalAmount: totalAmount.toFixed(2), + invoicePaidAmount: paidAmount.toFixed(2), + invoiceStatus: status, + }; + } + /** Recomputes totalAmount/paidAmount/status for the parent Invoice. */ private async summarizeInvoice( orgId: string, @@ -90,30 +187,253 @@ export class InvoicePaymentsService { }; } + /** UTC start-of-month — the MTD window lower bound (mirrors ReportsService). */ + private startOfMonthUtc(now: Date): Date { + return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); + } + + private parseDate( + value: string | undefined, + field: string, + ): Date | undefined { + if (!value) return undefined; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + throw new BadRequestException(`Invalid "${field}" date.`); + } + return parsed; + } + + /** + * Builds the shared WHERE for the register: org scope, the caller's building + * scope (null = whole org), plus the optional user filters. The list and the + * summary both use this so the tiles always describe exactly the rows shown. + * + * `opts.ignoreDateRange` drops from/to — used for the MTD tile, which keeps a + * stable "this month" meaning regardless of the range the user picked. + */ + private buildListWhere( + orgId: string, + allowedBuildingIds: string[] | null, + filters: InvoicePaymentListFilters, + opts: { ignoreDateRange?: boolean; paidAtFrom?: Date } = {}, + ): Prisma.InvoicePaymentWhereInput { + const { invoiceId, buildingId, renterId, method, q } = filters; + + if (method && !PAYMENT_METHODS.includes(method as InvoicePaymentMethod)) { + throw new BadRequestException(`Unknown payment method "${method}".`); + } + + const from = opts.ignoreDateRange + ? opts.paidAtFrom + : this.parseDate(filters.from, 'from'); + const to = opts.ignoreDateRange + ? undefined + : this.parseDate(filters.to, 'to'); + if (from && to && from > to) { + throw new BadRequestException('"from" must not be after "to".'); + } + + // A supervisor's allowed set intersected with an explicit building filter: + // an out-of-scope buildingId must narrow to nothing, never widen. + const buildingIdFilter: Prisma.StringFilter | string | undefined = + allowedBuildingIds + ? buildingId + ? allowedBuildingIds.includes(buildingId) + ? buildingId + : { in: [] } + : { in: allowedBuildingIds } + : buildingId; + + const invoiceWhere: Prisma.InvoiceWhereInput = { + ...(buildingIdFilter !== undefined && { buildingId: buildingIdFilter }), + ...(renterId && { lease: { renterId } }), + }; + + return { + orgId, + ...(invoiceId && { invoiceId }), + ...(method && { method: method as InvoicePaymentMethod }), + ...((from || to) && { + paidAt: { ...(from && { gte: from }), ...(to && { lte: to }) }, + }), + ...(Object.keys(invoiceWhere).length > 0 && { invoice: invoiceWhere }), + ...(q && { + OR: [ + { notes: { contains: q, mode: 'insensitive' } }, + { + invoice: { + lease: { + renter: { fullName: { contains: q, mode: 'insensitive' } }, + }, + }, + }, + { + invoice: { + lease: { + apartment: { unitNumber: { contains: q, mode: 'insensitive' } }, + }, + }, + }, + ], + }), + }; + } + + private async buildingNamesFor( + orgId: string, + buildingIds: string[], + ): Promise> { + if (buildingIds.length === 0) return new Map(); + const buildings = await this.prisma.building.findMany({ + where: { orgId, id: { in: buildingIds } }, + select: { id: true, name: true }, + }); + return new Map(buildings.map((b) => [b.id, b.name])); + } + + /** + * Org-wide rent-payment register, paginated and enriched. + * + * Returns the `{ items, total, page, limit }` envelope (PaginatedResponse) — + * NOT the `{ data }` envelope this method used before the register existed. + * The invoice-detail view consumes the same endpoint with `invoiceId` set. + */ async findAll( orgId: string, callerId: string, callerRole: Role, - invoiceId?: string, - ): Promise<{ data: InvoicePaymentResponse[] }> { + filters: InvoicePaymentListFilters = {}, + ): Promise> { 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' }, + const page = Math.max(1, Math.trunc(filters.page ?? 1)); + const limit = Math.min( + MAX_LIMIT, + Math.max(1, Math.trunc(filters.limit ?? DEFAULT_LIMIT)), + ); + const where = this.buildListWhere(orgId, allowedBuildingIds, filters); + + const [rows, total] = await Promise.all([ + this.prisma.invoicePayment.findMany({ + where, + include: ENRICHED_INCLUDE, + orderBy: [{ paidAt: 'desc' }, { createdAt: 'desc' }], + skip: (page - 1) * limit, + take: limit, + }), + this.prisma.invoicePayment.count({ where }), + ]); + + const buildingNameById = await this.buildingNamesFor(orgId, [ + ...new Set(rows.map((r) => r.invoice.buildingId)), + ]); + + return { + items: rows.map((r) => this.formatListItem(r, buildingNameById)), + total, + page, + limit, + }; + } + + /** + * Headline numbers for the register, over the same filters as the list. + * `outstandingTotal` is the counterpart to what was collected: Σ of the unpaid + * balance across invoices in scope, derived via computeInvoiceSummary so it + * reconciles exactly with the invoice list and the reports page. + */ + async summary( + orgId: string, + callerId: string, + callerRole: Role, + filters: InvoicePaymentListFilters = {}, + now: Date = new Date(), + ): Promise<{ data: RentPaymentSummaryResponse }> { + const allowedBuildingIds = await this.buildingAccess.getAllowedBuildingIds( + orgId, + callerId, + callerRole, + ); + + const where = this.buildListWhere(orgId, allowedBuildingIds, filters); + const mtdWhere = this.buildListWhere(orgId, allowedBuildingIds, filters, { + ignoreDateRange: true, + paidAtFrom: this.startOfMonthUtc(now), }); - return { data: payments.map((p) => this.formatPayment(p)) }; + // Invoices in the same scope, for the outstanding balance. + const invoiceWhere: Prisma.InvoiceWhereInput = { + orgId, + ...(where.invoice as Prisma.InvoiceWhereInput | undefined), + }; + + const [totals, mtdTotals, byMethodRows, invoices] = await Promise.all([ + this.prisma.invoicePayment.aggregate({ + where, + _sum: { amount: true }, + _count: { _all: true }, + }), + this.prisma.invoicePayment.aggregate({ + where: mtdWhere, + _sum: { amount: true }, + _count: { _all: true }, + }), + this.prisma.invoicePayment.groupBy({ + by: ['method'], + where, + _sum: { amount: true }, + _count: { _all: true }, + }), + this.prisma.invoice.findMany({ + where: invoiceWhere, + select: { + dueDate: true, + lineItems: { select: { amount: true } }, + payments: { select: { amount: true } }, + }, + }), + ]); + + let outstandingTotal = 0; + let outstandingInvoices = 0; + for (const invoice of invoices) { + const { totalAmount, paidAmount } = computeInvoiceSummary( + invoice.lineItems.map((li) => ({ amount: li.amount.toNumber() })), + invoice.payments.map((p) => ({ amount: p.amount.toNumber() })), + invoice.dueDate, + now, + ); + const balance = totalAmount - paidAmount; + if (balance > 0) { + outstandingTotal += balance; + outstandingInvoices += 1; + } + } + + const byMethod: RentPaymentMethodBreakdown[] = byMethodRows + .map((row) => ({ + method: row.method as InvoicePaymentMethod, + amount: (row._sum.amount?.toNumber() ?? 0).toFixed(2), + count: row._count._all, + })) + .sort((a, b) => Number(b.amount) - Number(a.amount)); + + return { + data: { + totalCollected: (totals._sum.amount?.toNumber() ?? 0).toFixed(2), + count: totals._count._all, + mtdCollected: (mtdTotals._sum.amount?.toNumber() ?? 0).toFixed(2), + mtdCount: mtdTotals._count._all, + outstandingTotal: outstandingTotal.toFixed(2), + outstandingInvoices, + byMethod, + }, + }; } async findOne( diff --git a/apps/api/src/modules/maintenance-requests/maintenance-requests.module.ts b/apps/api/src/modules/maintenance-requests/maintenance-requests.module.ts index 7c22483f..f55c4e78 100644 --- a/apps/api/src/modules/maintenance-requests/maintenance-requests.module.ts +++ b/apps/api/src/modules/maintenance-requests/maintenance-requests.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { MaintenanceRequestsController } from './maintenance-requests.controller'; import { MaintenanceRequestsService } from './maintenance-requests.service'; +import { NotificationsModule } from '@/modules/notifications/notifications.module'; @Module({ + imports: [NotificationsModule], controllers: [MaintenanceRequestsController], providers: [MaintenanceRequestsService], exports: [MaintenanceRequestsService], diff --git a/apps/api/src/modules/maintenance-requests/maintenance-requests.service.spec.ts b/apps/api/src/modules/maintenance-requests/maintenance-requests.service.spec.ts index 1654ebf4..ef0690bc 100644 --- a/apps/api/src/modules/maintenance-requests/maintenance-requests.service.spec.ts +++ b/apps/api/src/modules/maintenance-requests/maintenance-requests.service.spec.ts @@ -39,12 +39,17 @@ describe('MaintenanceRequestsService', () => { ...overrides.buildingAccess, }; const timeline = { emit: jest.fn().mockResolvedValue(undefined) }; + const notifications = { + enqueue: jest.fn().mockResolvedValue(undefined), + enqueueMany: jest.fn().mockResolvedValue(undefined), + }; const service = new MaintenanceRequestsService( prisma, buildingAccess as any, timeline as any, + notifications as any, ); - return { service, prisma, buildingAccess, timeline }; + return { service, prisma, buildingAccess, timeline, notifications }; } const requestRow = (overrides: Partial> = {}) => ({ @@ -331,6 +336,87 @@ describe('MaintenanceRequestsService', () => { expect(result.data.status).toBe('closed'); }); + // The tenant portal is read-only over this data, so the notification is the + // ONLY thing that tells a reporter their request moved. Regression guard. + it('notifies the linked tenant when the status actually changes', async () => { + const { service, prisma, notifications } = makeService({ + maintenanceRequest: { + findFirst: jest.fn().mockResolvedValue( + requestRow({ + status: 'open', + renter: { fullName: 'Jane Doe', renterUserId: 'kc-tenant-1' }, + }), + ), + }, + }); + prisma.maintenanceRequest.update.mockResolvedValue( + requestRow({ status: 'in_progress' }), + ); + + await service.update(orgId, actorId, Role.ORG_ADMIN, 'mr-1', { + status: 'in_progress', + }); + + expect(notifications.enqueue).toHaveBeenCalledWith( + expect.objectContaining({ + orgId, + userId: 'kc-tenant-1', + type: 'maintenance_request.in_progress', + // `requestId` deep-links; `requestTitle`/`status` are the render + // params the web UI substitutes into the localized copy. + data: { + requestId: 'mr-1', + status: 'in_progress', + requestTitle: 'Leaky faucet', + }, + }), + ); + }); + + it('does not notify when the status is unchanged (note/priority edits are silent)', async () => { + const { service, prisma, notifications } = makeService({ + maintenanceRequest: { + findFirst: jest.fn().mockResolvedValue( + requestRow({ + status: 'open', + renter: { fullName: 'Jane Doe', renterUserId: 'kc-tenant-1' }, + }), + ), + }, + }); + prisma.maintenanceRequest.update.mockResolvedValue( + requestRow({ status: 'open', notes: 'Plumber booked' }), + ); + + await service.update(orgId, actorId, Role.ORG_ADMIN, 'mr-1', { + notes: 'Plumber booked', + }); + + expect(notifications.enqueue).not.toHaveBeenCalled(); + }); + + it('stays silent when the renter has no linked portal login', async () => { + const { service, prisma, notifications } = makeService({ + maintenanceRequest: { + findFirst: jest.fn().mockResolvedValue( + requestRow({ + status: 'open', + renter: { fullName: 'Jane Doe', renterUserId: null }, + }), + ), + }, + }); + prisma.maintenanceRequest.update.mockResolvedValue( + requestRow({ status: 'resolved' }), + ); + + await service.update(orgId, actorId, Role.ORG_ADMIN, 'mr-1', { + status: 'resolved', + }); + + expect(notifications.enqueue).not.toHaveBeenCalled(); + }); + it('throws NotFoundException for a request in a different org', async () => { const { service } = makeService(); diff --git a/apps/api/src/modules/maintenance-requests/maintenance-requests.service.ts b/apps/api/src/modules/maintenance-requests/maintenance-requests.service.ts index dbe149f9..4fe8f100 100644 --- a/apps/api/src/modules/maintenance-requests/maintenance-requests.service.ts +++ b/apps/api/src/modules/maintenance-requests/maintenance-requests.service.ts @@ -7,6 +7,7 @@ import { import { PrismaService } from '@/infrastructure/prisma/prisma.service'; import { BuildingAccessService } from '@/common/building-access/building-access.service'; import { TimelineService } from '@/modules/timeline/timeline.service'; +import { NotificationsService } from '@/modules/notifications/notifications.service'; import { Role } from '@/common/enums'; import { MaintenanceRequestDetailResponse, @@ -23,6 +24,14 @@ const INCLUDE = { renter: { select: { fullName: true } }, } as const; +/** Human phrasing for a status, used in tenant-facing notification copy. */ +const STATUS_LABELS: Record = { + open: 'open', + in_progress: 'in progress', + resolved: 'resolved', + closed: 'closed', +}; + type MaintenanceRequestRow = { id: string; orgId: string; @@ -46,6 +55,7 @@ export class MaintenanceRequestsService { private readonly prisma: PrismaService, private readonly buildingAccess: BuildingAccessService, private readonly timeline: TimelineService, + private readonly notifications: NotificationsService, ) {} private assertOrgAdmin(callerRole: Role): void { @@ -192,6 +202,7 @@ export class MaintenanceRequestsService { const existing = await this.prisma.maintenanceRequest.findFirst({ where: { id: requestId, orgId }, + include: { renter: { select: { renterUserId: true } } }, }); if (!existing) { throw new NotFoundException('Maintenance request not found.'); @@ -225,6 +236,27 @@ export class MaintenanceRequestsService { metadata: { changes: Object.keys(dto) }, }); + // Close the loop with the tenant. Without this the tenant portal shows a + // stale badge and the reporter has no way to follow their own request. + // Only a REAL status transition is worth a notification (editing a note or + // the priority is not), and never notify the actor about their own change. + const statusChanged = request.status !== existing.status; + const tenantUserId = existing.renter.renterUserId; + if (statusChanged && tenantUserId && tenantUserId !== actorId) { + await this.notifications.enqueue({ + orgId, + userId: tenantUserId, + type: `maintenance_request.${request.status}`, + title: `Maintenance request ${STATUS_LABELS[request.status] ?? request.status}`, + body: `Your request "${request.title}" is now ${STATUS_LABELS[request.status] ?? request.status}.`, + data: { + requestId, + status: request.status, + requestTitle: request.title, + }, + }); + } + return { data: this.formatMaintenanceRequest(request) }; } diff --git a/apps/api/src/modules/notifications/notifications.module.ts b/apps/api/src/modules/notifications/notifications.module.ts index 85d6926b..061e7196 100644 --- a/apps/api/src/modules/notifications/notifications.module.ts +++ b/apps/api/src/modules/notifications/notifications.module.ts @@ -6,6 +6,7 @@ import { NotificationsService } from './notifications.service'; import { NotificationsProcessor } from './notifications.processor'; import { NotificationsDeadLetterService } from './notifications-dead-letter.service'; import { NotificationEmailService } from './notification-email.service'; +import { OrgRecipientsService } from './org-recipients.service'; import { NOTIFICATIONS_DEAD_LETTER_QUEUE, NOTIFICATIONS_QUEUE, @@ -25,7 +26,8 @@ import { NotificationsProcessor, NotificationsDeadLetterService, NotificationEmailService, + OrgRecipientsService, ], - exports: [NotificationsService], + exports: [NotificationsService, OrgRecipientsService], }) export class NotificationsModule {} diff --git a/apps/api/src/modules/notifications/notifications.service.ts b/apps/api/src/modules/notifications/notifications.service.ts index cc1d0870..01dcb35f 100644 --- a/apps/api/src/modules/notifications/notifications.service.ts +++ b/apps/api/src/modules/notifications/notifications.service.ts @@ -51,6 +51,20 @@ export class NotificationsService { } } + /** + * Fan a single notification out to several recipients. Same best-effort + * contract as {@link enqueue}: a failing recipient never blocks the others, + * and an empty recipient list is a no-op. + */ + async enqueueMany( + userIds: string[], + job: Omit, + ): Promise { + await Promise.all( + userIds.map((userId) => this.enqueue({ ...job, userId })), + ); + } + /** Persist a notification row — invoked by the queue worker. */ async persist(job: NotificationJobData): Promise { await this.prisma.notification.create({ @@ -126,10 +140,7 @@ export class NotificationsService { return { data: this.format(updated) }; } - async markAllRead( - orgId: string, - userId: string, - ): Promise<{ count: number }> { + 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() }, diff --git a/apps/api/src/modules/notifications/org-recipients.service.spec.ts b/apps/api/src/modules/notifications/org-recipients.service.spec.ts new file mode 100644 index 00000000..ced8f390 --- /dev/null +++ b/apps/api/src/modules/notifications/org-recipients.service.spec.ts @@ -0,0 +1,102 @@ +import { OrgRecipientsService } from './org-recipients.service'; +import { Role } from '@/common/enums'; + +describe('OrgRecipientsService', () => { + const orgId = 'org-1'; + + function makeService( + overrides: { + searchUsersByOrg?: jest.Mock; + getUsersWithClientRole?: jest.Mock; + } = {}, + ) { + const keycloak = { + searchUsersByOrg: + overrides.searchUsersByOrg ?? + jest.fn().mockResolvedValue([{ id: 'admin-1' }, { id: 'tenant-1' }]), + getUsersWithClientRole: + overrides.getUsersWithClientRole ?? + jest.fn().mockResolvedValue([{ id: 'admin-1' }]), + }; + return { + service: new OrgRecipientsService(keycloak as any), + keycloak, + }; + } + + it('returns the users that are both in the org and hold org_admin', async () => { + const { service, keycloak } = makeService(); + + await expect(service.getOrgAdminUserIds(orgId)).resolves.toEqual([ + 'admin-1', + ]); + expect(keycloak.getUsersWithClientRole).toHaveBeenCalledWith( + Role.ORG_ADMIN, + ); + }); + + // getUsersWithClientRole spans the whole realm, so the org intersection is + // the tenancy boundary — without it one org's ticket notifies every org's + // admins. + it('excludes org_admins that belong to a different org', async () => { + const { service } = makeService({ + searchUsersByOrg: jest.fn().mockResolvedValue([{ id: 'admin-1' }]), + getUsersWithClientRole: jest + .fn() + .mockResolvedValue([{ id: 'admin-1' }, { id: 'other-org-admin' }]), + }); + + await expect(service.getOrgAdminUserIds(orgId)).resolves.toEqual([ + 'admin-1', + ]); + }); + + it('omits the excluded actor so nobody is notified of their own action', async () => { + const { service } = makeService({ + searchUsersByOrg: jest + .fn() + .mockResolvedValue([{ id: 'admin-1' }, { id: 'admin-2' }]), + getUsersWithClientRole: jest + .fn() + .mockResolvedValue([{ id: 'admin-1' }, { id: 'admin-2' }]), + }); + + await expect(service.getOrgAdminUserIds(orgId, 'admin-1')).resolves.toEqual( + ['admin-2'], + ); + }); + + // Best-effort contract: an unreachable Keycloak must not fail the request + // that triggered the notification. + it('resolves to an empty list when Keycloak is unreachable', async () => { + const { service } = makeService({ + searchUsersByOrg: jest.fn().mockRejectedValue(new Error('KC down')), + }); + + await expect(service.getOrgAdminUserIds(orgId)).resolves.toEqual([]); + }); + + it('caches the roster so a burst of events does not hammer Keycloak', async () => { + const { service, keycloak } = makeService(); + + await service.getOrgAdminUserIds(orgId); + await service.getOrgAdminUserIds(orgId); + await service.getOrgAdminUserIds(orgId, 'admin-1'); + + expect(keycloak.searchUsersByOrg).toHaveBeenCalledTimes(1); + expect(keycloak.getUsersWithClientRole).toHaveBeenCalledTimes(1); + }); + + it('does not cache a failed lookup', async () => { + const searchUsersByOrg = jest + .fn() + .mockRejectedValueOnce(new Error('KC down')) + .mockResolvedValue([{ id: 'admin-1' }]); + const { service } = makeService({ searchUsersByOrg }); + + await expect(service.getOrgAdminUserIds(orgId)).resolves.toEqual([]); + await expect(service.getOrgAdminUserIds(orgId)).resolves.toEqual([ + 'admin-1', + ]); + }); +}); diff --git a/apps/api/src/modules/notifications/org-recipients.service.ts b/apps/api/src/modules/notifications/org-recipients.service.ts new file mode 100644 index 00000000..24fb9c39 --- /dev/null +++ b/apps/api/src/modules/notifications/org-recipients.service.ts @@ -0,0 +1,77 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { KeycloakAdminService } from '@/infrastructure/keycloak/keycloak-admin.service'; +import { Role } from '@/common/enums'; + +/** How long a resolved admin roster stays cached, in milliseconds. */ +const CACHE_TTL_MS = 60_000; + +type CacheEntry = { userIds: string[]; expiresAt: number }; + +/** + * Resolves the staff who should be told about a tenant-originated event. + * + * Keycloak is the source of truth for users and roles (there is no local User + * table), so "the org admins" means: users carrying the `org_admin` client role + * whose `org_id` attribute matches the org. That is two admin-API calls, so + * results are cached briefly — a ticket burst must not turn into a Keycloak + * hammering. + * + * Deliberately BEST-EFFORT: it returns `[]` and never throws, because a + * notification fan-out must never fail the request that triggered it (same + * contract as {@link NotificationsService.enqueue} and TimelineService.emit). + */ +@Injectable() +export class OrgRecipientsService { + private readonly logger = new Logger(OrgRecipientsService.name); + private readonly cache = new Map(); + + constructor(private readonly keycloak: KeycloakAdminService) {} + + /** + * Keycloak `sub`s of the org's admins. + * + * @param excludeUserId omitted from the result — used so an actor is never + * notified about their own action. + */ + async getOrgAdminUserIds( + orgId: string, + excludeUserId?: string, + ): Promise { + const userIds = await this.resolveOrgAdminUserIds(orgId); + return excludeUserId + ? userIds.filter((id) => id !== excludeUserId) + : userIds; + } + + private async resolveOrgAdminUserIds(orgId: string): Promise { + const cached = this.cache.get(orgId); + if (cached && cached.expiresAt > Date.now()) { + return cached.userIds; + } + + try { + const [orgUsers, admins] = await Promise.all([ + this.keycloak.searchUsersByOrg(orgId), + this.keycloak.getUsersWithClientRole(Role.ORG_ADMIN), + ]); + + // `getUsersWithClientRole` spans the whole realm — intersect with the org + // so one org's event never reaches another org's admins. + const orgUserIds = new Set(orgUsers.map((u) => u.id)); + const userIds = admins + .filter((u) => orgUserIds.has(u.id)) + .map((u) => u.id); + + this.cache.set(orgId, { + userIds, + expiresAt: Date.now() + CACHE_TTL_MS, + }); + return userIds; + } catch (error) { + this.logger.error( + `Failed to resolve org admins for ${orgId}: ${String(error)}`, + ); + return []; + } + } +} 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 index 200ead92..77acb9b1 100644 --- a/apps/api/src/modules/support-tickets/support-tickets.service.spec.ts +++ b/apps/api/src/modules/support-tickets/support-tickets.service.spec.ts @@ -20,13 +20,20 @@ describe('SupportTicketsService', () => { }, }; const timeline = { emit: jest.fn().mockResolvedValue(undefined) }; - const notifications = { enqueue: jest.fn().mockResolvedValue(undefined) }; + const notifications = { + enqueue: jest.fn().mockResolvedValue(undefined), + enqueueMany: jest.fn().mockResolvedValue(undefined), + }; + const orgRecipients = { + getOrgAdminUserIds: jest.fn().mockResolvedValue([adminId]), + }; const service = new SupportTicketsService( prisma, timeline as any, notifications as any, + orgRecipients as any, ); - return { service, prisma, timeline, notifications }; + return { service, prisma, timeline, notifications, orgRecipients }; } const ticketRow = (o: Partial> = {}) => ({ @@ -111,6 +118,31 @@ describe('SupportTicketsService', () => { ); expect(result.data.status).toBe('acknowledged'); }); + + // The opener gets an acknowledgment; the ADMINS get the actionable one. + // Previously only the opener was notified, so nobody was ever alerted. + it('also notifies the org admins, excluding the opener', async () => { + const { service, prisma, notifications, orgRecipients } = makeService(); + prisma.supportTicket.create.mockResolvedValue(ticketRow()); + + await service.create(orgId, tenantId, { + subject: 'Broken lift', + description: 'Stuck on 3', + } as any); + + expect(orgRecipients.getOrgAdminUserIds).toHaveBeenCalledWith( + orgId, + tenantId, + ); + expect(notifications.enqueueMany).toHaveBeenCalledWith( + [adminId], + expect.objectContaining({ + orgId, + type: 'support_ticket.created', + data: expect.objectContaining({ ticketId: 'ticket-1' }), + }), + ); + }); }); describe('updateStatus', () => { diff --git a/apps/api/src/modules/support-tickets/support-tickets.service.ts b/apps/api/src/modules/support-tickets/support-tickets.service.ts index b6f04751..413fe64f 100644 --- a/apps/api/src/modules/support-tickets/support-tickets.service.ts +++ b/apps/api/src/modules/support-tickets/support-tickets.service.ts @@ -7,6 +7,7 @@ import { import { PrismaService } from '@/infrastructure/prisma/prisma.service'; import { TimelineService } from '@/modules/timeline/timeline.service'; import { NotificationsService } from '@/modules/notifications/notifications.service'; +import { OrgRecipientsService } from '@/modules/notifications/org-recipients.service'; import { Role } from '@/common/enums'; import { SupportTicketCategory, @@ -50,6 +51,7 @@ export class SupportTicketsService { private readonly prisma: PrismaService, private readonly timeline: TimelineService, private readonly notifications: NotificationsService, + private readonly orgRecipients: OrgRecipientsService, ) {} private format(t: SupportTicketRow): SupportTicketResponse { @@ -147,7 +149,26 @@ export class SupportTicketsService { 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 }, + data: { + ticketId: ticket.id, + category: ticket.category, + subject: ticket.subject, + }, + }); + + // …and tell the org's admins there is something to action. The opener is + // excluded — they already got the acknowledgment above. + const admins = await this.orgRecipients.getOrgAdminUserIds(orgId, callerId); + await this.notifications.enqueueMany(admins, { + orgId, + type: 'support_ticket.created', + title: 'New support ticket', + body: `"${ticket.subject}" (${ticket.category}) was opened and needs a response.`, + data: { + ticketId: ticket.id, + category: ticket.category, + subject: ticket.subject, + }, }); return { data: this.format(ticket) }; @@ -201,7 +222,11 @@ export class SupportTicketsService { 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 }, + data: { + ticketId: ticket.id, + status: dto.status, + subject: ticket.subject, + }, }); } diff --git a/apps/api/src/modules/tenant/tenant.module.ts b/apps/api/src/modules/tenant/tenant.module.ts index ffe27450..c2806174 100644 --- a/apps/api/src/modules/tenant/tenant.module.ts +++ b/apps/api/src/modules/tenant/tenant.module.ts @@ -1,12 +1,15 @@ import { Module } from '@nestjs/common'; import { TenantController } from './tenant.controller'; import { TenantService } from './tenant.service'; +import { NotificationsModule } from '@/modules/notifications/notifications.module'; /** - * Prisma, OrgScope, LeaseStatus and Timeline are all @Global, so — like - * ReportsModule — this module only declares its own controller + service. + * Prisma, OrgScope, LeaseStatus and Timeline are all @Global. Notifications is + * not, and is needed so a tenant-opened maintenance request reaches the org's + * admins. */ @Module({ + imports: [NotificationsModule], controllers: [TenantController], providers: [TenantService], exports: [TenantService], diff --git a/apps/api/src/modules/tenant/tenant.service.spec.ts b/apps/api/src/modules/tenant/tenant.service.spec.ts index 83dc8d4f..dbd42c63 100644 --- a/apps/api/src/modules/tenant/tenant.service.spec.ts +++ b/apps/api/src/modules/tenant/tenant.service.spec.ts @@ -39,16 +39,30 @@ describe('TenantService', () => { }, }; const timeline = { emit: jest.fn().mockResolvedValue(undefined) }; + const notifications = { + enqueue: jest.fn().mockResolvedValue(undefined), + enqueueMany: jest.fn().mockResolvedValue(undefined), + }; + const orgRecipients = { + getOrgAdminUserIds: jest.fn().mockResolvedValue(['kc-sub-admin-1']), + }; const leaseStatus = new LeaseStatusService(); const service = new TenantService( prisma as any, leaseStatus, timeline as any, + notifications as any, + orgRecipients as any, ); - return { service, prisma, timeline }; + return { service, prisma, timeline, notifications, orgRecipients }; } - const renter = { id: 'renter-1', fullName: 'Jane Doe', email: null, phone: null }; + const renter = { + id: 'renter-1', + fullName: 'Jane Doe', + email: null, + phone: null, + }; const leaseRow = (overrides: Record = {}) => ({ id: 'lease-1', @@ -117,6 +131,7 @@ describe('TenantService', () => { status: 'open', priority: 'high', createdAt: now, + updatedAt: now, apartment: { unitNumber: '4B' }, }, ]), @@ -171,9 +186,7 @@ describe('TenantService', () => { renter: { findFirst: jest.fn().mockResolvedValue(renter) }, // Prisma orders by startDate desc, so the mock returns newest first. lease: { - findMany: jest - .fn() - .mockResolvedValue([currentLease, olderLease]), + findMany: jest.fn().mockResolvedValue([currentLease, olderLease]), }, }); @@ -245,6 +258,7 @@ describe('TenantService', () => { status: 'open', priority: 'medium', createdAt: now, + updatedAt: now, apartment: { unitNumber: '4B' }, }); const { service, prisma, timeline } = makeService({ @@ -299,6 +313,7 @@ describe('TenantService', () => { status: 'open', priority: 'urgent', createdAt: now, + updatedAt: now, apartment: { unitNumber: '4B' }, }); const { service } = makeService({ @@ -316,5 +331,46 @@ describe('TenantService', () => { expect(create.mock.calls[0][0].data.priority).toBe('urgent'); }); + + // Without this fan-out a tenant-opened request sits unseen until an admin + // happens to open the Tasks page. Regression guard. + it('notifies the org admins, excluding the reporting tenant', async () => { + const create = jest.fn().mockResolvedValue({ + id: 'mr-11', + title: 'No hot water', + description: null, + status: 'open', + priority: 'high', + createdAt: now, + updatedAt: now, + apartment: { unitNumber: '4B' }, + }); + const { service, notifications, orgRecipients } = makeService({ + renter: { findFirst: jest.fn().mockResolvedValue(renter) }, + lease: { findMany: jest.fn().mockResolvedValue([leaseRow()]) }, + maintenanceRequest: { create }, + }); + + await service.createMaintenanceRequest( + orgId, + sub, + { title: 'No hot water' }, + now, + ); + + // The caller is excluded so a self-serve action never notifies its actor. + expect(orgRecipients.getOrgAdminUserIds).toHaveBeenCalledWith(orgId, sub); + expect(notifications.enqueueMany).toHaveBeenCalledWith( + ['kc-sub-admin-1'], + expect.objectContaining({ + orgId, + type: 'maintenance_request.created', + data: expect.objectContaining({ + requestId: 'mr-11', + unitNumber: '4B', + }), + }), + ); + }); }); }); diff --git a/apps/api/src/modules/tenant/tenant.service.ts b/apps/api/src/modules/tenant/tenant.service.ts index 5e933f55..cb414d64 100644 --- a/apps/api/src/modules/tenant/tenant.service.ts +++ b/apps/api/src/modules/tenant/tenant.service.ts @@ -6,6 +6,8 @@ import { import { PrismaService } from '@/infrastructure/prisma/prisma.service'; import { LeaseStatusService } from '@/common/lease-status/lease-status.service'; import { TimelineService } from '@/modules/timeline/timeline.service'; +import { NotificationsService } from '@/modules/notifications/notifications.service'; +import { OrgRecipientsService } from '@/modules/notifications/org-recipients.service'; import { computeInvoiceSummary } from '@/common/invoice-summary/compute-invoice-summary'; import type { InvoiceStatus, @@ -46,6 +48,7 @@ type RequestRow = { status: string; priority: string; createdAt: Date; + updatedAt: Date; apartment: { unitNumber: string }; }; @@ -63,6 +66,8 @@ export class TenantService { private readonly prisma: PrismaService, private readonly leaseStatus: LeaseStatusService, private readonly timeline: TimelineService, + private readonly notifications: NotificationsService, + private readonly orgRecipients: OrgRecipientsService, ) {} private money(value: number): string { @@ -108,6 +113,9 @@ export class TenantService { priority: request.priority as MaintenanceRequestPriority, unitNumber: request.apartment.unitNumber, createdAt: request.createdAt.toISOString(), + // Lets the portal show "Updated 5 minutes ago" — the tenant's only signal + // that staff have moved the request along. + updatedAt: request.updatedAt.toISOString(), }; } @@ -185,6 +193,7 @@ export class TenantService { status: true, priority: true, createdAt: true, + updatedAt: true, apartment: { select: { unitNumber: true } }, }, orderBy: { createdAt: 'desc' }, @@ -307,6 +316,7 @@ export class TenantService { status: true, priority: true, createdAt: true, + updatedAt: true, apartment: { select: { unitNumber: true } }, }, }); @@ -324,6 +334,25 @@ export class TenantService { }, }); + // Tell the org's admins — otherwise a tenant-opened request sits unseen + // until someone happens to load the Tasks page. `data.requestId` deep-links + // the notification; the remaining `data` fields are the render params the + // web UI substitutes into the localized copy (`title`/`body` below are the + // English fallback, used for email and for unknown types). + const admins = await this.orgRecipients.getOrgAdminUserIds(orgId, userId); + await this.notifications.enqueueMany(admins, { + orgId, + type: 'maintenance_request.created', + title: 'New maintenance request', + body: `${renter.fullName} (unit ${created.apartment.unitNumber}) reported: "${created.title}".`, + data: { + requestId: created.id, + requestTitle: created.title, + renterName: renter.fullName, + unitNumber: created.apartment.unitNumber, + }, + }); + 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 index 6ea48738..e544bbad 100644 --- a/apps/api/src/modules/webhooks/webhooks.service.spec.ts +++ b/apps/api/src/modules/webhooks/webhooks.service.spec.ts @@ -118,3 +118,96 @@ describe('WebhooksService.handleCheckoutCompleted (atomic activation)', () => { ); }); }); + +/** + * currentPeriodEnd mapping (customer.subscription.created/updated). + * In Stripe's basil API `current_period_end` moved from the Subscription to the + * subscription ITEM. The old code fell back to `billing_cycle_anchor` (the period + * START), storing a period end ≈ now. These guard the correct item-level read. + */ +const ANCHOR = 1735689600; // 2025-01-01T00:00:00Z — period START (must NOT be used) +const ITEM_END = 1738368000; // 2025-02-01T00:00:00Z — real period END (must be used) + +function subscriptionEvent(sub: Record): Stripe.Event { + return { + id: 'evt_sub_1', + type: 'customer.subscription.updated', + data: { object: sub }, + } as unknown as Stripe.Event; +} + +describe('WebhooksService.handleSubscriptionUpsert (currentPeriodEnd)', () => { + it('reads current_period_end from the subscription ITEM, never billing_cycle_anchor', async () => { + const { service, prisma } = makeService(); + prisma.organization.findFirst.mockResolvedValue({ id: 'org-1' }); + + await service.handleEvent( + subscriptionEvent({ + id: 'sub_1', + status: 'active', + customer: 'cus_1', + billing_cycle_anchor: ANCHOR, + items: { + data: [{ price: { id: 'price_1' }, current_period_end: ITEM_END }], + }, + }), + ); + + expect(prisma.subscription.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { orgId: 'org-1' }, + create: expect.objectContaining({ + currentPeriodEnd: new Date(ITEM_END * 1000), + }), + update: expect.objectContaining({ + currentPeriodEnd: new Date(ITEM_END * 1000), + }), + }), + ); + }); + + it('prefers a legacy top-level current_period_end when present', async () => { + const { service, prisma } = makeService(); + prisma.organization.findFirst.mockResolvedValue({ id: 'org-1' }); + + await service.handleEvent( + subscriptionEvent({ + id: 'sub_1', + status: 'active', + customer: 'cus_1', + current_period_end: ITEM_END, + billing_cycle_anchor: ANCHOR, + items: { data: [{ price: { id: 'price_1' } }] }, + }), + ); + + expect(prisma.subscription.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ + currentPeriodEnd: new Date(ITEM_END * 1000), + }), + }), + ); + }); + + it('stores null when no period end is available (never the anchor)', async () => { + const { service, prisma } = makeService(); + prisma.organization.findFirst.mockResolvedValue({ id: 'org-1' }); + + await service.handleEvent( + subscriptionEvent({ + id: 'sub_1', + status: 'active', + customer: 'cus_1', + billing_cycle_anchor: ANCHOR, + items: { data: [{ price: { id: 'price_1' } }] }, + }), + ); + + expect(prisma.subscription.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ currentPeriodEnd: null }), + }), + ); + }); +}); diff --git a/apps/api/src/modules/webhooks/webhooks.service.ts b/apps/api/src/modules/webhooks/webhooks.service.ts index 234fabd0..5e93da83 100644 --- a/apps/api/src/modules/webhooks/webhooks.service.ts +++ b/apps/api/src/modules/webhooks/webhooks.service.ts @@ -192,10 +192,15 @@ export class WebhooksService { } const status = this.mapSubscriptionStatus(subscription.status); - // current_period_end removed in Stripe v22 basil API; fall back to billing_cycle_anchor + // `current_period_end` was moved off the top-level Subscription in Stripe's + // basil API (2025-08) and now lives on each subscription ITEM. Read it from + // the item; keep the legacy top-level as a fallback for accounts still on an + // older API version. Do NOT fall back to `billing_cycle_anchor` — that is the + // period START/anchor, so it produced a currentPeriodEnd ≈ now (bug), which + // is worse than a null. A missing period end is stored as null (schema allows). const periodEndRaw = - (subscription as any).current_period_end ?? - (subscription as any).billing_cycle_anchor ?? + (subscription as { current_period_end?: number }).current_period_end ?? + subscription.items?.data?.[0]?.current_period_end ?? null; const currentPeriodEnd = periodEndRaw ? new Date(periodEndRaw * 1000) diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 4278e410..e6045a12 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -12,7 +12,19 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ rm -rf /var/lib/apt/lists/* WORKDIR /app COPY . . -RUN npm ci +# npm intermittently skips platform-specific OPTIONAL deps (npm/cli#4828), which +# silently drops Tailwind v4's native oxide engine and makes `next build` fail with +# "Cannot find native binding". This layer re-runs on every source change, so the +# bug is hit repeatedly. Verify the binding resolves and repair it if missing — +# the target is pinned (node:22-slim = linux/glibc, amd64), so the package name is +# deterministic and the version is read from the lockfile's own resolution. +RUN npm ci && \ + node -e "require('@tailwindcss/oxide')" 2>/dev/null || { \ + echo "oxide native binding missing after npm ci — repairing"; \ + npm install --no-save --include=optional \ + "@tailwindcss/oxide-linux-x64-gnu@$(node -p "require('./node_modules/@tailwindcss/oxide/package.json').version")"; \ + node -e "require('@tailwindcss/oxide')"; \ + } # 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 diff --git a/apps/web/src/app/[lang]/dashboard/rent-payments/page.tsx b/apps/web/src/app/[lang]/dashboard/rent-payments/page.tsx new file mode 100644 index 00000000..ab5e150d --- /dev/null +++ b/apps/web/src/app/[lang]/dashboard/rent-payments/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 { RentPaymentsPage } from '@/components/dashboard/rent-payments-page'; + +export default async function RentPaymentsPageRoute({ + 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); + + // maintenance + tenant have no access; supervisor is read-only. + if (!canAccess(role, 'rentPayments')) { + redirect(`/${locale}/dashboard`); + } + + const dict = await getDictionary(locale); + + return ( + + ); +} diff --git a/apps/web/src/app/api/invoice-payments/summary/route.ts b/apps/web/src/app/api/invoice-payments/summary/route.ts new file mode 100644 index 00000000..9d0aab0c --- /dev/null +++ b/apps/web/src/app/api/invoice-payments/summary/route.ts @@ -0,0 +1,6 @@ +import { forwardRoute } from '@/lib/api/forward'; + +export const runtime = 'nodejs'; + +// Static segment — Next resolves it ahead of the sibling [id] route. +export const GET = forwardRoute('/invoice-payments/summary'); diff --git a/apps/web/src/auth/permissions.ts b/apps/web/src/auth/permissions.ts index aedbc03c..3953822d 100644 --- a/apps/web/src/auth/permissions.ts +++ b/apps/web/src/auth/permissions.ts @@ -19,10 +19,17 @@ * "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 + * "invoices" → /dashboard/invoices (invoices + the per-invoice payment + * list on invoice detail, same as "tasks" covering Maintenance * Requests + Work Orders; distinct from "payments"/"billing", * which are the platform's own Stripe subscription billing) + * "rentPayments" → /dashboard/rent-payments — the org-wide register of rent + * money RECEIVED from tenants (InvoicePayment). Deliberately + * its own area, not folded into "invoices": recording money is + * a finance action, and a supervisor may read the register + * (building-scoped, server-side) without any write rights. + * Note the path is kebab-case while the area key is camelCase — + * proxy.ts maps the two via SUBAREA_ALIASES. * "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) @@ -48,6 +55,7 @@ export type DashboardArea = | 'vendors' | 'expenses' | 'invoices' + | 'rentPayments' | 'support' | 'leases' | 'notifications'; @@ -75,6 +83,7 @@ export const PERMISSION_MATRIX: PermissionMatrix = { vendors: 'full', expenses: 'full', invoices: 'full', + rentPayments: 'full', support: 'full', leases: 'full', notifications: 'full', @@ -91,6 +100,7 @@ export const PERMISSION_MATRIX: PermissionMatrix = { vendors: 'readonly', expenses: 'readonly', invoices: 'readonly', + rentPayments: 'readonly', support: 'full', leases: 'readonly', notifications: 'full', @@ -107,6 +117,7 @@ export const PERMISSION_MATRIX: PermissionMatrix = { vendors: 'readonly', expenses: 'full', invoices: 'full', + rentPayments: 'full', support: 'readonly', leases: 'readonly', notifications: 'full', @@ -123,6 +134,7 @@ export const PERMISSION_MATRIX: PermissionMatrix = { vendors: 'readonly', expenses: 'none', invoices: 'none', + rentPayments: 'none', support: 'readonly', leases: 'readonly', notifications: 'full', @@ -139,6 +151,7 @@ export const PERMISSION_MATRIX: PermissionMatrix = { vendors: 'none', expenses: 'none', invoices: 'none', + rentPayments: '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. diff --git a/apps/web/src/components/dashboard/invoices-page.tsx b/apps/web/src/components/dashboard/invoices-page.tsx index de538fa9..276c02de 100644 --- a/apps/web/src/components/dashboard/invoices-page.tsx +++ b/apps/web/src/components/dashboard/invoices-page.tsx @@ -15,6 +15,7 @@ import { TrashIcon, XIcon, RefreshCwIcon, + WalletIcon, } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; @@ -897,6 +898,18 @@ export function InvoicesPage({ canWrite, locale, dict }: InvoicesPageProps) { {dict.common.edit} + {invoice.status !== 'paid' && ( + + router.push( + `/${locale}/dashboard/rent-payments?recordFor=${encodeURIComponent(invoice.id)}`, + ) + } + > + + {t.list.recordPayment} + + )} ).ticketId === 'string'; - const type = notification.type.toLowerCase(); - if (type.includes('support') || type.includes('ticket') || hasTicketId) { - return `/${locale}/dashboard/support`; - } - return null; -} - export function NotificationsPage({ locale, dict }: Props) { const t = dict.notifications; const router = useRouter(); @@ -130,15 +116,17 @@ export function NotificationsPage({ locale, dict }: Props) { ) : (
    {notifications.map((n) => { - const target = getNotificationTarget(n, locale); + const href = getNotificationHref(n, locale, 'dashboard'); return ( { - markRead(n.id); - if (target) router.push(target); + if (!n.readAt) markRead(n.id); + if (href) router.push(href); }} /> ); @@ -151,11 +139,15 @@ export function NotificationsPage({ locale, dict }: Props) { function NotificationCard({ notification, + content, timeLabel, + hasTarget, onActivate, }: { notification: NotificationResponse; + content: NotificationContent; timeLabel: string; + hasTarget: boolean; onActivate: () => void; }) { const isUnread = !notification.readAt; @@ -169,26 +161,35 @@ function NotificationCard({ /> )}
    -

    - {notification.title} -

    - {notification.body && ( -

    - {notification.body} -

    +

    {content.title}

    + {content.body && ( +

    {content.body}

    )}

    {timeLabel}

    + {hasTarget && ( +