diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index f9d9411e..d00c8c7b 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -44,6 +44,9 @@ import { NotificationsModule } from '@/modules/notifications/notifications.modul import { SupportTicketsModule } from '@/modules/support-tickets/support-tickets.module'; import { ReportsModule } from '@/modules/reports/reports.module'; import { TenantModule } from '@/modules/tenant/tenant.module'; +import { RecurringInvoicesModule } from '@/modules/recurring-invoices/recurring-invoices.module'; +import { ApartmentStatusSweepModule } from '@/modules/apartment-status-sweep/apartment-status-sweep.module'; +import { AvailableUnitsModule } from '@/modules/available-units/available-units.module'; @Module({ imports: [ @@ -110,6 +113,9 @@ import { TenantModule } from '@/modules/tenant/tenant.module'; SupportTicketsModule, ReportsModule, TenantModule, + RecurringInvoicesModule, + ApartmentStatusSweepModule, + AvailableUnitsModule, ], providers: [ { provide: APP_GUARD, useClass: ThrottlerGuard }, diff --git a/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep-scheduler.service.ts b/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep-scheduler.service.ts new file mode 100644 index 00000000..03488e71 --- /dev/null +++ b/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep-scheduler.service.ts @@ -0,0 +1,44 @@ +import { InjectQueue } from '@nestjs/bullmq'; +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Queue } from 'bullmq'; +import { + APARTMENT_STATUS_SWEEP_CRON, + APARTMENT_STATUS_SWEEP_DAILY_JOB_ID, + APARTMENT_STATUS_SWEEP_QUEUE, + APARTMENT_STATUS_SWEEP_RUN_JOB, +} from './apartment-status-sweep.constants'; + +/** + * Registers the daily repeatable job on module init (there is no + * `@nestjs/schedule` in this app — BullMQ's repeatable jobs are the + * scheduling primitive here, same as the rest of the queue infra). A fixed + * jobId means BullMQ upserts the same repeatable job definition on every app + * restart instead of accumulating duplicate schedules. + */ +@Injectable() +export class ApartmentStatusSweepSchedulerService implements OnModuleInit { + private readonly logger = new Logger( + ApartmentStatusSweepSchedulerService.name, + ); + + constructor( + @InjectQueue(APARTMENT_STATUS_SWEEP_QUEUE) private readonly queue: Queue, + ) {} + + async onModuleInit(): Promise { + try { + await this.queue.add( + APARTMENT_STATUS_SWEEP_RUN_JOB, + {}, + { + repeat: { pattern: APARTMENT_STATUS_SWEEP_CRON }, + jobId: APARTMENT_STATUS_SWEEP_DAILY_JOB_ID, + }, + ); + } catch (error) { + this.logger.error( + `Failed to schedule the daily apartment-status sweep job: ${String(error)}`, + ); + } + } +} diff --git a/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.constants.ts b/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.constants.ts new file mode 100644 index 00000000..c75e3b0f --- /dev/null +++ b/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.constants.ts @@ -0,0 +1,16 @@ +/** Name of the BullMQ queue that drives the daily apartment-status sweep. */ +export const APARTMENT_STATUS_SWEEP_QUEUE = 'apartment-status-sweep'; + +/** Job name used for the daily sweep run. */ +export const APARTMENT_STATUS_SWEEP_RUN_JOB = 'run-daily'; + +/** + * Fixed jobId for the repeatable job. BullMQ upserts a repeatable job by its + * (name, jobId, repeat options) — reusing this id on every app restart avoids + * accumulating duplicate schedules. + */ +export const APARTMENT_STATUS_SWEEP_DAILY_JOB_ID = + 'apartment-status-sweep-daily'; + +/** Daily at 07:00 UTC — an hour after the recurring-invoices run. */ +export const APARTMENT_STATUS_SWEEP_CRON = '0 7 * * *'; diff --git a/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.controller.ts b/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.controller.ts new file mode 100644 index 00000000..b1792843 --- /dev/null +++ b/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.controller.ts @@ -0,0 +1,33 @@ +import { Controller, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { ApartmentStatusSweepService } from './apartment-status-sweep.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'; + +/** + * Manual "sweep now" trigger for the apartment-status expiry sweep (F4.2). + * Runs the same idempotent core the daily scheduler runs, scoped to the + * caller's org. Mounted at `/apartments` (not a dedicated top-level path) + * since a status sweep reads most naturally as an apartments action; it + * does not collide with the nested + * buildings/:buildingId/floors/:floorId/apartments controller since Nest + * matches on the full path. + */ +@ApiTags('apartments') +@ApiBearerAuth() +@Controller('apartments') +@Roles(Role.ORG_ADMIN) +export class ApartmentStatusSweepController { + constructor( + private readonly apartmentStatusSweep: ApartmentStatusSweepService, + private readonly orgScope: OrgScopeService, + ) {} + + @Post('status-sweep') + async runSweep(@CurrentUser() user: AuthenticatedUser) { + const { orgId } = await this.orgScope.resolveForCaller(user); + return this.apartmentStatusSweep.sweepForOrg(orgId); + } +} diff --git a/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.module.ts b/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.module.ts new file mode 100644 index 00000000..c2ae9c3d --- /dev/null +++ b/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bullmq'; +import { ApartmentStatusSweepController } from './apartment-status-sweep.controller'; +import { ApartmentStatusSweepService } from './apartment-status-sweep.service'; +import { ApartmentStatusSweepProcessor } from './apartment-status-sweep.processor'; +import { ApartmentStatusSweepSchedulerService } from './apartment-status-sweep-scheduler.service'; +import { APARTMENT_STATUS_SWEEP_QUEUE } from './apartment-status-sweep.constants'; + +@Module({ + imports: [BullModule.registerQueue({ name: APARTMENT_STATUS_SWEEP_QUEUE })], + controllers: [ApartmentStatusSweepController], + providers: [ + ApartmentStatusSweepService, + ApartmentStatusSweepProcessor, + ApartmentStatusSweepSchedulerService, + ], + exports: [ApartmentStatusSweepService], +}) +export class ApartmentStatusSweepModule {} diff --git a/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.processor.ts b/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.processor.ts new file mode 100644 index 00000000..27bcdb4f --- /dev/null +++ b/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.processor.ts @@ -0,0 +1,27 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Logger } from '@nestjs/common'; +import { Job } from 'bullmq'; +import { APARTMENT_STATUS_SWEEP_QUEUE } from './apartment-status-sweep.constants'; +import { ApartmentStatusSweepService } from './apartment-status-sweep.service'; + +/** + * Consumes the daily repeatable job and sweeps every org. The manual + * `POST /apartments/status-sweep` endpoint calls + * {@link ApartmentStatusSweepService.sweepForOrg} directly (single-org, + * synchronous response) — this processor is only reached by the scheduled job. + */ +@Processor(APARTMENT_STATUS_SWEEP_QUEUE) +export class ApartmentStatusSweepProcessor extends WorkerHost { + private readonly logger = new Logger(ApartmentStatusSweepProcessor.name); + + constructor( + private readonly apartmentStatusSweep: ApartmentStatusSweepService, + ) { + super(); + } + + async process(job: Job): Promise { + this.logger.debug(`Starting apartment-status sweep (job ${job.id})`); + await this.apartmentStatusSweep.sweepAll(); + } +} diff --git a/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.service.spec.ts b/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.service.spec.ts new file mode 100644 index 00000000..c771101f --- /dev/null +++ b/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.service.spec.ts @@ -0,0 +1,145 @@ +import { ApartmentStatusSweepService } from './apartment-status-sweep.service'; +import { LeaseStatusService } from '@/common/lease-status/lease-status.service'; + +describe('ApartmentStatusSweepService', () => { + const orgId = 'org-1'; + const FAR_FUTURE = new Date('2099-01-01T00:00:00.000Z'); + const FAR_PAST = new Date('2000-01-01T00:00:00.000Z'); + + function makeService( + overrides: { + apartment?: Partial>; + } = {}, + ) { + const prisma: any = { + apartment: { + findMany: jest.fn().mockResolvedValue([]), + update: jest.fn().mockResolvedValue({}), + ...overrides.apartment, + }, + }; + const leaseStatus = new LeaseStatusService(); + const service = new ApartmentStatusSweepService(prisma, leaseStatus); + return { service, prisma }; + } + + describe('sweepForOrg', () => { + it('queries only occupied apartments, scoped to the org', async () => { + const { service, prisma } = makeService(); + + await service.sweepForOrg(orgId); + + expect(prisma.apartment.findMany).toHaveBeenCalledWith({ + where: { orgId, status: 'occupied' }, + select: { + id: true, + leases: { select: { status: true, endDate: true } }, + }, + }); + }); + + it('reverts an occupied apartment with no effectively-active lease to vacant', async () => { + const { service, prisma } = makeService({ + apartment: { + findMany: jest.fn().mockResolvedValue([ + { + id: 'apt-1', + leases: [{ status: 'terminated', endDate: FAR_PAST }], + }, + ]), + }, + }); + + const result = await service.sweepForOrg(orgId); + + expect(prisma.apartment.update).toHaveBeenCalledWith({ + where: { id: 'apt-1' }, + data: { status: 'vacant' }, + }); + expect(result).toEqual({ reverted: 1, considered: 1 }); + }); + + it('reverts an occupied apartment with no leases at all', async () => { + const { service, prisma } = makeService({ + apartment: { + findMany: jest + .fn() + .mockResolvedValue([{ id: 'apt-1', leases: [] }]), + }, + }); + + const result = await service.sweepForOrg(orgId); + + expect(prisma.apartment.update).toHaveBeenCalledWith({ + where: { id: 'apt-1' }, + data: { status: 'vacant' }, + }); + expect(result).toEqual({ reverted: 1, considered: 1 }); + }); + + it('leaves an occupied apartment alone when it still has an effectively-active lease', async () => { + const { service, prisma } = makeService({ + apartment: { + findMany: jest.fn().mockResolvedValue([ + { + id: 'apt-1', + leases: [{ status: 'active', endDate: FAR_FUTURE }], + }, + ]), + }, + }); + + const result = await service.sweepForOrg(orgId); + + expect(prisma.apartment.update).not.toHaveBeenCalled(); + expect(result).toEqual({ reverted: 0, considered: 1 }); + }); + + it('reports considered/reverted counts across a mix of apartments', async () => { + const { service, prisma } = makeService({ + apartment: { + findMany: jest.fn().mockResolvedValue([ + { id: 'apt-1', leases: [] }, + { + id: 'apt-2', + leases: [{ status: 'active', endDate: FAR_FUTURE }], + }, + { + id: 'apt-3', + leases: [{ status: 'active', endDate: FAR_PAST }], // expired + }, + ]), + }, + }); + + const result = await service.sweepForOrg(orgId); + + expect(prisma.apartment.update).toHaveBeenCalledTimes(2); + expect(prisma.apartment.update).toHaveBeenCalledWith({ + where: { id: 'apt-1' }, + data: { status: 'vacant' }, + }); + expect(prisma.apartment.update).toHaveBeenCalledWith({ + where: { id: 'apt-3' }, + data: { status: 'vacant' }, + }); + expect(result).toEqual({ reverted: 2, considered: 3 }); + }); + }); + + describe('sweepAll', () => { + it('sweeps across every org (no orgId filter)', async () => { + const { service, prisma } = makeService(); + + await service.sweepAll(); + + expect(prisma.apartment.findMany).toHaveBeenCalledWith({ + where: { status: 'occupied' }, + select: { + id: true, + leases: { select: { status: true, endDate: true } }, + }, + }); + }); + }); +}); diff --git a/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.service.ts b/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.service.ts new file mode 100644 index 00000000..f583a18c --- /dev/null +++ b/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.service.ts @@ -0,0 +1,80 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '@/infrastructure/prisma/prisma.service'; +import { LeaseStatusService } from '@/common/lease-status/lease-status.service'; +import { ApartmentStatusSweepResponse } from '@repo/contracts'; + +/** + * F4.2: leases only sync the apartment status at create/update/renew time — + * nothing continuously re-checks it, so an apartment left `occupied` by a + * lease that has since expired (or was terminated without a replacement) + * stays stuck `occupied` forever. This sweep finds `occupied` apartments with + * no effectively-active lease and reverts them to `vacant`. + * + * Only `occupied` apartments are ever touched: `maintenance` and + * `unavailable` apartments are excluded by the query itself (status === + * 'occupied'), so an apartment with an open work order — which + * WorkOrderApartmentStatusService keeps at `maintenance` — is never + * considered here, let alone reverted. + */ +@Injectable() +export class ApartmentStatusSweepService { + private readonly logger = new Logger(ApartmentStatusSweepService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly leaseStatus: LeaseStatusService, + ) {} + + /** + * Pure, idempotent core: revert every `occupied` apartment (optionally + * scoped to `orgId`) that has no effectively-active lease as of `asOf`. + */ + private async sweep( + orgId: string | undefined, + asOf: Date, + ): Promise { + const apartments = await this.prisma.apartment.findMany({ + where: { ...(orgId && { orgId }), status: 'occupied' }, + select: { + id: true, + leases: { select: { status: true, endDate: true } }, + }, + }); + + let reverted = 0; + for (const apartment of apartments) { + const hasActiveLease = apartment.leases.some((lease) => + this.leaseStatus.isEffectivelyActive( + { status: lease.status, endDate: lease.endDate }, + asOf, + ), + ); + if (!hasActiveLease) { + await this.prisma.apartment.update({ + where: { id: apartment.id }, + data: { status: 'vacant' }, + }); + reverted++; + } + } + + return { reverted, considered: apartments.length }; + } + + /** Sweep a single org — the manual `POST /apartments/status-sweep` trigger. */ + async sweepForOrg( + orgId: string, + asOf: Date = new Date(), + ): Promise { + return this.sweep(orgId, asOf); + } + + /** Sweep every org — the daily scheduler's entry point. */ + async sweepAll(asOf: Date = new Date()): Promise { + const result = await this.sweep(undefined, asOf); + this.logger.log( + `Apartment status sweep: reverted=${result.reverted} considered=${result.considered}`, + ); + return result; + } +} diff --git a/apps/api/src/modules/available-units/available-units.controller.ts b/apps/api/src/modules/available-units/available-units.controller.ts new file mode 100644 index 00000000..021964fa --- /dev/null +++ b/apps/api/src/modules/available-units/available-units.controller.ts @@ -0,0 +1,38 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { AvailableUnitsService } from './available-units.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'; + +/** + * F6.2 — org-wide, read-only "Available units" showcase for prospective + * renters. A separate top-level controller (mirrors LeasesOverviewController + * / WorkOrdersOverviewController), not nested under buildings, since it lists + * vacant apartments across the whole org rather than a single building. + * Every member role — including TENANT — can view it; prospective tenants + * express interest via a support ticket rather than an action here. + */ +@ApiTags('available-units') +@ApiBearerAuth() +@Controller('available-units') +export class AvailableUnitsController { + constructor( + private readonly availableUnitsService: AvailableUnitsService, + private readonly orgScope: OrgScopeService, + ) {} + + @Roles( + Role.ORG_ADMIN, + Role.SUPERVISOR, + Role.FINANCE, + Role.MAINTENANCE, + Role.TENANT, + ) + @Get() + async getAvailableUnits(@CurrentUser() user: AuthenticatedUser) { + const { orgId } = await this.orgScope.resolveForCaller(user); + return this.availableUnitsService.findAvailable(orgId); + } +} diff --git a/apps/api/src/modules/available-units/available-units.module.ts b/apps/api/src/modules/available-units/available-units.module.ts new file mode 100644 index 00000000..65ba87cc --- /dev/null +++ b/apps/api/src/modules/available-units/available-units.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { AvailableUnitsController } from './available-units.controller'; +import { AvailableUnitsService } from './available-units.service'; + +@Module({ + controllers: [AvailableUnitsController], + providers: [AvailableUnitsService], +}) +export class AvailableUnitsModule {} diff --git a/apps/api/src/modules/available-units/available-units.service.spec.ts b/apps/api/src/modules/available-units/available-units.service.spec.ts new file mode 100644 index 00000000..ab1db722 --- /dev/null +++ b/apps/api/src/modules/available-units/available-units.service.spec.ts @@ -0,0 +1,83 @@ +import { AvailableUnitsService } from './available-units.service'; + +describe('AvailableUnitsService', () => { + const orgId = 'org-1'; + + function makeService( + overrides: { + apartment?: Partial>; + } = {}, + ) { + const prisma: any = { + apartment: { + findMany: jest.fn().mockResolvedValue([]), + ...overrides.apartment, + }, + }; + const service = new AvailableUnitsService(prisma); + return { service, prisma }; + } + + describe('findAvailable', () => { + it('scopes the query to the org and vacant apartments only, ordered by building then unit number', async () => { + const { service, prisma } = makeService(); + + await service.findAvailable(orgId); + + expect(prisma.apartment.findMany).toHaveBeenCalledWith({ + where: { orgId, status: 'vacant' }, + include: { + building: { select: { name: true } }, + floor: { select: { name: true } }, + }, + orderBy: [{ building: { name: 'asc' } }, { unitNumber: 'asc' }], + }); + }); + + it('maps each vacant apartment to an AvailableUnit enriched with building/floor names', async () => { + const { service } = makeService({ + apartment: { + findMany: jest.fn().mockResolvedValue([ + { + id: 'apt-1', + buildingId: 'building-1', + floorId: 'floor-1', + unitNumber: '101', + bedrooms: 2, + bathrooms: { toString: () => '1.5' }, + sqft: 850, + building: { name: 'Sunrise Tower' }, + floor: { name: 'Ground Floor' }, + }, + ]), + }, + }); + + const result = await service.findAvailable(orgId); + + expect(result).toEqual({ + data: [ + { + id: 'apt-1', + buildingId: 'building-1', + buildingName: 'Sunrise Tower', + floorId: 'floor-1', + floorName: 'Ground Floor', + unitNumber: '101', + bedrooms: 2, + bathrooms: '1.5', + sqft: 850, + }, + ], + }); + }); + + it('returns an empty list when the org has no vacant apartments', async () => { + const { service } = makeService(); + + const result = await service.findAvailable(orgId); + + expect(result).toEqual({ data: [] }); + }); + }); +}); diff --git a/apps/api/src/modules/available-units/available-units.service.ts b/apps/api/src/modules/available-units/available-units.service.ts new file mode 100644 index 00000000..2578d713 --- /dev/null +++ b/apps/api/src/modules/available-units/available-units.service.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@repo/db'; +import { PrismaService } from '@/infrastructure/prisma/prisma.service'; +import { AvailableUnit, AvailableUnitListResponse } from '@repo/contracts'; + +@Injectable() +export class AvailableUnitsService { + constructor(private readonly prisma: PrismaService) {} + + // ── Format helpers ──────────────────────────────────────────────────────── + + private formatAvailableUnit(apartment: { + id: string; + buildingId: string; + floorId: string; + unitNumber: string; + bedrooms: number; + bathrooms: Prisma.Decimal; + sqft: number | null; + building: { name: string }; + floor: { name: string }; + }): AvailableUnit { + return { + id: apartment.id, + buildingId: apartment.buildingId, + buildingName: apartment.building.name, + floorId: apartment.floorId, + floorName: apartment.floor.name, + unitNumber: apartment.unitNumber, + bedrooms: apartment.bedrooms, + bathrooms: apartment.bathrooms.toString(), + sqft: apartment.sqft, + }; + } + + // ── Reads ───────────────────────────────────────────────────────────────── + + /** + * F6.2 — every vacant apartment in the org, enriched with building/floor + * names for the renter-facing "Available units" showcase. Deliberately NOT + * building-access scoped like the nested apartments/leases reads: a tenant + * isn't building-assigned, and this showcase is meant to surface every + * vacant unit org-wide, not just the caller's assigned buildings. + */ + async findAvailable(orgId: string): Promise { + const apartments = await this.prisma.apartment.findMany({ + where: { orgId, status: 'vacant' }, + include: { + building: { select: { name: true } }, + floor: { select: { name: true } }, + }, + orderBy: [{ building: { name: 'asc' } }, { unitNumber: 'asc' }], + }); + + return { data: apartments.map((a) => this.formatAvailableUnit(a)) }; + } +} diff --git a/apps/api/src/modules/billing/billing.controller.ts b/apps/api/src/modules/billing/billing.controller.ts index 611e58e0..1c9da691 100644 --- a/apps/api/src/modules/billing/billing.controller.ts +++ b/apps/api/src/modules/billing/billing.controller.ts @@ -17,7 +17,7 @@ export class BillingController { private readonly orgScope: OrgScopeService, ) {} - @Roles(Role.ORG_ADMIN, Role.FINANCE) + @Roles(Role.ORG_ADMIN) @Get('subscription') async getSubscription(@CurrentUser() user: AuthenticatedUser) { const { orgId } = await this.orgScope.resolveForCaller(user); diff --git a/apps/api/src/modules/expenses/expenses.service.spec.ts b/apps/api/src/modules/expenses/expenses.service.spec.ts index 9c76d98e..6575aa02 100644 --- a/apps/api/src/modules/expenses/expenses.service.spec.ts +++ b/apps/api/src/modules/expenses/expenses.service.spec.ts @@ -53,6 +53,7 @@ describe('ExpensesService', () => { buildingId, vendorId: null, workOrderId: null, + workOrder: null, category: 'repairs', amount: { toString: () => '150.00' }, incurredAt: new Date('2026-01-05T00:00:00.000Z'), @@ -62,6 +63,10 @@ describe('ExpensesService', () => { ...overrides, }); + const WORK_ORDER_NUMBER_INCLUDE = { + workOrder: { select: { number: true } }, + }; + describe('findAll', () => { it('returns all org expenses for an org-wide role (org_admin)', async () => { const { service, prisma, buildingAccess } = makeService({ @@ -77,6 +82,7 @@ describe('ExpensesService', () => { ); expect(prisma.expense.findMany).toHaveBeenCalledWith({ where: { orgId }, + include: WORK_ORDER_NUMBER_INCLUDE, orderBy: { incurredAt: 'desc' }, }); expect(result.data).toEqual([ @@ -100,6 +106,7 @@ describe('ExpensesService', () => { ); expect(prisma.expense.findMany).toHaveBeenCalledWith({ where: { orgId }, + include: WORK_ORDER_NUMBER_INCLUDE, orderBy: { incurredAt: 'desc' }, }); }); @@ -115,9 +122,34 @@ describe('ExpensesService', () => { expect(prisma.expense.findMany).toHaveBeenCalledWith({ where: { orgId, buildingId: { in: [buildingId] } }, + include: WORK_ORDER_NUMBER_INCLUDE, orderBy: { incurredAt: 'desc' }, }); }); + + it('populates workOrderNumberLabel for a row with a linked work order', async () => { + const { service, prisma } = makeService({ + expense: { + findMany: jest + .fn() + .mockResolvedValue([ + expenseRow({ workOrderId: 'wo-1', workOrder: { number: 123 } }), + ]), + }, + }); + + const result = await service.findAll(orgId, callerId, Role.ORG_ADMIN); + + expect(prisma.expense.findMany).toHaveBeenCalledWith( + expect.objectContaining({ include: WORK_ORDER_NUMBER_INCLUDE }), + ); + expect(result.data).toEqual([ + expect.objectContaining({ + workOrderId: 'wo-1', + workOrderNumberLabel: 'WO-000123', + }), + ]); + }); }); describe('findOne', () => { @@ -135,6 +167,7 @@ describe('ExpensesService', () => { expect(prisma.expense.findFirst).toHaveBeenCalledWith({ where: { id: 'expense-1', orgId }, + include: WORK_ORDER_NUMBER_INCLUDE, }); expect(result.data).toEqual(expect.objectContaining({ id: 'expense-1' })); }); @@ -201,6 +234,7 @@ describe('ExpensesService', () => { incurredAt: new Date('2026-01-05T00:00:00.000Z'), notes: undefined, }, + include: WORK_ORDER_NUMBER_INCLUDE, }); expect(timeline.emit).toHaveBeenCalledWith( expect.objectContaining({ @@ -242,12 +276,14 @@ describe('ExpensesService', () => { where: { id: 'wo-1', orgId }, select: { vendorId: true }, }); - expect(prisma.expense.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ - vendorId: 'vendor-1', - workOrderId: 'wo-1', + expect(prisma.expense.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + vendorId: 'vendor-1', + workOrderId: 'wo-1', + }), }), - }); + ); }); it('rejects when vendorId and workOrderId are both provided but do not match', async () => { @@ -280,12 +316,14 @@ describe('ExpensesService', () => { vendorId: 'vendor-1', }); - expect(prisma.expense.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ - vendorId: 'vendor-1', - workOrderId: 'wo-1', + expect(prisma.expense.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + vendorId: 'vendor-1', + workOrderId: 'wo-1', + }), }), - }); + ); }); it('rejects with ForbiddenException for a supervisor caller', async () => { @@ -322,6 +360,7 @@ describe('ExpensesService', () => { expect(prisma.expense.update).toHaveBeenCalledWith({ where: { id: 'expense-1' }, data: { notes: 'Paid in full' }, + include: WORK_ORDER_NUMBER_INCLUDE, }); expect(timeline.emit).toHaveBeenCalledWith( expect.objectContaining({ action: 'expense.updated' }), diff --git a/apps/api/src/modules/expenses/expenses.service.ts b/apps/api/src/modules/expenses/expenses.service.ts index df8b2003..3718ae6f 100644 --- a/apps/api/src/modules/expenses/expenses.service.ts +++ b/apps/api/src/modules/expenses/expenses.service.ts @@ -12,7 +12,11 @@ import { } from '@/common/building-access/building-access.service'; import { TimelineService } from '@/modules/timeline/timeline.service'; import { Role } from '@/common/enums'; -import { ExpenseCategory, ExpenseResponse } from '@repo/contracts'; +import { + ExpenseCategory, + ExpenseResponse, + formatWorkOrderNumber, +} from '@repo/contracts'; import { CreateExpenseDto } from './dto/create-expense.dto'; import { UpdateExpenseDto } from './dto/update-expense.dto'; @@ -28,8 +32,15 @@ type ExpenseRow = { notes: string | null; createdAt: Date; updatedAt: Date; + workOrder?: { number: number } | null; }; +/** Shared across findAll/findOne/create/update so the linked Work Order's + * display number is always available to formatExpense. */ +const WORK_ORDER_NUMBER_INCLUDE = { + workOrder: { select: { number: true } }, +} as const; + @Injectable() export class ExpensesService { constructor( @@ -82,6 +93,9 @@ export class ExpensesService { buildingId: expense.buildingId, vendorId: expense.vendorId, workOrderId: expense.workOrderId, + workOrderNumberLabel: expense.workOrder + ? formatWorkOrderNumber(expense.workOrder.number) + : null, category: expense.category as ExpenseCategory, amount: expense.amount.toString(), incurredAt: expense.incurredAt.toISOString(), @@ -109,6 +123,7 @@ export class ExpensesService { orgId, ...(allowedBuildingIds && { buildingId: { in: allowedBuildingIds } }), }, + include: WORK_ORDER_NUMBER_INCLUDE, orderBy: { incurredAt: 'desc' }, }); @@ -123,6 +138,7 @@ export class ExpensesService { ): Promise<{ data: ExpenseResponse }> { const expense = await this.prisma.expense.findFirst({ where: { id: expenseId, orgId }, + include: WORK_ORDER_NUMBER_INCLUDE, }); if (!expense) throw new NotFoundException('Expense not found.'); @@ -176,6 +192,7 @@ export class ExpensesService { incurredAt: new Date(dto.incurredAt), notes: dto.notes, }, + include: WORK_ORDER_NUMBER_INCLUDE, }); await this.timeline.emit({ @@ -226,6 +243,7 @@ export class ExpensesService { }), ...(dto.notes !== undefined && { notes: dto.notes }), }, + include: WORK_ORDER_NUMBER_INCLUDE, }); await this.timeline.emit({ diff --git a/apps/api/src/modules/invoice-payments/invoice-payments.module.ts b/apps/api/src/modules/invoice-payments/invoice-payments.module.ts index 19644921..2d1a89ec 100644 --- a/apps/api/src/modules/invoice-payments/invoice-payments.module.ts +++ b/apps/api/src/modules/invoice-payments/invoice-payments.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { InvoicePaymentsController } from './invoice-payments.controller'; import { InvoicePaymentsService } from './invoice-payments.service'; +import { NotificationsModule } from '@/modules/notifications/notifications.module'; @Module({ + imports: [NotificationsModule], controllers: [InvoicePaymentsController], providers: [InvoicePaymentsService], exports: [InvoicePaymentsService], 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 71956c24..761ed460 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 @@ -16,6 +16,7 @@ describe('InvoicePaymentsService', () => { invoicePayment?: Partial>; invoice?: Partial>; buildingAccess?: Partial>; + notifications?: Partial>; } = {}, ) { const prisma: any = { @@ -37,12 +38,17 @@ describe('InvoicePaymentsService', () => { ...overrides.buildingAccess, }; const timeline = { emit: jest.fn().mockResolvedValue(undefined) }; + const notifications = { + enqueue: jest.fn().mockResolvedValue(undefined), + ...overrides.notifications, + }; const service = new InvoicePaymentsService( prisma, buildingAccess as any, timeline as any, + notifications as any, ); - return { service, prisma, buildingAccess, timeline }; + return { service, prisma, buildingAccess, timeline, notifications }; } const decimal = (value: string) => ({ @@ -168,7 +174,10 @@ describe('InvoicePaymentsService', () => { invoice: { findFirst: jest .fn() - .mockResolvedValueOnce({ id: 'invoice-1' }) + .mockResolvedValueOnce({ + id: 'invoice-1', + lease: { renter: { renterUserId: null } }, + }) .mockResolvedValueOnce(invoiceForSummary()), }, invoicePayment: { @@ -225,6 +234,57 @@ describe('InvoicePaymentsService', () => { service.create(orgId, callerId, Role.SUPERVISOR, dto), ).rejects.toBeInstanceOf(ForbiddenException); }); + + describe('tenant notification (F5.1)', () => { + it('notifies the tenant when the invoice lease renter has a linked portal user', async () => { + const { service, notifications } = makeService({ + invoice: { + findFirst: jest + .fn() + .mockResolvedValueOnce({ + id: 'invoice-1', + lease: { renter: { renterUserId: 'tenant-user-1' } }, + }) + .mockResolvedValueOnce(invoiceForSummary()), + }, + invoicePayment: { + create: jest.fn().mockResolvedValue(paymentRow()), + }, + }); + + await service.create(orgId, callerId, Role.ORG_ADMIN, dto); + + expect(notifications.enqueue).toHaveBeenCalledTimes(1); + expect(notifications.enqueue).toHaveBeenCalledWith( + expect.objectContaining({ + orgId, + userId: 'tenant-user-1', + type: 'invoice.payment_recorded', + }), + ); + }); + + it('does not notify when the renter has no linked portal user', async () => { + const { service, notifications } = makeService({ + invoice: { + findFirst: jest + .fn() + .mockResolvedValueOnce({ + id: 'invoice-1', + lease: { renter: { renterUserId: null } }, + }) + .mockResolvedValueOnce(invoiceForSummary()), + }, + invoicePayment: { + create: jest.fn().mockResolvedValue(paymentRow()), + }, + }); + + await service.create(orgId, callerId, Role.ORG_ADMIN, dto); + + expect(notifications.enqueue).not.toHaveBeenCalled(); + }); + }); }); describe('remove', () => { 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 679d89fe..0d965698 100644 --- a/apps/api/src/modules/invoice-payments/invoice-payments.service.ts +++ b/apps/api/src/modules/invoice-payments/invoice-payments.service.ts @@ -8,6 +8,7 @@ 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 { NotificationsService } from '@/modules/notifications/notifications.service'; import { Role } from '@/common/enums'; import { InvoicePaymentMethod, @@ -35,6 +36,7 @@ export class InvoicePaymentsService { private readonly prisma: PrismaService, private readonly buildingAccess: BuildingAccessService, private readonly timeline: TimelineService, + private readonly notifications: NotificationsService, ) {} private assertWriteAccess(callerRole: Role): void { @@ -159,7 +161,10 @@ export class InvoicePaymentsService { const invoice = await this.prisma.invoice.findFirst({ where: { id: dto.invoiceId, orgId }, - select: { id: true }, + select: { + id: true, + lease: { select: { renter: { select: { renterUserId: true } } } }, + }, }); if (!invoice) throw new NotFoundException('Invoice not found.'); @@ -183,6 +188,19 @@ export class InvoicePaymentsService { metadata: { invoiceId: invoice.id, amount: payment.amount.toString() }, }); + // F5.1: notify the tenant a payment was recorded — skip silently if the + // renter has no linked portal user. + if (invoice.lease.renter.renterUserId) { + await this.notifications.enqueue({ + orgId, + userId: invoice.lease.renter.renterUserId, + type: 'invoice.payment_recorded', + title: 'Payment recorded', + body: `A payment of ${payment.amount.toString()} was recorded on your invoice.`, + data: { invoiceId: invoice.id, paymentId: payment.id }, + }); + } + const summary = await this.summarizeInvoice(orgId, invoice.id); return { data: { payment: this.formatPayment(payment), invoice: summary } }; diff --git a/apps/api/src/modules/invoices/invoices.module.ts b/apps/api/src/modules/invoices/invoices.module.ts index 058d12f8..25ec475d 100644 --- a/apps/api/src/modules/invoices/invoices.module.ts +++ b/apps/api/src/modules/invoices/invoices.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { InvoicesController } from './invoices.controller'; import { InvoicesService } from './invoices.service'; +import { NotificationsModule } from '@/modules/notifications/notifications.module'; @Module({ + imports: [NotificationsModule], controllers: [InvoicesController], providers: [InvoicesService], exports: [InvoicesService], diff --git a/apps/api/src/modules/invoices/invoices.service.spec.ts b/apps/api/src/modules/invoices/invoices.service.spec.ts index b02a2d2c..ecd5ed7c 100644 --- a/apps/api/src/modules/invoices/invoices.service.spec.ts +++ b/apps/api/src/modules/invoices/invoices.service.spec.ts @@ -19,6 +19,7 @@ describe('InvoicesService', () => { invoiceLineItem?: Partial>; invoicePayment?: Partial>; buildingAccess?: Partial>; + notifications?: Partial>; } = {}, ) { const prisma: any = { @@ -52,12 +53,17 @@ describe('InvoicesService', () => { ...overrides.buildingAccess, }; const timeline = { emit: jest.fn().mockResolvedValue(undefined) }; + const notifications = { + enqueue: jest.fn().mockResolvedValue(undefined), + ...overrides.notifications, + }; const service = new InvoicesService( prisma, buildingAccess as any, timeline as any, + notifications as any, ); - return { service, prisma, buildingAccess, timeline }; + return { service, prisma, buildingAccess, timeline, notifications }; } const decimal = (value: string) => ({ @@ -210,7 +216,11 @@ describe('InvoicesService', () => { 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 }), + findFirst: jest.fn().mockResolvedValue({ + id: 'lease-1', + buildingId, + renter: { renterUserId: null }, + }), }, invoice: { create: jest.fn().mockResolvedValue(invoiceRow()) }, }); @@ -275,6 +285,49 @@ describe('InvoicesService', () => { service.create(orgId, callerId, Role.SUPERVISOR, dto), ).rejects.toBeInstanceOf(ForbiddenException); }); + + describe('tenant notification (F5.1)', () => { + it('notifies the tenant when the lease renter has a linked portal user', async () => { + const { service, notifications } = makeService({ + lease: { + findFirst: jest.fn().mockResolvedValue({ + id: 'lease-1', + buildingId, + renter: { renterUserId: 'tenant-user-1' }, + }), + }, + invoice: { create: jest.fn().mockResolvedValue(invoiceRow()) }, + }); + + await service.create(orgId, callerId, Role.ORG_ADMIN, dto); + + expect(notifications.enqueue).toHaveBeenCalledTimes(1); + expect(notifications.enqueue).toHaveBeenCalledWith( + expect.objectContaining({ + orgId, + userId: 'tenant-user-1', + type: 'invoice.issued', + }), + ); + }); + + it('does not notify when the renter has no linked portal user', async () => { + const { service, notifications } = makeService({ + lease: { + findFirst: jest.fn().mockResolvedValue({ + id: 'lease-1', + buildingId, + renter: { renterUserId: null }, + }), + }, + invoice: { create: jest.fn().mockResolvedValue(invoiceRow()) }, + }); + + await service.create(orgId, callerId, Role.ORG_ADMIN, dto); + + expect(notifications.enqueue).not.toHaveBeenCalled(); + }); + }); }); describe('update', () => { diff --git a/apps/api/src/modules/invoices/invoices.service.ts b/apps/api/src/modules/invoices/invoices.service.ts index 1f514fad..4cc2d3f7 100644 --- a/apps/api/src/modules/invoices/invoices.service.ts +++ b/apps/api/src/modules/invoices/invoices.service.ts @@ -9,6 +9,7 @@ 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 { NotificationsService } from '@/modules/notifications/notifications.service'; import { Role } from '@/common/enums'; import { InvoiceLineItemCategory, InvoiceResponse } from '@repo/contracts'; import { computeInvoiceSummary } from '@/common/invoice-summary/compute-invoice-summary'; @@ -58,6 +59,7 @@ export class InvoicesService { private readonly prisma: PrismaService, private readonly buildingAccess: BuildingAccessService, private readonly timeline: TimelineService, + private readonly notifications: NotificationsService, ) {} private assertWriteAccess(callerRole: Role): void { @@ -180,7 +182,11 @@ export class InvoicesService { const lease = await this.prisma.lease.findFirst({ where: { id: dto.leaseId, orgId }, - select: { id: true, buildingId: true }, + select: { + id: true, + buildingId: true, + renter: { select: { renterUserId: true } }, + }, }); if (!lease) throw new NotFoundException('Lease not found.'); @@ -214,6 +220,19 @@ export class InvoicesService { }, }); + // F5.1: notify the tenant an invoice was issued — skip silently if the + // renter has no linked portal user (renterUserId is nullable). + if (lease.renter.renterUserId) { + await this.notifications.enqueue({ + orgId, + userId: lease.renter.renterUserId, + type: 'invoice.issued', + title: 'New invoice issued', + body: `An invoice due ${invoice.dueDate.toISOString().slice(0, 10)} has been issued to your lease.`, + data: { invoiceId: invoice.id, leaseId: invoice.leaseId }, + }); + } + return { data: this.formatInvoice(invoice) }; } diff --git a/apps/api/src/modules/leases/dto/create-lease.dto.ts b/apps/api/src/modules/leases/dto/create-lease.dto.ts index cfe9ee24..e3775f2b 100644 --- a/apps/api/src/modules/leases/dto/create-lease.dto.ts +++ b/apps/api/src/modules/leases/dto/create-lease.dto.ts @@ -1,4 +1,5 @@ import { + IsBoolean, IsDateString, IsEnum, IsNotEmpty, @@ -51,4 +52,14 @@ export class CreateLeaseDto { @IsOptional() @IsString() notes?: string; + + @ApiPropertyOptional({ + description: + "F4.3 (decision D3): a NEW active lease may not silently start in the past. " + + 'Set this to explicitly record an already-existing lease with a back-dated ' + + 'start; without it, a past start on an active lease is rejected (400).', + }) + @IsOptional() + @IsBoolean() + recordExisting?: boolean; } diff --git a/apps/api/src/modules/leases/leases.module.ts b/apps/api/src/modules/leases/leases.module.ts index 588310b0..e8b3b7f9 100644 --- a/apps/api/src/modules/leases/leases.module.ts +++ b/apps/api/src/modules/leases/leases.module.ts @@ -2,8 +2,10 @@ import { Module } from '@nestjs/common'; import { LeasesController } from './leases.controller'; import { LeasesOverviewController } from './leases-overview.controller'; import { LeasesService } from './leases.service'; +import { NotificationsModule } from '@/modules/notifications/notifications.module'; @Module({ + imports: [NotificationsModule], 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 9bae2a03..45d5cd60 100644 --- a/apps/api/src/modules/leases/leases.service.spec.ts +++ b/apps/api/src/modules/leases/leases.service.spec.ts @@ -1,4 +1,5 @@ import { + BadRequestException, ConflictException, ForbiddenException, NotFoundException, @@ -28,6 +29,7 @@ describe('LeasesService', () => { building?: Partial>; floor?: Partial>; buildingAccess?: Partial>; + notifications?: Partial>; } = {}, ) { const prisma: any = { @@ -74,6 +76,10 @@ describe('LeasesService', () => { ); const timeline = { emit: jest.fn().mockResolvedValue(undefined) }; + const notifications = { + enqueue: jest.fn().mockResolvedValue(undefined), + ...overrides.notifications, + }; const buildingAccess = { assertBuildingAccess: jest.fn().mockResolvedValue(undefined), getAllowedBuildingIds: jest.fn(), @@ -83,10 +89,18 @@ describe('LeasesService', () => { const service = new LeasesService( prisma, timeline as any, + notifications as any, buildingAccess as any, leaseStatus, ); - return { service, prisma, timeline, buildingAccess, leaseStatus }; + return { + service, + prisma, + timeline, + notifications, + buildingAccess, + leaseStatus, + }; } const leaseRow = (overrides: Partial> = {}) => ({ @@ -108,9 +122,13 @@ describe('LeasesService', () => { ...overrides, }); + // Comfortably in the future (well past "today" for the foreseeable life of + // this suite) so the F4.3 past-start guard never trips on the happy path. + const FAR_ENOUGH_FUTURE_START = '2050-01-01T00:00:00.000Z'; + const dto = { renterId, - startDate: '2026-01-01T00:00:00.000Z', + startDate: FAR_ENOUGH_FUTURE_START, endDate: FAR_FUTURE, rentAmount: 1500, depositAmount: 1500, @@ -146,6 +164,20 @@ describe('LeasesService', () => { }); }); + it('rejects creating a lease whose end date is not after its start date', async () => { + const { service, prisma } = makeService(); + + await expect( + service.create(orgId, actorId, buildingId, floorId, apartmentId, { + ...dto, + startDate: '2026-06-01T00:00:00.000Z', + endDate: '2026-06-01T00:00:00.000Z', + }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(prisma.lease.create).not.toHaveBeenCalled(); + }); + it('does not sync the apartment when created with an explicit non-active status', async () => { const { service, prisma } = makeService(); prisma.lease.create.mockResolvedValue(leaseRow({ status: 'draft' })); @@ -158,7 +190,7 @@ describe('LeasesService', () => { expect(prisma.apartment.update).not.toHaveBeenCalled(); }); - it('rejects creating a second active lease on an apartment that already has one', async () => { + it('rejects creating a second active lease whose dates fully overlap an existing one', async () => { const { service, prisma } = makeService({ lease: { findMany: jest.fn().mockResolvedValue([leaseRow()]), @@ -171,22 +203,6 @@ describe('LeasesService', () => { expect(prisma.lease.create).not.toHaveBeenCalled(); }); - it('allows creating a draft lease alongside an existing active lease', async () => { - const { service, prisma } = makeService({ - lease: { - findMany: jest.fn().mockResolvedValue([leaseRow()]), - }, - }); - prisma.lease.create.mockResolvedValue(leaseRow({ status: 'draft' })); - - await service.create(orgId, actorId, buildingId, floorId, apartmentId, { - ...dto, - status: 'draft', - }); - - expect(prisma.lease.create).toHaveBeenCalled(); - }); - it('allows a new active lease when the existing one has already expired', async () => { const { service, prisma } = makeService({ lease: { @@ -209,6 +225,198 @@ describe('LeasesService', () => { expect(prisma.lease.create).toHaveBeenCalled(); }); + describe('date-overlap check (F4.1)', () => { + // A bounded existing lease (not FAR_FUTURE-ended) so there's real room + // to construct disjoint/adjacent/partial ranges around it. + const boundedExisting = () => + leaseRow({ + id: 'lease-existing', + startDate: new Date('2030-01-01T00:00:00.000Z'), + endDate: new Date('2031-01-01T00:00:00.000Z'), + }); + + it('rejects a partial overlap at the head (new lease starts before, ends inside)', async () => { + const { service, prisma } = makeService({ + lease: { findMany: jest.fn().mockResolvedValue([boundedExisting()]) }, + }); + + await expect( + service.create(orgId, actorId, buildingId, floorId, apartmentId, { + ...dto, + startDate: '2029-06-01T00:00:00.000Z', + endDate: '2030-06-01T00:00:00.000Z', + }), + ).rejects.toBeInstanceOf(ConflictException); + expect(prisma.lease.create).not.toHaveBeenCalled(); + }); + + it('rejects a partial overlap at the tail (new lease starts inside, ends after)', async () => { + const { service, prisma } = makeService({ + lease: { findMany: jest.fn().mockResolvedValue([boundedExisting()]) }, + }); + + await expect( + service.create(orgId, actorId, buildingId, floorId, apartmentId, { + ...dto, + startDate: '2030-06-01T00:00:00.000Z', + endDate: '2031-06-01T00:00:00.000Z', + }), + ).rejects.toBeInstanceOf(ConflictException); + expect(prisma.lease.create).not.toHaveBeenCalled(); + }); + + it('allows a back-to-back lease starting exactly when the existing one ends', async () => { + const { service, prisma } = makeService({ + lease: { findMany: jest.fn().mockResolvedValue([boundedExisting()]) }, + }); + prisma.lease.create.mockResolvedValue(leaseRow()); + + await service.create(orgId, actorId, buildingId, floorId, apartmentId, { + ...dto, + startDate: '2031-01-01T00:00:00.000Z', // === existing.endDate + endDate: '2032-01-01T00:00:00.000Z', + }); + + expect(prisma.lease.create).toHaveBeenCalled(); + }); + + it('allows a disjoint future lease (starts well after the existing one ends)', async () => { + const { service, prisma } = makeService({ + lease: { findMany: jest.fn().mockResolvedValue([boundedExisting()]) }, + }); + prisma.lease.create.mockResolvedValue(leaseRow()); + + await service.create(orgId, actorId, buildingId, floorId, apartmentId, { + ...dto, + startDate: '2035-01-01T00:00:00.000Z', + endDate: '2036-01-01T00:00:00.000Z', + }); + + expect(prisma.lease.create).toHaveBeenCalled(); + }); + + it('allows a disjoint past lease (ends before the existing one starts)', async () => { + const { service, prisma } = makeService({ + lease: { findMany: jest.fn().mockResolvedValue([boundedExisting()]) }, + }); + prisma.lease.create.mockResolvedValue(leaseRow({ status: 'draft' })); + + // Use a draft here (start-date policy F4.3 only guards 'active'). + await service.create(orgId, actorId, buildingId, floorId, apartmentId, { + ...dto, + status: 'draft', + startDate: '2020-01-01T00:00:00.000Z', + endDate: '2021-01-01T00:00:00.000Z', + }); + + expect(prisma.lease.create).toHaveBeenCalled(); + }); + + it('ignores a terminated existing lease even if its dates fully overlap', async () => { + const { service, prisma } = makeService({ + lease: { + findMany: jest + .fn() + .mockResolvedValue([ + { ...boundedExisting(), status: 'terminated' }, + ]), + }, + }); + prisma.lease.create.mockResolvedValue(leaseRow()); + + await service.create(orgId, actorId, buildingId, floorId, apartmentId, { + ...dto, + startDate: '2030-01-01T00:00:00.000Z', + endDate: '2031-01-01T00:00:00.000Z', + }); + + expect(prisma.lease.create).toHaveBeenCalled(); + }); + + it('applies the overlap check to draft leases too (not exempt)', async () => { + const { service, prisma } = makeService({ + lease: { findMany: jest.fn().mockResolvedValue([boundedExisting()]) }, + }); + + await expect( + service.create(orgId, actorId, buildingId, floorId, apartmentId, { + ...dto, + status: 'draft', + startDate: '2030-06-01T00:00:00.000Z', + endDate: '2030-09-01T00:00:00.000Z', + }), + ).rejects.toBeInstanceOf(ConflictException); + expect(prisma.lease.create).not.toHaveBeenCalled(); + }); + }); + + describe('start-date policy (F4.3)', () => { + it('rejects a new active lease starting in the past without recordExisting', async () => { + const { service, prisma } = makeService(); + + await expect( + service.create(orgId, actorId, buildingId, floorId, apartmentId, { + ...dto, + startDate: '2020-01-01T00:00:00.000Z', + }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(prisma.lease.create).not.toHaveBeenCalled(); + }); + + it('allows a past-start active lease when recordExisting is true', async () => { + const { service, prisma } = makeService(); + prisma.lease.create.mockResolvedValue( + leaseRow({ startDate: new Date('2020-01-01T00:00:00.000Z') }), + ); + + await service.create(orgId, actorId, buildingId, floorId, apartmentId, { + ...dto, + startDate: '2020-01-01T00:00:00.000Z', + recordExisting: true, + }); + + expect(prisma.lease.create).toHaveBeenCalled(); + }); + + it('allows an active lease starting today', async () => { + const { service, prisma } = makeService(); + const today = new Date(); + const todayUtcMidnight = new Date( + Date.UTC( + today.getUTCFullYear(), + today.getUTCMonth(), + today.getUTCDate(), + ), + ).toISOString(); + prisma.lease.create.mockResolvedValue(leaseRow()); + + await service.create(orgId, actorId, buildingId, floorId, apartmentId, { + ...dto, + startDate: todayUtcMidnight, + }); + + expect(prisma.lease.create).toHaveBeenCalled(); + }); + + it('allows a past-start draft lease without recordExisting (guard only applies to active)', async () => { + const { service, prisma } = makeService(); + prisma.lease.create.mockResolvedValue( + leaseRow({ + status: 'draft', + startDate: new Date('2020-01-01T00:00:00.000Z'), + }), + ); + + await service.create(orgId, actorId, buildingId, floorId, apartmentId, { + ...dto, + status: 'draft', + startDate: '2020-01-01T00:00:00.000Z', + }); + + expect(prisma.lease.create).toHaveBeenCalled(); + }); + }); + it('throws NotFoundException when the apartment does not belong to the floor/building/org', async () => { const { service } = makeService({ apartment: { findFirst: jest.fn().mockResolvedValue(null) }, @@ -228,6 +436,59 @@ describe('LeasesService', () => { service.create(orgId, actorId, buildingId, floorId, apartmentId, dto), ).rejects.toBeInstanceOf(NotFoundException); }); + + describe('tenant notification (F5.1)', () => { + it('notifies the tenant when the renter has a linked portal user', async () => { + const { service, prisma, notifications } = makeService({ + renter: { + findFirst: jest + .fn() + .mockResolvedValue({ id: renterId, renterUserId: 'tenant-user-1' }), + }, + }); + prisma.lease.create.mockResolvedValue(leaseRow()); + + await service.create( + orgId, + actorId, + buildingId, + floorId, + apartmentId, + dto, + ); + + expect(notifications.enqueue).toHaveBeenCalledTimes(1); + expect(notifications.enqueue).toHaveBeenCalledWith( + expect.objectContaining({ + orgId, + userId: 'tenant-user-1', + type: 'lease.created', + }), + ); + }); + + it('does not notify when the renter has no linked portal user', async () => { + const { service, prisma, notifications } = makeService({ + renter: { + findFirst: jest + .fn() + .mockResolvedValue({ id: renterId, renterUserId: null }), + }, + }); + prisma.lease.create.mockResolvedValue(leaseRow()); + + await service.create( + orgId, + actorId, + buildingId, + floorId, + apartmentId, + dto, + ); + + expect(notifications.enqueue).not.toHaveBeenCalled(); + }); + }); }); describe('update', () => { @@ -313,6 +574,91 @@ describe('LeasesService', () => { ), ).rejects.toBeInstanceOf(NotFoundException); }); + + describe('date-overlap check (F4.1)', () => { + it('excludes the lease being updated from the overlap check (self-exclusion)', async () => { + const { service, prisma } = makeService({ + lease: { + findFirst: jest.fn().mockResolvedValue(leaseRow()), + findMany: jest.fn().mockResolvedValue([]), + }, + }); + prisma.lease.update.mockResolvedValue( + leaseRow({ endDate: new Date('2099-06-01T00:00:00.000Z') }), + ); + + await service.update( + orgId, + actorId, + buildingId, + floorId, + apartmentId, + 'lease-1', + { endDate: '2099-06-01T00:00:00.000Z' }, + ); + + expect(prisma.lease.findMany).toHaveBeenCalledWith({ + where: { apartmentId, id: { not: 'lease-1' } }, + select: { status: true, startDate: true, endDate: true }, + }); + expect(prisma.lease.update).toHaveBeenCalled(); + }); + + it('rejects updating a lease when the new dates overlap another non-terminated lease on the same apartment', async () => { + const { service, prisma } = makeService({ + lease: { + findFirst: jest.fn().mockResolvedValue(leaseRow()), + findMany: jest.fn().mockResolvedValue([ + leaseRow({ + id: 'lease-2', + startDate: new Date('2027-06-01T00:00:00.000Z'), + endDate: new Date('2028-06-01T00:00:00.000Z'), + }), + ]), + }, + }); + + await expect( + service.update( + orgId, + actorId, + buildingId, + floorId, + apartmentId, + 'lease-1', + { + startDate: '2027-01-01T00:00:00.000Z', + endDate: '2027-12-01T00:00:00.000Z', + }, + ), + ).rejects.toBeInstanceOf(ConflictException); + expect(prisma.lease.update).not.toHaveBeenCalled(); + }); + + it('does not re-run the overlap check when neither dates nor status change', async () => { + const { service, prisma } = makeService({ + lease: { + findFirst: jest.fn().mockResolvedValue(leaseRow()), + }, + }); + prisma.lease.update.mockResolvedValue( + leaseRow({ rentAmount: new Prisma.Decimal('1600.00') }), + ); + + await service.update( + orgId, + actorId, + buildingId, + floorId, + apartmentId, + 'lease-1', + { rentAmount: 1600 }, + ); + + expect(prisma.lease.findMany).not.toHaveBeenCalled(); + expect(prisma.lease.update).toHaveBeenCalled(); + }); + }); }); describe('remove', () => { diff --git a/apps/api/src/modules/leases/leases.service.ts b/apps/api/src/modules/leases/leases.service.ts index 3df5a02b..8539f3f4 100644 --- a/apps/api/src/modules/leases/leases.service.ts +++ b/apps/api/src/modules/leases/leases.service.ts @@ -1,10 +1,12 @@ import { + BadRequestException, ConflictException, 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 { BuildingAccessService } from '@/common/building-access/building-access.service'; import { LeaseStatusService } from '@/common/lease-status/lease-status.service'; import { Role } from '@/common/enums'; @@ -21,6 +23,7 @@ export class LeasesService { constructor( private readonly prisma: PrismaService, private readonly timeline: TimelineService, + private readonly notifications: NotificationsService, private readonly buildingAccess: BuildingAccessService, private readonly leaseStatus: LeaseStatusService, ) {} @@ -48,12 +51,81 @@ export class LeasesService { private async assertRenterInOrg( orgId: string, renterId: string, - ): Promise { + ): Promise<{ id: string; renterUserId: string | null }> { const renter = await this.prisma.renter.findFirst({ where: { id: renterId, orgId }, - select: { id: true }, + select: { id: true, renterUserId: true }, }); if (!renter) throw new NotFoundException('Renter not found.'); + return renter; + } + + /** + * Server-side invariant: a lease must end strictly after it starts. The FE + * enforces this too, but a direct API call previously bypassed it (the DTO + * only validated each date in isolation), so a lease with endDate <= startDate + * could be persisted. + */ + private assertValidDateRange( + startDate: string | Date, + endDate: string | Date, + ): void { + const startMs = new Date(startDate).getTime(); + const endMs = new Date(endDate).getTime(); + if (Number.isNaN(startMs) || Number.isNaN(endMs)) { + throw new BadRequestException('Invalid lease start or end date.'); + } + if (endMs <= startMs) { + throw new BadRequestException( + 'Lease end date must be after the start date.', + ); + } + } + + /** UTC midnight for `date` — the F4.3 start-date policy compares at day granularity. */ + private startOfUtcDay(date: Date): Date { + return new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()), + ); + } + + /** + * F4.1: true date-range overlap check, replacing the old "any effectively- + * active lease on the apartment" rule (which ignored the new lease's own + * dates and made back-to-back/future leases impossible). Rejects only if + * `[startDate, endDate)` overlaps an existing NON-terminated lease's own + * `[startDate, endDate)` on the same apartment — end-exclusive, so + * back-to-back leases (newStart === existingEnd) are allowed. Terminated + * leases are filtered out in application code (matching the sibling + * WorkOrderApartmentStatusService convention of querying broadly and + * filtering status in JS) so a stale mock/row can't silently bypass this in + * tests. `excludeLeaseId` lets update() exclude its own row. + */ + private async assertNoOverlappingLease( + apartmentId: string, + startDate: Date, + endDate: Date, + excludeLeaseId?: string, + ): Promise { + const existingLeases = await this.prisma.lease.findMany({ + where: { + apartmentId, + ...(excludeLeaseId && { id: { not: excludeLeaseId } }), + }, + select: { status: true, startDate: true, endDate: true }, + }); + + const overlaps = existingLeases.some( + (l) => + l.status !== 'terminated' && + startDate < l.endDate && + l.startDate < endDate, + ); + if (overlaps) { + throw new ConflictException( + 'This apartment already has a lease that overlaps these dates.', + ); + } } // ── CRUD ────────────────────────────────────────────────────────────────── @@ -185,26 +257,33 @@ export class LeasesService { dto: CreateLeaseDto, ): Promise<{ data: LeaseResponse }> { await this.assertApartmentInScope(orgId, buildingId, floorId, apartmentId); - await this.assertRenterInOrg(orgId, dto.renterId); + const renter = await this.assertRenterInOrg(orgId, dto.renterId); + + const startDate = new Date(dto.startDate); + const endDate = new Date(dto.endDate); + this.assertValidDateRange(startDate, endDate); const resolvedStatus = dto.status ?? 'active'; const now = new Date(); - if (resolvedStatus === 'active') { - const existingLeases = await this.prisma.lease.findMany({ - where: { apartmentId }, - }); - const hasActiveLease = existingLeases.some((l) => - this.leaseStatus.isEffectivelyActive( - { status: l.status, endDate: l.endDate }, - now, - ), + // F4.3 (decision D3): a new ACTIVE lease can't silently start in the + // past — that almost always means the caller meant to back-date an + // already-existing lease, which must be explicit via recordExisting. + if ( + resolvedStatus === 'active' && + this.startOfUtcDay(startDate) < this.startOfUtcDay(now) && + !dto.recordExisting + ) { + throw new BadRequestException( + "A new active lease can't start in the past; use 'record an existing lease' to back-date.", ); - if (hasActiveLease) { - throw new ConflictException( - 'This apartment already has an active lease.', - ); - } + } + + // F4.1: reject only on a genuine date-range overlap with another + // non-terminated lease on this apartment — not simply "an active lease + // exists" (that made future/back-to-back leases impossible). + if (resolvedStatus !== 'terminated') { + await this.assertNoOverlappingLease(apartmentId, startDate, endDate); } const lease = await this.prisma.$transaction(async (tx) => { @@ -215,8 +294,8 @@ export class LeasesService { floorId, apartmentId, renterId: dto.renterId, - startDate: new Date(dto.startDate), - endDate: new Date(dto.endDate), + startDate, + endDate, rentAmount: dto.rentAmount, depositAmount: dto.depositAmount, status: resolvedStatus, @@ -244,6 +323,19 @@ export class LeasesService { metadata: { apartmentId, renterId: dto.renterId }, }); + // F5.1: notify the tenant — skip silently if the renter has no linked + // portal user. + if (renter.renterUserId) { + await this.notifications.enqueue({ + orgId, + userId: renter.renterUserId, + type: 'lease.created', + title: 'New lease created', + body: 'A new lease has been created for you.', + data: { leaseId: lease.id, apartmentId }, + }); + } + return { data: this.formatLease(lease) }; } @@ -261,6 +353,31 @@ export class LeasesService { }); if (!existing) throw new NotFoundException('Lease not found.'); + // Validate the resulting date range against whichever dates are being + // changed, falling back to the stored values for the untouched one. + const resolvedStartDate = + dto.startDate !== undefined ? new Date(dto.startDate) : existing.startDate; + const resolvedEndDate = + dto.endDate !== undefined ? new Date(dto.endDate) : existing.endDate; + this.assertValidDateRange(resolvedStartDate, resolvedEndDate); + + // F4.1: re-run the overlap check when the update touches the dates + // and/or (re)activates the lease — excluding the lease's own row so it + // doesn't conflict with itself. + const resolvedStatus = dto.status ?? existing.status; + const datesOrStatusChanging = + dto.startDate !== undefined || + dto.endDate !== undefined || + dto.status !== undefined; + if (datesOrStatusChanging && resolvedStatus !== 'terminated') { + await this.assertNoOverlappingLease( + apartmentId, + resolvedStartDate, + resolvedEndDate, + leaseId, + ); + } + const isTerminating = dto.status === 'terminated' && existing.status !== 'terminated'; diff --git a/apps/api/src/modules/payments/payments.controller.ts b/apps/api/src/modules/payments/payments.controller.ts index 99061fe6..d1731a2b 100644 --- a/apps/api/src/modules/payments/payments.controller.ts +++ b/apps/api/src/modules/payments/payments.controller.ts @@ -8,9 +8,8 @@ import { Role } from '@/common/enums'; @ApiTags('payments') @ApiBearerAuth() -// Supervisor is read-only — no mutating payment endpoints are exposed to supervisor. // TODO: scope by buildingId once Payment is building-linked. -@Roles(Role.ORG_ADMIN, Role.FINANCE, Role.SUPERVISOR) +@Roles(Role.ORG_ADMIN, Role.FINANCE) @Controller('payments') export class PaymentsController { constructor( diff --git a/apps/api/src/modules/payments/payments.service.spec.ts b/apps/api/src/modules/payments/payments.service.spec.ts new file mode 100644 index 00000000..407de712 --- /dev/null +++ b/apps/api/src/modules/payments/payments.service.spec.ts @@ -0,0 +1,45 @@ +import { PaymentsService } from './payments.service'; + +/** + * Regression guard for the paginated-list contract drift bug: the backend list + * envelope MUST expose its rows under `items` (matching PaginatedResponse in + * @repo/contracts and the FE reads), never `data`. A `data` array here silently + * renders empty lists on the client (the FE reads `.items`), and would also be + * mis-peeled by the client's `unwrap` ApiEnvelope transform. + */ +describe('PaymentsService', () => { + const orgId = 'org-1'; + + function makeService(rows: unknown[] = [], total = 0) { + const prisma: any = { + payment: { + findMany: jest.fn().mockResolvedValue(rows), + count: jest.fn().mockResolvedValue(total), + }, + }; + return { service: new PaymentsService(prisma), prisma }; + } + + it('returns the paginated envelope keyed by `items`, not `data`', async () => { + const rows = [{ id: 'pay-1' }, { id: 'pay-2' }]; + const { service } = makeService(rows, 2); + + const result = await service.findAll(orgId, 1, 20); + + expect(result).toEqual({ items: rows, total: 2, page: 1, limit: 20 }); + expect(result.items).toEqual(rows); + expect(result).not.toHaveProperty('data'); + }); + + it('applies page/limit as skip/take and echoes them back', async () => { + const { service, prisma } = makeService([], 0); + + const result = await service.findAll(orgId, 3, 10); + + expect(prisma.payment.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { orgId }, skip: 20, take: 10 }), + ); + expect(result.page).toBe(3); + expect(result.limit).toBe(10); + }); +}); diff --git a/apps/api/src/modules/recurring-invoices/recurring-invoices-scheduler.service.ts b/apps/api/src/modules/recurring-invoices/recurring-invoices-scheduler.service.ts new file mode 100644 index 00000000..c61e9d0c --- /dev/null +++ b/apps/api/src/modules/recurring-invoices/recurring-invoices-scheduler.service.ts @@ -0,0 +1,42 @@ +import { InjectQueue } from '@nestjs/bullmq'; +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Queue } from 'bullmq'; +import { + RECURRING_INVOICES_CRON, + RECURRING_INVOICES_DAILY_JOB_ID, + RECURRING_INVOICES_QUEUE, + RECURRING_INVOICES_RUN_JOB, +} from './recurring-invoices.constants'; + +/** + * Registers the daily repeatable job on module init (there is no + * `@nestjs/schedule` in this app — BullMQ's repeatable jobs are the + * scheduling primitive here, same as the rest of the queue infra). A fixed + * jobId means BullMQ upserts the same repeatable job definition on every app + * restart instead of accumulating duplicate schedules. + */ +@Injectable() +export class RecurringInvoicesSchedulerService implements OnModuleInit { + private readonly logger = new Logger(RecurringInvoicesSchedulerService.name); + + constructor( + @InjectQueue(RECURRING_INVOICES_QUEUE) private readonly queue: Queue, + ) {} + + async onModuleInit(): Promise { + try { + await this.queue.add( + RECURRING_INVOICES_RUN_JOB, + {}, + { + repeat: { pattern: RECURRING_INVOICES_CRON }, + jobId: RECURRING_INVOICES_DAILY_JOB_ID, + }, + ); + } catch (error) { + this.logger.error( + `Failed to schedule the daily recurring-invoices job: ${String(error)}`, + ); + } + } +} diff --git a/apps/api/src/modules/recurring-invoices/recurring-invoices.constants.ts b/apps/api/src/modules/recurring-invoices/recurring-invoices.constants.ts new file mode 100644 index 00000000..7d0e9228 --- /dev/null +++ b/apps/api/src/modules/recurring-invoices/recurring-invoices.constants.ts @@ -0,0 +1,15 @@ +/** Name of the BullMQ queue that drives the daily recurring-invoice run. */ +export const RECURRING_INVOICES_QUEUE = 'recurring-invoices'; + +/** Job name used for the daily generation run. */ +export const RECURRING_INVOICES_RUN_JOB = 'run-daily'; + +/** + * Fixed jobId for the repeatable job. BullMQ upserts a repeatable job by its + * (name, jobId, repeat options) — reusing this id on every app restart avoids + * accumulating duplicate schedules. + */ +export const RECURRING_INVOICES_DAILY_JOB_ID = 'recurring-invoices-daily'; + +/** Daily at 06:00 UTC — well ahead of the 7-day generation lead window. */ +export const RECURRING_INVOICES_CRON = '0 6 * * *'; diff --git a/apps/api/src/modules/recurring-invoices/recurring-invoices.controller.ts b/apps/api/src/modules/recurring-invoices/recurring-invoices.controller.ts new file mode 100644 index 00000000..9f5f8087 --- /dev/null +++ b/apps/api/src/modules/recurring-invoices/recurring-invoices.controller.ts @@ -0,0 +1,30 @@ +import { Controller, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { RecurringInvoicesService } from './recurring-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'; + +/** + * Manual "generate now" trigger for recurring rent invoices (F3.1). Runs the + * same idempotent core the daily scheduler runs, scoped to the caller's org — + * both a real feature (an org admin doesn't have to wait for the 06:00 UTC + * job) and the primary way to live-verify generation. + */ +@ApiTags('recurring-invoices') +@ApiBearerAuth() +@Controller('recurring-invoices') +@Roles(Role.ORG_ADMIN) +export class RecurringInvoicesController { + constructor( + private readonly recurringInvoices: RecurringInvoicesService, + private readonly orgScope: OrgScopeService, + ) {} + + @Post('run') + async run(@CurrentUser() user: AuthenticatedUser) { + const { orgId } = await this.orgScope.resolveForCaller(user); + return this.recurringInvoices.runForOrg(orgId); + } +} diff --git a/apps/api/src/modules/recurring-invoices/recurring-invoices.module.ts b/apps/api/src/modules/recurring-invoices/recurring-invoices.module.ts new file mode 100644 index 00000000..bb7c9d57 --- /dev/null +++ b/apps/api/src/modules/recurring-invoices/recurring-invoices.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bullmq'; +import { RecurringInvoicesController } from './recurring-invoices.controller'; +import { RecurringInvoicesService } from './recurring-invoices.service'; +import { RecurringInvoicesProcessor } from './recurring-invoices.processor'; +import { RecurringInvoicesSchedulerService } from './recurring-invoices-scheduler.service'; +import { RECURRING_INVOICES_QUEUE } from './recurring-invoices.constants'; +import { NotificationsModule } from '@/modules/notifications/notifications.module'; + +@Module({ + imports: [ + BullModule.registerQueue({ name: RECURRING_INVOICES_QUEUE }), + NotificationsModule, + ], + controllers: [RecurringInvoicesController], + providers: [ + RecurringInvoicesService, + RecurringInvoicesProcessor, + RecurringInvoicesSchedulerService, + ], + exports: [RecurringInvoicesService], +}) +export class RecurringInvoicesModule {} diff --git a/apps/api/src/modules/recurring-invoices/recurring-invoices.processor.ts b/apps/api/src/modules/recurring-invoices/recurring-invoices.processor.ts new file mode 100644 index 00000000..0f134e63 --- /dev/null +++ b/apps/api/src/modules/recurring-invoices/recurring-invoices.processor.ts @@ -0,0 +1,25 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Logger } from '@nestjs/common'; +import { Job } from 'bullmq'; +import { RECURRING_INVOICES_QUEUE } from './recurring-invoices.constants'; +import { RecurringInvoicesService } from './recurring-invoices.service'; + +/** + * Consumes the daily repeatable job and runs generation across every org. + * The manual `POST /recurring-invoices/run` endpoint calls + * {@link RecurringInvoicesService.runForOrg} directly (single-org, synchronous + * response) — this processor is only reached by the scheduled job. + */ +@Processor(RECURRING_INVOICES_QUEUE) +export class RecurringInvoicesProcessor extends WorkerHost { + private readonly logger = new Logger(RecurringInvoicesProcessor.name); + + constructor(private readonly recurringInvoices: RecurringInvoicesService) { + super(); + } + + async process(job: Job): Promise { + this.logger.debug(`Starting recurring-invoice run (job ${job.id})`); + await this.recurringInvoices.runAll(); + } +} diff --git a/apps/api/src/modules/recurring-invoices/recurring-invoices.service.spec.ts b/apps/api/src/modules/recurring-invoices/recurring-invoices.service.spec.ts new file mode 100644 index 00000000..b360754b --- /dev/null +++ b/apps/api/src/modules/recurring-invoices/recurring-invoices.service.spec.ts @@ -0,0 +1,340 @@ +import { Prisma } from '@repo/db'; +import { RecurringInvoicesService } from './recurring-invoices.service'; +import { LeaseStatusService } from '@/common/lease-status/lease-status.service'; + +describe('RecurringInvoicesService', () => { + const orgId = 'org-1'; + const buildingId = 'building-1'; + const leaseId = 'lease-1'; + + function makeService( + overrides: { + invoice?: Partial>; + lease?: Partial>; + notifications?: Partial>; + } = {}, + ) { + const prisma: any = { + invoice: { + findUnique: jest.fn().mockResolvedValue(null), + create: jest + .fn() + .mockImplementation((args: any) => + Promise.resolve({ id: 'invoice-new', ...args.data }), + ), + ...overrides.invoice, + }, + lease: { + findMany: jest.fn().mockResolvedValue([]), + ...overrides.lease, + }, + }; + const timeline = { emit: jest.fn().mockResolvedValue(undefined) }; + const notifications = { + enqueue: jest.fn().mockResolvedValue(undefined), + ...overrides.notifications, + }; + const leaseStatus = new LeaseStatusService(); + const service = new RecurringInvoicesService( + prisma, + timeline as any, + notifications as any, + leaseStatus, + ); + return { service, prisma, timeline, notifications, leaseStatus }; + } + + const lease = (overrides: Partial> = {}) => ({ + id: leaseId, + orgId, + buildingId, + startDate: new Date('2026-01-15T00:00:00.000Z'), + endDate: new Date('2027-01-15T00:00:00.000Z'), + status: 'active', + rentAmount: new Prisma.Decimal('1000.00'), + renterUserId: null, + ...overrides, + }); + + // runForOrg/runAll query the DB with `renter: { select: { renterUserId } }` + // nested (RecurringLeaseInput itself stays flat for generateForLease's + // hand-built fixtures — see its doc comment). This wraps a flat `lease()` + // fixture into the raw Prisma-shaped row runForOrg's mapping expects. + const rawLeaseRow = (l: ReturnType) => { + const { renterUserId, ...rest } = l; + return { ...rest, renter: { renterUserId: renterUserId ?? null } }; + }; + + describe('generateForLease', () => { + it('creates an invoice when the next due date is within the 7-day lead window', async () => { + const { service, prisma, timeline } = makeService(); + const asOf = new Date('2026-06-15T00:00:00.000Z'); // anchor day 15 — due today + + const result = await service.generateForLease(lease(), asOf); + + expect(result).toBe('created'); + expect(prisma.invoice.create).toHaveBeenCalledWith({ + data: { + orgId, + buildingId, + leaseId, + dueDate: new Date('2026-06-15T00:00:00.000Z'), + billingPeriod: '2026-06', + lineItems: { + create: [ + { + category: 'rent', + description: 'Monthly rent', + amount: expect.any(Prisma.Decimal), + }, + ], + }, + }, + }); + expect(timeline.emit).toHaveBeenCalledWith( + expect.objectContaining({ + orgId, + action: 'invoice.auto_generated', + targetType: 'Invoice', + metadata: { leaseId, billingPeriod: '2026-06' }, + }), + ); + }); + + it('clamps a 31st anchor into a short month (Feb) and still generates within the window', async () => { + const { service, prisma } = makeService(); + const shortMonthLease = lease({ + startDate: new Date('2026-01-31T00:00:00.000Z'), + }); + const asOf = new Date('2026-02-24T00:00:00.000Z'); // 4 days before Feb 28 (clamped) + + const result = await service.generateForLease(shortMonthLease, asOf); + + expect(result).toBe('created'); + expect(prisma.invoice.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + dueDate: new Date('2026-02-28T00:00:00.000Z'), + billingPeriod: '2026-02', + }), + }), + ); + }); + + it('returns not-due when the next occurrence is more than 7 days away', async () => { + const { service, prisma } = makeService(); + const asOf = new Date('2026-06-01T00:00:00.000Z'); // anchor 15th — 14 days away + + const result = await service.generateForLease(lease(), asOf); + + expect(result).toBe('not-due'); + expect(prisma.invoice.create).not.toHaveBeenCalled(); + }); + + it('returns skipped-existing when an invoice already exists for the billing period', async () => { + const { service, prisma } = makeService({ + invoice: { + findUnique: jest.fn().mockResolvedValue({ id: 'existing-invoice' }), + }, + }); + const asOf = new Date('2026-06-15T00:00:00.000Z'); + + const result = await service.generateForLease(lease(), asOf); + + expect(result).toBe('skipped-existing'); + expect(prisma.invoice.create).not.toHaveBeenCalled(); + }); + + it('is idempotent: calling generateForLease twice for the same period creates exactly one invoice', async () => { + // Fake the DB unique constraint on (leaseId, billingPeriod) with a tiny + // in-memory store, so the second call's findUnique sees what the first + // call's create wrote. + const store = new Map(); + const { service, prisma } = makeService({ + invoice: { + findUnique: jest.fn().mockImplementation(({ where }: any) => { + const key = `${where.leaseId_billingPeriod.leaseId}:${where.leaseId_billingPeriod.billingPeriod}`; + return Promise.resolve(store.get(key) ?? null); + }), + create: jest.fn().mockImplementation(({ data }: any) => { + const key = `${data.leaseId}:${data.billingPeriod}`; + const row = { id: `invoice-${store.size + 1}`, ...data }; + store.set(key, row); + return Promise.resolve(row); + }), + }, + }); + const asOf = new Date('2026-06-15T00:00:00.000Z'); + const theLease = lease(); + + const first = await service.generateForLease(theLease, asOf); + const second = await service.generateForLease(theLease, asOf); + + expect(first).toBe('created'); + expect(second).toBe('skipped-existing'); + expect(prisma.invoice.create).toHaveBeenCalledTimes(1); + }); + + it('treats a P2002 unique-constraint race on create as skipped-existing', async () => { + const { service, prisma } = makeService({ + invoice: { + create: jest.fn().mockRejectedValue( + new Prisma.PrismaClientKnownRequestError('duplicate', { + code: 'P2002', + clientVersion: 'test', + }), + ), + }, + }); + const asOf = new Date('2026-06-15T00:00:00.000Z'); + + const result = await service.generateForLease(lease(), asOf); + + expect(result).toBe('skipped-existing'); + }); + + it('returns inactive when the lease has already ended by the candidate due date', async () => { + const { service, prisma } = makeService(); + const endedLease = lease({ + startDate: new Date('2025-01-15T00:00:00.000Z'), + endDate: new Date('2026-05-31T00:00:00.000Z'), // ended before the June candidate + }); + const asOf = new Date('2026-06-10T00:00:00.000Z'); + + const result = await service.generateForLease(endedLease, asOf); + + expect(result).toBe('inactive'); + expect(prisma.invoice.create).not.toHaveBeenCalled(); + }); + + it('returns inactive for a non-active (draft/terminated) lease status', async () => { + const { service } = makeService(); + const draftLease = lease({ status: 'draft' }); + const asOf = new Date('2026-06-15T00:00:00.000Z'); + + const result = await service.generateForLease(draftLease, asOf); + + expect(result).toBe('inactive'); + }); + + it('returns inactive when the computed candidate falls before the lease actually starts', async () => { + const { service, prisma } = makeService(); + // Lease doesn't start until August 10th; asOf is in early June, well + // before the lease starts, yet the periodic anchor's June occurrence + // still falls within the 7-day lead window relative to asOf. + const futureLease = lease({ + startDate: new Date('2026-08-10T00:00:00.000Z'), + endDate: new Date('2027-08-10T00:00:00.000Z'), + }); + const asOf = new Date('2026-06-05T00:00:00.000Z'); + + const result = await service.generateForLease(futureLease, asOf); + + expect(result).toBe('inactive'); + expect(prisma.invoice.create).not.toHaveBeenCalled(); + }); + + describe('tenant notification (F5.1)', () => { + it('notifies the tenant when the lease has a linked portal user', async () => { + const { service, notifications } = makeService(); + const asOf = new Date('2026-06-15T00:00:00.000Z'); + + const result = await service.generateForLease( + lease({ renterUserId: 'tenant-user-1' }), + asOf, + ); + + expect(result).toBe('created'); + expect(notifications.enqueue).toHaveBeenCalledTimes(1); + expect(notifications.enqueue).toHaveBeenCalledWith( + expect.objectContaining({ + orgId, + userId: 'tenant-user-1', + type: 'invoice.issued', + }), + ); + }); + + it('does not notify when the lease has no linked portal user', async () => { + const { service, notifications } = makeService(); + const asOf = new Date('2026-06-15T00:00:00.000Z'); + + const result = await service.generateForLease(lease(), asOf); + + expect(result).toBe('created'); + expect(notifications.enqueue).not.toHaveBeenCalled(); + }); + + it('does not notify when no invoice is generated (not-due)', async () => { + const { service, notifications } = makeService(); + const asOf = new Date('2026-06-01T00:00:00.000Z'); // 14 days away + + const result = await service.generateForLease( + lease({ renterUserId: 'tenant-user-1' }), + asOf, + ); + + expect(result).toBe('not-due'); + expect(notifications.enqueue).not.toHaveBeenCalled(); + }); + }); + }); + + describe('runForOrg', () => { + it("aggregates created/skipped/considered across the org's active leases", async () => { + const dueLease = lease({ id: 'lease-due' }); + const notDueLease = lease({ + id: 'lease-not-due', + startDate: new Date('2026-01-01T00:00:00.000Z'), + }); + const { service, prisma } = makeService({ + lease: { + findMany: jest + .fn() + .mockResolvedValue([ + rawLeaseRow(dueLease), + rawLeaseRow(notDueLease), + ]), + }, + }); + const asOf = new Date('2026-06-15T00:00:00.000Z'); + + const result = await service.runForOrg(orgId, asOf); + + expect(prisma.lease.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { orgId, status: 'active' } }), + ); + expect(result.leasesConsidered).toBe(2); + expect(result.generated).toBe(1); + expect(result.skippedExisting).toBe(0); + }); + }); + + describe('runAll', () => { + it('sums runForOrg results across every org with an active lease', async () => { + const { service, prisma } = makeService({ + lease: { + findMany: jest + .fn() + .mockResolvedValueOnce([{ orgId: 'org-a' }, { orgId: 'org-b' }]) // distinct orgId query + .mockResolvedValueOnce([rawLeaseRow(lease({ orgId: 'org-a' }))]) // org-a leases + .mockResolvedValueOnce([ + rawLeaseRow(lease({ orgId: 'org-b', id: 'lease-2' })), + ]), // org-b leases + }, + }); + const asOf = new Date('2026-06-15T00:00:00.000Z'); + + const result = await service.runAll(asOf); + + expect(prisma.lease.findMany).toHaveBeenNthCalledWith(1, { + where: { status: 'active' }, + select: { orgId: true }, + distinct: ['orgId'], + }); + expect(result.leasesConsidered).toBe(2); + expect(result.generated).toBe(2); + expect(result.skippedExisting).toBe(0); + }); + }); +}); diff --git a/apps/api/src/modules/recurring-invoices/recurring-invoices.service.ts b/apps/api/src/modules/recurring-invoices/recurring-invoices.service.ts new file mode 100644 index 00000000..a8324242 --- /dev/null +++ b/apps/api/src/modules/recurring-invoices/recurring-invoices.service.ts @@ -0,0 +1,270 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Prisma } from '@repo/db'; +import { PrismaService } from '@/infrastructure/prisma/prisma.service'; +import { TimelineService } from '@/modules/timeline/timeline.service'; +import { NotificationsService } from '@/modules/notifications/notifications.service'; +import { LeaseStatusService } from '@/common/lease-status/lease-status.service'; +import { LeaseStatus, RecurringInvoiceRunResponse } from '@repo/contracts'; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** Generate 7 days before the computed due date (decision D1). */ +const LEAD_WINDOW_DAYS = 7; + +export type GenerateResult = + 'created' | 'skipped-existing' | 'not-due' | 'inactive'; + +/** + * The minimal lease shape {@link RecurringInvoicesService.generateForLease} + * needs. Deliberately DB-shape-agnostic (plain fields, no Prisma payload + * types) so unit tests can hand-build fixtures without a real Lease row. + */ +export type RecurringLeaseInput = { + id: string; + orgId: string; + buildingId: string; + startDate: Date; + endDate: Date; + /** Raw DB status ('draft' | 'active' | 'terminated' — 'expired' is derived, never stored). */ + status: string; + rentAmount: Prisma.Decimal | number; + /** Keycloak sub of the tenant to notify when an invoice is auto-generated (F5.1); null if the renter has no linked portal user. */ + renterUserId: string | null; +}; + +/** + * F3.1 recurring rent invoices (decision D1): monthly, anchored to the + * lease's start-date day-of-month, generated 7 days before the computed due + * date, idempotent per (lease, billing period) via the Invoice + * `@@unique([leaseId, billingPeriod])` constraint. No proration — the line + * item is always the flat `lease.rentAmount`. + */ +@Injectable() +export class RecurringInvoicesService { + private readonly logger = new Logger(RecurringInvoicesService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly timeline: TimelineService, + private readonly notifications: NotificationsService, + private readonly leaseStatus: LeaseStatusService, + ) {} + + /** UTC midnight for `date` — every comparison below is done at day granularity. */ + private startOfUtcDay(date: Date): Date { + return new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()), + ); + } + + /** + * `anchorDay` clamped into (year, month) — e.g. anchor 31 in a 28-day + * February becomes Feb 28. `month` may be any integer (including < 0 or + * > 11); `Date.UTC` normalizes the overflow/underflow into the correct + * year, which is what lets `nextDueDate` roll into next month for free. + */ + private clampedDateForMonth( + year: number, + month: number, + anchorDay: number, + ): Date { + const lastDayOfMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); + return new Date(Date.UTC(year, month, Math.min(anchorDay, lastDayOfMonth))); + } + + /** + * The next occurrence of `anchorDay` on/after `today` (both already + * UTC-midnight-normalized): this month's occurrence (clamped for short + * months), or next month's if this month's has already passed. + */ + private nextDueDate(anchorDay: number, today: Date): Date { + const thisMonth = this.clampedDateForMonth( + today.getUTCFullYear(), + today.getUTCMonth(), + anchorDay, + ); + if (thisMonth < today) { + return this.clampedDateForMonth( + today.getUTCFullYear(), + today.getUTCMonth() + 1, + anchorDay, + ); + } + return thisMonth; + } + + private billingPeriodKey(date: Date): string { + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + return `${date.getUTCFullYear()}-${month}`; + } + + /** + * Pure, idempotent core: given a lease and an "as of" date, decide whether + * a rent invoice is due and — if so — create it. Safe to call repeatedly + * for the same (lease, asOf): a second call in the same billing period is + * a no-op ('skipped-existing'), backstopped by the DB unique constraint + * even under a concurrent-writer race. + */ + async generateForLease( + lease: RecurringLeaseInput, + asOf: Date, + ): Promise { + const today = this.startOfUtcDay(asOf); + const anchorDay = lease.startDate.getUTCDate(); + const candidate = this.nextDueDate(anchorDay, today); + + const leadDays = Math.round( + (candidate.getTime() - today.getTime()) / MS_PER_DAY, + ); + if (leadDays > LEAD_WINDOW_DAYS) { + return 'not-due'; + } + + const isActive = this.leaseStatus.isEffectivelyActive( + { status: lease.status as LeaseStatus, endDate: lease.endDate }, + candidate, + ); + if (candidate < this.startOfUtcDay(lease.startDate) || !isActive) { + return 'inactive'; + } + + const billingPeriod = this.billingPeriodKey(candidate); + + const existing = await this.prisma.invoice.findUnique({ + where: { leaseId_billingPeriod: { leaseId: lease.id, billingPeriod } }, + }); + if (existing) { + return 'skipped-existing'; + } + + try { + const invoice = await this.prisma.invoice.create({ + data: { + orgId: lease.orgId, + buildingId: lease.buildingId, + leaseId: lease.id, + dueDate: candidate, + billingPeriod, + lineItems: { + create: [ + { + category: 'rent', + description: 'Monthly rent', + amount: lease.rentAmount, + }, + ], + }, + }, + }); + + await this.timeline.emit({ + orgId: lease.orgId, + action: 'invoice.auto_generated', + targetType: 'Invoice', + targetId: invoice.id, + metadata: { leaseId: lease.id, billingPeriod }, + }); + + // F5.1: notify the tenant — system-generated, no actor. Skip silently + // when the renter has no linked portal user. + if (lease.renterUserId) { + await this.notifications.enqueue({ + orgId: lease.orgId, + userId: lease.renterUserId, + type: 'invoice.issued', + title: 'New invoice issued', + body: `Your rent invoice for ${billingPeriod} has been issued, due ${candidate.toISOString().slice(0, 10)}.`, + data: { invoiceId: invoice.id, leaseId: lease.id, billingPeriod }, + }); + } + + return 'created'; + } catch (err) { + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === 'P2002' + ) { + // Idempotency backstop: another writer created the same + // (leaseId, billingPeriod) invoice in the race window between our + // existence check and this create. + return 'skipped-existing'; + } + throw err; + } + } + + /** Run generation for every active lease in one org. */ + async runForOrg( + orgId: string, + asOf: Date = new Date(), + ): Promise { + const rawLeases = await this.prisma.lease.findMany({ + where: { orgId, status: 'active' }, + select: { + id: true, + orgId: true, + buildingId: true, + startDate: true, + endDate: true, + status: true, + rentAmount: true, + renter: { select: { renterUserId: true } }, + }, + }); + + // Flatten Renter.renterUserId onto the lease — RecurringLeaseInput stays + // DB-shape-agnostic (see its doc comment) so generateForLease's unit + // tests can keep hand-building plain fixtures. + const leases: RecurringLeaseInput[] = rawLeases.map((l) => ({ + id: l.id, + orgId: l.orgId, + buildingId: l.buildingId, + startDate: l.startDate, + endDate: l.endDate, + status: l.status, + rentAmount: l.rentAmount, + renterUserId: l.renter?.renterUserId ?? null, + })); + + let generated = 0; + let skippedExisting = 0; + for (const lease of leases) { + const result = await this.generateForLease(lease, asOf); + if (result === 'created') generated++; + else if (result === 'skipped-existing') skippedExisting++; + } + + return { generated, leasesConsidered: leases.length, skippedExisting }; + } + + /** + * Run generation across every org — the daily scheduler's entry point. + * Iterates orgs (derived from the set of orgs with an active lease) and + * aggregates each org's {@link runForOrg} result. + */ + async runAll(asOf: Date = new Date()): Promise { + const orgs = await this.prisma.lease.findMany({ + where: { status: 'active' }, + select: { orgId: true }, + distinct: ['orgId'], + }); + + const totals: RecurringInvoiceRunResponse = { + generated: 0, + leasesConsidered: 0, + skippedExisting: 0, + }; + for (const { orgId } of orgs) { + const result = await this.runForOrg(orgId, asOf); + totals.generated += result.generated; + totals.leasesConsidered += result.leasesConsidered; + totals.skippedExisting += result.skippedExisting; + } + + this.logger.log( + `Recurring invoice run: generated=${totals.generated} considered=${totals.leasesConsidered} skipped=${totals.skippedExisting}`, + ); + + return totals; + } +} 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 5796350b..da87a6aa 100644 --- a/apps/api/src/modules/renters/dto/create-renter.dto.ts +++ b/apps/api/src/modules/renters/dto/create-renter.dto.ts @@ -1,6 +1,25 @@ -import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { + IsEmail, + IsNotEmpty, + IsOptional, + IsString, + MinLength, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +export class PortalLoginDto { + @ApiProperty() + @IsEmail() + email: string; + + @ApiProperty() + @IsString() + @MinLength(8) + password: string; +} + export class CreateRenterDto { @ApiProperty() @IsString() @@ -40,4 +59,14 @@ export class CreateRenterDto { @IsOptional() @IsString() renterUserId?: string | null; + + @ApiPropertyOptional({ + description: + 'When present, the API mints a Keycloak tenant login for this renter and links it (sets renterUserId to the new sub). Admin-provisioned only — no self-registration. If both portalLogin and renterUserId are provided, portalLogin wins.', + type: PortalLoginDto, + }) + @IsOptional() + @ValidateNested() + @Type(() => PortalLoginDto) + portalLogin?: PortalLoginDto; } diff --git a/apps/api/src/modules/renters/renters.service.spec.ts b/apps/api/src/modules/renters/renters.service.spec.ts index e80ada10..092985b1 100644 --- a/apps/api/src/modules/renters/renters.service.spec.ts +++ b/apps/api/src/modules/renters/renters.service.spec.ts @@ -19,6 +19,7 @@ describe('RentersService', () => { renter?: Partial>; lease?: Partial>; buildingAccess?: Partial>; + keycloakAdmin?: Partial>; } = {}, ) { const prisma = { @@ -42,14 +43,21 @@ describe('RentersService', () => { getAllowedBuildingIds: jest.fn().mockResolvedValue(null), ...overrides.buildingAccess, }; + const keycloakAdmin = { + createUserWithPassword: jest.fn().mockResolvedValue('kc-sub-1'), + setSingleClientRole: jest.fn().mockResolvedValue(undefined), + deleteUser: jest.fn().mockResolvedValue(undefined), + ...overrides.keycloakAdmin, + }; const leaseStatus = new LeaseStatusService(); const service = new RentersService( prisma as any, timeline as any, buildingAccess as any, leaseStatus, + keycloakAdmin as any, ); - return { service, prisma, timeline, buildingAccess }; + return { service, prisma, timeline, buildingAccess, keycloakAdmin }; } const renterRow = (overrides: Partial> = {}) => ({ @@ -221,7 +229,7 @@ describe('RentersService', () => { const dto = { fullName: 'Jane Doe' }; it('creates a renter scoped to the org and emits renter.created', async () => { - const { service, prisma, timeline } = makeService(); + const { service, prisma, timeline, keycloakAdmin } = makeService(); prisma.renter.create.mockResolvedValue(renterRow()); await service.create(orgId, actorId, dto); @@ -245,6 +253,71 @@ describe('RentersService', () => { targetType: 'Renter', }), ); + // No portalLogin was passed → zero Keycloak calls, behavior unchanged. + expect(keycloakAdmin.createUserWithPassword).not.toHaveBeenCalled(); + expect(keycloakAdmin.setSingleClientRole).not.toHaveBeenCalled(); + expect(keycloakAdmin.deleteUser).not.toHaveBeenCalled(); + }); + + it('mints a Keycloak tenant login when portalLogin is provided, links renterUserId to the new sub, and tags the timeline event', async () => { + const { service, prisma, timeline, keycloakAdmin } = makeService(); + prisma.renter.create.mockResolvedValue( + renterRow({ renterUserId: 'kc-sub-1' }), + ); + + const portalDto = { + fullName: 'Jane Doe', + portalLogin: { email: 'jane@tenant.test', password: 'password123' }, + }; + + await service.create(orgId, actorId, portalDto); + + expect(keycloakAdmin.createUserWithPassword).toHaveBeenCalledWith({ + username: 'jane@tenant.test', + password: 'password123', + email: 'jane@tenant.test', + firstName: 'Jane', + lastName: 'Doe', + attributes: { org_id: [orgId] }, + }); + expect(keycloakAdmin.setSingleClientRole).toHaveBeenCalledWith( + 'kc-sub-1', + Role.TENANT, + ); + expect(prisma.renter.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ renterUserId: 'kc-sub-1' }), + }); + expect(timeline.emit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'renter.created', + metadata: expect.objectContaining({ portalLogin: true }), + }), + ); + }); + + it('maps a Keycloak 409 (email already taken) to ConflictException and persists no renter', async () => { + const conflict = { + isAxiosError: true, + response: { status: 409 }, + message: 'Request failed with status code 409', + }; + + const { service, prisma, keycloakAdmin } = makeService({ + keycloakAdmin: { + createUserWithPassword: jest.fn().mockRejectedValue(conflict), + }, + }); + + const portalDto = { + fullName: 'Jane Doe', + portalLogin: { email: 'jane@tenant.test', password: 'password123' }, + }; + + await expect( + service.create(orgId, actorId, portalDto), + ).rejects.toBeInstanceOf(ConflictException); + expect(prisma.renter.create).not.toHaveBeenCalled(); + expect(keycloakAdmin.setSingleClientRole).not.toHaveBeenCalled(); }); }); diff --git a/apps/api/src/modules/renters/renters.service.ts b/apps/api/src/modules/renters/renters.service.ts index 6153d222..5aae08a4 100644 --- a/apps/api/src/modules/renters/renters.service.ts +++ b/apps/api/src/modules/renters/renters.service.ts @@ -2,12 +2,15 @@ import { ConflictException, ForbiddenException, Injectable, + Logger, NotFoundException, } from '@nestjs/common'; +import { isAxiosError } from 'axios'; import { PrismaService } from '@/infrastructure/prisma/prisma.service'; 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 { KeycloakAdminService } from '@/infrastructure/keycloak/keycloak-admin.service'; import { formatLease, LeaseRow } from '@/modules/leases/lease-formatter'; import { Role } from '@/common/enums'; import { @@ -34,11 +37,14 @@ type RenterRow = { @Injectable() export class RentersService { + private readonly logger = new Logger(RentersService.name); + constructor( private readonly prisma: PrismaService, private readonly timeline: TimelineService, private readonly buildingAccess: BuildingAccessService, private readonly leaseStatus: LeaseStatusService, + private readonly keycloakAdmin: KeycloakAdminService, ) {} // ── Format helpers ──────────────────────────────────────────────────────── @@ -148,18 +154,65 @@ export class RentersService { actorId: string, dto: CreateRenterDto, ): Promise<{ data: RenterResponse }> { - const renter = await this.prisma.renter.create({ - data: { - orgId, - fullName: dto.fullName, - email: dto.email, - phone: dto.phone, - emergencyContactName: dto.emergencyContactName, - emergencyContactPhone: dto.emergencyContactPhone, - notes: dto.notes, - renterUserId: dto.renterUserId, - }, - }); + // portalLogin wins over a passed-through renterUserId (see CreateRenterBody). + let renterUserId = dto.renterUserId; + let portalLoginMinted = false; + + if (dto.portalLogin) { + const [firstName = '', ...rest] = dto.fullName.trim().split(' '); + const lastName = rest.join(' '); + + try { + renterUserId = await this.keycloakAdmin.createUserWithPassword({ + username: dto.portalLogin.email, + password: dto.portalLogin.password, + email: dto.portalLogin.email, + firstName, + lastName, + attributes: { org_id: [orgId] }, + }); + } catch (error) { + if (isAxiosError(error) && error.response?.status === 409) { + throw new ConflictException( + 'A portal login with this email already exists.', + ); + } + throw error; + } + + // Tenant is NOT a CAPPED_ROLE and does not get building assignments — + // unlike staff (see UsersService.create). + await this.keycloakAdmin.setSingleClientRole(renterUserId, Role.TENANT); + portalLoginMinted = true; + } + + let renter: RenterRow; + try { + renter = await this.prisma.renter.create({ + data: { + orgId, + fullName: dto.fullName, + email: dto.email, + phone: dto.phone, + emergencyContactName: dto.emergencyContactName, + emergencyContactPhone: dto.emergencyContactPhone, + notes: dto.notes, + renterUserId, + }, + }); + } catch (error) { + // Best-effort rollback: don't strand a Keycloak login with no renter row. + if (portalLoginMinted && renterUserId) { + try { + await this.keycloakAdmin.deleteUser(renterUserId); + } catch (cleanupError) { + this.logger.warn( + `Failed to roll back Keycloak user ${renterUserId} after renter creation failure: ${String(cleanupError)}`, + ); + } + } + throw error; + } await this.timeline.emit({ orgId, @@ -167,7 +220,10 @@ export class RentersService { action: 'renter.created', targetType: 'Renter', targetId: renter.id, - metadata: { fullName: renter.fullName }, + metadata: { + fullName: renter.fullName, + ...(portalLoginMinted && { portalLogin: true }), + }, }); return { data: this.formatRenter(renter, null) }; diff --git a/apps/api/src/modules/tenant/tenant.service.spec.ts b/apps/api/src/modules/tenant/tenant.service.spec.ts index ecd14283..83dc8d4f 100644 --- a/apps/api/src/modules/tenant/tenant.service.spec.ts +++ b/apps/api/src/modules/tenant/tenant.service.spec.ts @@ -72,6 +72,7 @@ describe('TenantService', () => { expect(data.linked).toBe(false); expect(data.renter).toBeNull(); expect(data.lease).toBeNull(); + expect(data.leaseHistory).toEqual([]); expect(data.invoices).toEqual([]); expect(data.maintenanceRequests).toEqual([]); expect(data.balance).toEqual({ @@ -157,6 +158,44 @@ describe('TenantService', () => { ]); }); + it('returns the full lease history newest-first, distinct from the single current lease', async () => { + const olderLease = leaseRow({ + id: 'lease-0', + startDate: new Date('2024-01-01T00:00:00.000Z'), + endDate: new Date('2025-01-01T00:00:00.000Z'), + status: 'ended', + apartment: { unitNumber: '2A', building: { name: 'Old Building' } }, + }); + const currentLease = leaseRow(); // startDate 2026-01-01, newer + const { service } = makeService({ + renter: { findFirst: jest.fn().mockResolvedValue(renter) }, + // Prisma orders by startDate desc, so the mock returns newest first. + lease: { + findMany: jest + .fn() + .mockResolvedValue([currentLease, olderLease]), + }, + }); + + const { data } = await service.getOverview(orgId, sub, now); + + expect(data.leaseHistory).toHaveLength(2); + expect(data.leaseHistory.map((l) => l.id)).toEqual([ + 'lease-1', + 'lease-0', + ]); + expect(data.leaseHistory[1]).toEqual( + expect.objectContaining({ + id: 'lease-0', + unitNumber: '2A', + buildingName: 'Old Building', + status: 'ended', + }), + ); + // `lease` remains the single current one, unaffected by the history list. + expect(data.lease?.id).toBe('lease-1'); + }); + it('derives effectiveStatus expired for an active lease whose endDate has passed', async () => { const { service } = makeService({ renter: { findFirst: jest.fn().mockResolvedValue(renter) }, diff --git a/apps/api/src/modules/tenant/tenant.service.ts b/apps/api/src/modules/tenant/tenant.service.ts index 98e27e35..5e933f55 100644 --- a/apps/api/src/modules/tenant/tenant.service.ts +++ b/apps/api/src/modules/tenant/tenant.service.ts @@ -74,6 +74,7 @@ export class TenantService { linked: false, renter: null, lease: null, + leaseHistory: [], balance: { invoiced: '0.00', paid: '0.00', outstanding: '0.00' }, invoices: [], maintenanceRequests: [], @@ -226,6 +227,12 @@ export class TenantService { phone: renter.phone, }, lease: current ? this.formatLease(current, now) : null, + // Full lease history for the portal (TP3) — same per-lease formatter as + // `lease`, applied to every lease the tenant has ever held, newest first + // (leases were already fetched ordered by startDate desc). + leaseHistory: (leases as LeaseRow[]).map((l) => + this.formatLease(l, now), + ), balance: { invoiced: this.money(totalInvoiced), paid: this.money(totalPaid), diff --git a/apps/api/src/modules/timeline/timeline.service.spec.ts b/apps/api/src/modules/timeline/timeline.service.spec.ts new file mode 100644 index 00000000..05e9b0ad --- /dev/null +++ b/apps/api/src/modules/timeline/timeline.service.spec.ts @@ -0,0 +1,156 @@ +import { TimelineService } from './timeline.service'; +import { AuthenticatedUser } from '@/common/types/authenticated-user.type'; + +/** + * Regression guard for the paginated-list contract drift bug: the timeline feed + * envelope MUST expose its rows under `items` (matching PaginatedResponse in + * @repo/contracts and the FE reads), never `data`. A `data` array here silently + * renders every "Recent activity" feed empty on the client. + */ +describe('TimelineService', () => { + const orgId = 'org-1'; + + function makeService( + rows: unknown[] = [], + total = 0, + overrides: { keycloakAdmin?: Partial> } = {}, + ) { + const prisma: any = { + event: { + findMany: jest.fn().mockResolvedValue(rows), + count: jest.fn().mockResolvedValue(total), + }, + }; + const keycloakAdmin = { + getUser: jest.fn().mockResolvedValue(null), + ...overrides.keycloakAdmin, + }; + return { + service: new TimelineService(prisma, keycloakAdmin as any), + prisma, + keycloakAdmin, + }; + } + + const caller = (roles: string[]): AuthenticatedUser => + ({ sub: 'user-1', roles } as unknown as AuthenticatedUser); + + it('returns the paginated envelope keyed by `items`, not `data`', async () => { + const rows = [{ id: 'evt-1' }, { id: 'evt-2' }]; + const { service } = makeService(rows, 2); + + const result = await service.findForCaller(orgId, caller(['org_admin']), 1, 20); + + expect(result).toEqual({ + items: [ + { id: 'evt-1', actorName: null }, + { id: 'evt-2', actorName: null }, + ], + total: 2, + page: 1, + limit: 20, + }); + expect(result).not.toHaveProperty('data'); + }); + + describe('actorName enrichment (F5.2)', () => { + it('resolves the actor name once for two events by the same actor (dedupe/cache)', async () => { + const rows = [ + { id: 'evt-1', actorId: 'user-a' }, + { id: 'evt-2', actorId: 'user-a' }, + ]; + const { service, keycloakAdmin } = makeService(rows, 2, { + keycloakAdmin: { + getUser: jest + .fn() + .mockResolvedValue({ firstName: 'Jane', lastName: 'Doe' }), + }, + }); + + const result = await service.findForCaller( + orgId, + caller(['org_admin']), + 1, + 20, + ); + + expect(result.items).toEqual([ + { id: 'evt-1', actorId: 'user-a', actorName: 'Jane Doe' }, + { id: 'evt-2', actorId: 'user-a', actorName: 'Jane Doe' }, + ]); + expect(keycloakAdmin.getUser).toHaveBeenCalledTimes(1); + expect(keycloakAdmin.getUser).toHaveBeenCalledWith('user-a'); + }); + + it('caches a resolved actor name across separate findForCaller calls', async () => { + const rows = [{ id: 'evt-1', actorId: 'user-a' }]; + const { service, keycloakAdmin } = makeService(rows, 1, { + keycloakAdmin: { + getUser: jest.fn().mockResolvedValue({ firstName: 'Jane' }), + }, + }); + + await service.findForCaller(orgId, caller(['org_admin']), 1, 20); + await service.findForCaller(orgId, caller(['org_admin']), 1, 20); + + expect(keycloakAdmin.getUser).toHaveBeenCalledTimes(1); + }); + + it('yields a null actorName for a system-generated event (null actorId)', async () => { + const rows = [{ id: 'evt-1', actorId: null }]; + const { service, keycloakAdmin } = makeService(rows, 1); + + const result = await service.findForCaller( + orgId, + caller(['org_admin']), + 1, + 20, + ); + + expect(result.items).toEqual([ + { id: 'evt-1', actorId: null, actorName: null }, + ]); + expect(keycloakAdmin.getUser).not.toHaveBeenCalled(); + }); + + it('yields a null actorName (never throws) when the Keycloak lookup fails', async () => { + const rows = [{ id: 'evt-1', actorId: 'user-a' }]; + const { service } = makeService(rows, 1, { + keycloakAdmin: { + getUser: jest.fn().mockRejectedValue(new Error('KC unreachable')), + }, + }); + + const result = await service.findForCaller( + orgId, + caller(['org_admin']), + 1, + 20, + ); + + expect(result.items).toEqual([ + { id: 'evt-1', actorId: 'user-a', actorName: null }, + ]); + }); + + it('falls back to email when no first/last name is available', async () => { + const rows = [{ id: 'evt-1', actorId: 'user-a' }]; + const { service } = makeService(rows, 1, { + keycloakAdmin: { + getUser: jest.fn().mockResolvedValue({ email: 'jane@example.com' }), + }, + }); + + const result = await service.findForCaller( + orgId, + caller(['org_admin']), + 1, + 20, + ); + + expect(result.items[0]).toEqual( + expect.objectContaining({ actorName: 'jane@example.com' }), + ); + }); + }); +}); diff --git a/apps/api/src/modules/timeline/timeline.service.ts b/apps/api/src/modules/timeline/timeline.service.ts index e0b3d330..27e0ffc9 100644 --- a/apps/api/src/modules/timeline/timeline.service.ts +++ b/apps/api/src/modules/timeline/timeline.service.ts @@ -3,6 +3,7 @@ import { PrismaService } from '@/infrastructure/prisma/prisma.service'; import { Prisma } from '@repo/db'; import { Role } from '@/common/enums'; import { AuthenticatedUser } from '@/common/types/authenticated-user.type'; +import { KeycloakAdminService } from '@/infrastructure/keycloak/keycloak-admin.service'; export interface EmitEventOptions { orgId: string; @@ -13,11 +14,30 @@ export interface EmitEventOptions { metadata?: Record; } +/** F5.2: how long a resolved actor display name is cached before re-fetching. */ +const ACTOR_NAME_CACHE_TTL_MS = 5 * 60 * 1000; + +interface ActorNameCacheEntry { + name: string | null; + expiresAt: number; +} + @Injectable() export class TimelineService { private readonly logger = new Logger(TimelineService.name); - constructor(private readonly prisma: PrismaService) {} + /** + * F5.2: process-local cache of actorId (Keycloak sub) -> resolved display + * name, so the activity feed doesn't hit Keycloak once per event per + * request. Small TTL rather than forever, so a user's name change (rare) + * eventually shows up without a restart. + */ + private readonly actorNameCache = new Map(); + + constructor( + private readonly prisma: PrismaService, + private readonly keycloakAdmin: KeycloakAdminService, + ) {} async emit(options: EmitEventOptions): Promise { try { @@ -53,17 +73,21 @@ export class TimelineService { // Tenant: only own events where = { orgId, actorId: caller.sub }; } else if (role === Role.FINANCE) { - // Finance: billing/payment/subscription events only + // Finance: invoice/invoice-payment/expense events only (finance's actual + // domain — NOT the platform's own Stripe subscription billing, which + // finance cannot even reach; see billing.controller.ts @Roles). where = { orgId, action: { in: [ - 'subscription.created', - 'subscription.updated', - 'subscription.canceled', - 'payment.paid', - 'payment.failed', - 'checkout.completed', + 'invoice.created', + 'invoice.updated', + 'invoice.deleted', + 'invoice_payment.created', + 'invoice_payment.deleted', + 'expense.created', + 'expense.updated', + 'expense.deleted', ], }, }; @@ -80,6 +104,81 @@ export class TimelineService { this.prisma.event.count({ where }), ]); - return { items, total, page, limit }; + return { + items: await this.enrichWithActorNames(items), + total, + page, + limit, + }; + } + + /** + * F5.2: resolve each event's `actorId` (a Keycloak sub) to a display name + * for the activity feed's "who" column. Deduped per page (one KC lookup per + * distinct actor, not per event) and cached across requests; a null/missing + * actorId (system-generated event) or an unresolvable actor both yield a + * null `actorName` rather than throwing — the feed must always return. + */ + private async enrichWithActorNames< + T extends { actorId?: string | null }, + >(events: T[]): Promise<(T & { actorName: string | null })[]> { + const distinctActorIds = [ + ...new Set( + events + .map((e) => e.actorId) + .filter((id): id is string => Boolean(id)), + ), + ]; + + const nameByActorId = new Map(); + await Promise.all( + distinctActorIds.map(async (actorId) => { + nameByActorId.set(actorId, await this.resolveActorName(actorId)); + }), + ); + + return events.map((e) => ({ + ...e, + actorName: e.actorId ? (nameByActorId.get(e.actorId) ?? null) : null, + })); + } + + /** Resolve (and cache) a single actor's display name by Keycloak sub. */ + private async resolveActorName(sub: string): Promise { + const cached = this.actorNameCache.get(sub); + const now = Date.now(); + if (cached && cached.expiresAt > now) { + return cached.name; + } + + let name: string | null = null; + try { + const user = await this.keycloakAdmin.getUser(sub); + name = this.extractDisplayName(user); + } catch (error) { + // Never let a KC hiccup fail the whole feed — the event still renders, + // just without a resolved actor name. + this.logger.warn(`Failed to resolve actor name for ${sub}: ${String(error)}`); + name = null; + } + + this.actorNameCache.set(sub, { + name, + expiresAt: now + ACTOR_NAME_CACHE_TTL_MS, + }); + return name; + } + + /** firstName + lastName, falling back to email, then null. */ + private extractDisplayName( + user: Record | null, + ): string | null { + if (!user) return null; + const firstName = typeof user.firstName === 'string' ? user.firstName : ''; + const lastName = typeof user.lastName === 'string' ? user.lastName : ''; + const fullName = [firstName, lastName].filter(Boolean).join(' ').trim(); + if (fullName) return fullName; + const email = user.email; + return typeof email === 'string' && email.length > 0 ? email : null; } } diff --git a/apps/api/src/modules/work-orders/dto/create-work-order.dto.ts b/apps/api/src/modules/work-orders/dto/create-work-order.dto.ts index 778203b3..35f735db 100644 --- a/apps/api/src/modules/work-orders/dto/create-work-order.dto.ts +++ b/apps/api/src/modules/work-orders/dto/create-work-order.dto.ts @@ -1,4 +1,11 @@ -import { IsEnum, IsNumber, IsOptional, IsString, Min } from 'class-validator'; +import { + IsBoolean, + IsEnum, + IsNumber, + IsOptional, + IsString, + Min, +} from 'class-validator'; import { ApiPropertyOptional } from '@nestjs/swagger'; import { WorkOrderStatus } from '@repo/db'; @@ -31,4 +38,18 @@ export class CreateWorkOrderDto { @IsOptional() @IsString() resolutionNotes?: string; + + @ApiPropertyOptional({ + description: + 'Opt-in: bill this work order to the tenant on completion. Defaults to false.', + }) + @IsOptional() + @IsBoolean() + chargeToTenant?: boolean; + + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0) + tenantChargeAmount?: number | null; } diff --git a/apps/api/src/modules/work-orders/dto/update-work-order.dto.ts b/apps/api/src/modules/work-orders/dto/update-work-order.dto.ts index b169dd18..5312b024 100644 --- a/apps/api/src/modules/work-orders/dto/update-work-order.dto.ts +++ b/apps/api/src/modules/work-orders/dto/update-work-order.dto.ts @@ -1,4 +1,5 @@ import { + IsBoolean, IsDateString, IsEnum, IsNumber, @@ -40,4 +41,17 @@ export class UpdateWorkOrderDto { @IsOptional() @IsDateString() completedAt?: string | null; + + @ApiPropertyOptional({ + description: 'Opt-in: bill this work order to the tenant on completion.', + }) + @IsOptional() + @IsBoolean() + chargeToTenant?: boolean; + + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0) + tenantChargeAmount?: number | null; } diff --git a/apps/api/src/modules/work-orders/work-order-formatter.ts b/apps/api/src/modules/work-orders/work-order-formatter.ts index 6c36cf12..32bb1b76 100644 --- a/apps/api/src/modules/work-orders/work-order-formatter.ts +++ b/apps/api/src/modules/work-orders/work-order-formatter.ts @@ -1,9 +1,10 @@ import { Prisma } from '@repo/db'; -import { WorkOrderResponse } from '@repo/contracts'; +import { WorkOrderResponse, formatWorkOrderNumber } from '@repo/contracts'; export type WorkOrderRow = { id: string; orgId: string; + number: number; maintenanceRequestId: string; vendorId: string | null; assignedUserId: string | null; @@ -11,6 +12,9 @@ export type WorkOrderRow = { cost: Prisma.Decimal | null; resolutionNotes: string | null; completedAt: Date | null; + chargeToTenant: boolean; + tenantChargeAmount: Prisma.Decimal | null; + tenantChargedAt: Date | null; createdAt: Date; updatedAt: Date; }; @@ -20,6 +24,8 @@ export function formatWorkOrder(workOrder: WorkOrderRow): WorkOrderResponse { return { id: workOrder.id, orgId: workOrder.orgId, + number: workOrder.number, + numberLabel: formatWorkOrderNumber(workOrder.number), maintenanceRequestId: workOrder.maintenanceRequestId, vendorId: workOrder.vendorId, assignedUserId: workOrder.assignedUserId, @@ -27,6 +33,9 @@ export function formatWorkOrder(workOrder: WorkOrderRow): WorkOrderResponse { cost: workOrder.cost?.toString() ?? null, resolutionNotes: workOrder.resolutionNotes, completedAt: workOrder.completedAt?.toISOString() ?? null, + chargeToTenant: workOrder.chargeToTenant, + tenantChargeAmount: workOrder.tenantChargeAmount?.toString() ?? null, + tenantChargedAt: workOrder.tenantChargedAt?.toISOString() ?? null, createdAt: workOrder.createdAt.toISOString(), updatedAt: workOrder.updatedAt.toISOString(), }; diff --git a/apps/api/src/modules/work-orders/work-orders-overview.controller.ts b/apps/api/src/modules/work-orders/work-orders-overview.controller.ts new file mode 100644 index 00000000..3676959a --- /dev/null +++ b/apps/api/src/modules/work-orders/work-orders-overview.controller.ts @@ -0,0 +1,31 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { WorkOrdersService } from './work-orders.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 "my work orders" list (Sprint F2.2) — feeds a maintenance user's + * assigned-to-me view across every maintenance request. Complements (does + * not replace) the nested + * maintenance-requests/:maintenanceRequestId/work-orders controller, which + * stays scoped to a single request. + */ +@ApiTags('work-orders') +@ApiBearerAuth() +@Controller('work-orders') +export class WorkOrdersOverviewController { + constructor( + private readonly workOrdersService: WorkOrdersService, + private readonly orgScope: OrgScopeService, + ) {} + + @Roles(Role.ORG_ADMIN, Role.SUPERVISOR, Role.MAINTENANCE) + @Get('assigned-to-me') + async getAssignedToMe(@CurrentUser() user: AuthenticatedUser) { + const { orgId } = await this.orgScope.resolveForCaller(user); + return this.workOrdersService.findAssignedToCaller(orgId, user.sub); + } +} diff --git a/apps/api/src/modules/work-orders/work-orders.module.ts b/apps/api/src/modules/work-orders/work-orders.module.ts index 89bdbe27..7042403d 100644 --- a/apps/api/src/modules/work-orders/work-orders.module.ts +++ b/apps/api/src/modules/work-orders/work-orders.module.ts @@ -1,10 +1,13 @@ import { Module } from '@nestjs/common'; import { WorkOrdersController } from './work-orders.controller'; +import { WorkOrdersOverviewController } from './work-orders-overview.controller'; import { WorkOrdersService } from './work-orders.service'; import { WorkOrderApartmentStatusService } from './work-order-apartment-status.service'; +import { NotificationsModule } from '@/modules/notifications/notifications.module'; @Module({ - controllers: [WorkOrdersController], + imports: [NotificationsModule], + controllers: [WorkOrdersController, WorkOrdersOverviewController], providers: [WorkOrdersService, WorkOrderApartmentStatusService], exports: [WorkOrdersService], }) diff --git a/apps/api/src/modules/work-orders/work-orders.service.spec.ts b/apps/api/src/modules/work-orders/work-orders.service.spec.ts index 56fe6722..246efa31 100644 --- a/apps/api/src/modules/work-orders/work-orders.service.spec.ts +++ b/apps/api/src/modules/work-orders/work-orders.service.spec.ts @@ -21,8 +21,12 @@ describe('WorkOrdersService', () => { maintenanceRequest?: Partial>; workOrder?: Partial>; expense?: Partial>; + lease?: Partial>; + invoice?: Partial>; buildingAccess?: Partial>; + leaseStatus?: Partial>; workOrderApartmentStatus?: Partial>; + notifications?: Partial>; } = {}, ) { const prisma: any = { @@ -32,12 +36,14 @@ describe('WorkOrdersService', () => { orgId, buildingId, apartmentId, + title: 'Leaking faucet', }), ...overrides.maintenanceRequest, }, workOrder: { findFirst: jest.fn().mockResolvedValue(null), findMany: jest.fn().mockResolvedValue([]), + aggregate: jest.fn().mockResolvedValue({ _max: { number: 0 } }), create: jest.fn(), update: jest.fn(), delete: jest.fn(), @@ -47,12 +53,36 @@ describe('WorkOrdersService', () => { count: jest.fn().mockResolvedValue(0), ...overrides.expense, }, + // F3.2 tenant-charge path: find the apartment's active lease and create + // a new invoice on it. + lease: { + findFirst: jest.fn().mockResolvedValue(null), + ...overrides.lease, + }, + invoice: { + create: jest.fn().mockResolvedValue({ id: 'invoice-1' }), + ...overrides.invoice, + }, + // create() assigns the org-scoped number inside a transaction + advisory + // lock; the tenant-charge path also runs inside a $transaction. Run the + // callback against the same mock and stub the raw lock. + $executeRaw: jest.fn().mockResolvedValue(1), + $transaction: jest.fn(), }; + prisma.$transaction.mockImplementation((cb: any) => cb(prisma)); const buildingAccess = { assertBuildingAccess: jest.fn().mockResolvedValue(undefined), ...overrides.buildingAccess, }; const timeline = { emit: jest.fn().mockResolvedValue(undefined) }; + const notifications = { + enqueue: jest.fn().mockResolvedValue(undefined), + ...overrides.notifications, + }; + const leaseStatus = { + isEffectivelyActive: jest.fn().mockReturnValue(true), + ...overrides.leaseStatus, + }; const workOrderApartmentStatus = { onWorkOrderOpened: jest.fn().mockResolvedValue(undefined), onWorkOrderClosed: jest.fn().mockResolvedValue(undefined), @@ -62,6 +92,8 @@ describe('WorkOrdersService', () => { prisma, buildingAccess as any, timeline as any, + notifications as any, + leaseStatus as any, workOrderApartmentStatus as any, ); return { @@ -69,6 +101,8 @@ describe('WorkOrdersService', () => { prisma, buildingAccess, timeline, + notifications, + leaseStatus, workOrderApartmentStatus, }; } @@ -76,6 +110,7 @@ describe('WorkOrdersService', () => { const workOrderRow = (overrides: Partial> = {}) => ({ id: 'wo-1', orgId, + number: 1, maintenanceRequestId, vendorId: 'vendor-1', assignedUserId: null, @@ -83,6 +118,9 @@ describe('WorkOrdersService', () => { cost: new Prisma.Decimal('150.00'), resolutionNotes: null, completedAt: null, + chargeToTenant: false, + tenantChargeAmount: null, + tenantChargedAt: null, createdAt: new Date('2026-01-01T00:00:00.000Z'), updatedAt: new Date('2026-01-01T00:00:00.000Z'), ...overrides, @@ -241,6 +279,126 @@ describe('WorkOrdersService', () => { }); }); + describe('findAssignedToCaller', () => { + const assignedRow = (overrides: Partial> = {}) => ({ + ...workOrderRow({ assignedUserId: callerId, vendorId: null }), + maintenanceRequest: { + title: 'Leaking faucet', + status: 'open', + buildingId, + apartmentId, + apartment: { + unitNumber: '101', + building: { name: 'Tower A' }, + }, + }, + ...overrides, + }); + + it('queries only work orders assigned to the caller, ordered by createdAt desc', async () => { + const { service, prisma } = makeService({ + workOrder: { findMany: jest.fn().mockResolvedValue([assignedRow()]) }, + }); + + await service.findAssignedToCaller(orgId, callerId); + + expect(prisma.workOrder.findMany).toHaveBeenCalledWith({ + where: { orgId, assignedUserId: callerId }, + include: { + maintenanceRequest: { + select: { + title: true, + status: true, + buildingId: true, + apartmentId: true, + apartment: { + select: { + unitNumber: true, + building: { select: { name: true } }, + }, + }, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + }); + + it('enriches each row with request/apartment/building context and the numberLabel', async () => { + const { service } = makeService({ + workOrder: { + findMany: jest.fn().mockResolvedValue([assignedRow({ number: 123 })]), + }, + }); + + const result = await service.findAssignedToCaller(orgId, callerId); + + expect(result.data).toEqual([ + expect.objectContaining({ + id: 'wo-1', + number: 123, + numberLabel: 'WO-000123', + requestTitle: 'Leaking faucet', + requestStatus: 'open', + buildingId, + buildingName: 'Tower A', + apartmentId, + apartmentUnit: '101', + }), + ]); + }); + + it('returns an empty list when the caller has no assigned work orders', async () => { + const { service, prisma } = makeService({ + workOrder: { findMany: jest.fn().mockResolvedValue([]) }, + }); + + const result = await service.findAssignedToCaller(orgId, 'other-caller'); + + expect(prisma.workOrder.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { orgId, assignedUserId: 'other-caller' }, + }), + ); + expect(result.data).toEqual([]); + }); + + it('surfaces active (scheduled/in_progress) work orders before completed/canceled ones', async () => { + const completed = assignedRow({ + id: 'wo-completed', + status: 'completed', + createdAt: new Date('2026-01-03T00:00:00.000Z'), + }); + const scheduled = assignedRow({ + id: 'wo-scheduled', + status: 'scheduled', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + }); + const inProgress = assignedRow({ + id: 'wo-in-progress', + status: 'in_progress', + createdAt: new Date('2026-01-02T00:00:00.000Z'), + }); + const { service } = makeService({ + // Simulate the query's createdAt-desc ordering: completed (newest) + // first, then in_progress, then scheduled (oldest). + workOrder: { + findMany: jest + .fn() + .mockResolvedValue([completed, inProgress, scheduled]), + }, + }); + + const result = await service.findAssignedToCaller(orgId, callerId); + + expect(result.data.map((w) => w.id)).toEqual([ + 'wo-in-progress', + 'wo-scheduled', + 'wo-completed', + ]); + }); + }); + describe('create', () => { it('creates a work order with a vendor assigned, opens the apartment, and emits work_order.created', async () => { const { service, prisma, timeline, workOrderApartmentStatus } = @@ -262,6 +420,7 @@ describe('WorkOrdersService', () => { expect(prisma.workOrder.create).toHaveBeenCalledWith({ data: { orgId, + number: 1, maintenanceRequestId, vendorId: 'vendor-1', assignedUserId: undefined, @@ -270,6 +429,10 @@ describe('WorkOrdersService', () => { resolutionNotes: undefined, }, }); + expect(prisma.workOrder.aggregate).toHaveBeenCalledWith({ + where: { orgId }, + _max: { number: true }, + }); expect(workOrderApartmentStatus.onWorkOrderOpened).toHaveBeenCalledWith( apartmentId, ); @@ -295,6 +458,36 @@ describe('WorkOrdersService', () => { expect(result.data.assignedUserId).toBe('user-1'); }); + it('assigns the next org-scoped number (max + 1) and exposes a WO-000123 label', async () => { + const { service, prisma } = makeService({ + workOrder: { + aggregate: jest.fn().mockResolvedValue({ _max: { number: 41 } }), + create: jest + .fn() + .mockResolvedValue( + workOrderRow({ number: 42, vendorId: 'vendor-1' }), + ), + }, + }); + + const result = await service.create( + orgId, + actorId, + Role.ORG_ADMIN, + maintenanceRequestId, + { vendorId: 'vendor-1' }, + ); + + expect(prisma.$executeRaw).toHaveBeenCalled(); // advisory lock taken + expect(prisma.workOrder.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ number: 42 }), + }), + ); + expect(result.data.number).toBe(42); + expect(result.data.numberLabel).toBe('WO-000042'); + }); + it('rejects with BadRequestException when both vendorId and assignedUserId are set', async () => { const { service } = makeService(); @@ -343,6 +536,173 @@ describe('WorkOrdersService', () => { }); }); + describe('create — tenant charge on completion (F3.2)', () => { + const activeLeaseRow = { + id: 'lease-1', + buildingId, + status: 'active', + endDate: new Date('2030-01-01T00:00:00.000Z'), + }; + + it("charges the apartment's active lease when created directly as completed with chargeToTenant", async () => { + const { service, prisma, timeline } = makeService({ + lease: { findFirst: jest.fn().mockResolvedValue(activeLeaseRow) }, + }); + prisma.workOrder.create.mockResolvedValue( + workOrderRow({ + status: 'completed', + chargeToTenant: true, + tenantChargeAmount: new Prisma.Decimal('75.00'), + }), + ); + + await service.create( + orgId, + actorId, + Role.ORG_ADMIN, + maintenanceRequestId, + { + vendorId: 'vendor-1', + status: 'completed', + chargeToTenant: true, + tenantChargeAmount: 75, + }, + ); + + expect(prisma.lease.findFirst).toHaveBeenCalledWith({ + where: { orgId, apartmentId, status: 'active' }, + orderBy: { startDate: 'desc' }, + }); + expect(prisma.invoice.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + orgId, + buildingId, + leaseId: 'lease-1', + lineItems: { + create: [ + expect.objectContaining({ + category: 'other', + description: 'Work order WO-000001: Leaking faucet', + }), + ], + }, + }), + }); + expect(prisma.workOrder.update).toHaveBeenCalledWith({ + where: { id: 'wo-1' }, + data: { tenantChargedAt: expect.any(Date) }, + }); + expect(timeline.emit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'work_order.tenant_charged', + targetId: 'wo-1', + }), + ); + }); + + it('does not charge and does not throw when the apartment has no active lease', async () => { + const { service, prisma } = makeService({ + lease: { findFirst: jest.fn().mockResolvedValue(null) }, + }); + prisma.workOrder.create.mockResolvedValue( + workOrderRow({ + status: 'completed', + chargeToTenant: true, + tenantChargeAmount: new Prisma.Decimal('75.00'), + }), + ); + + await expect( + service.create(orgId, actorId, Role.ORG_ADMIN, maintenanceRequestId, { + vendorId: 'vendor-1', + status: 'completed', + chargeToTenant: true, + tenantChargeAmount: 75, + }), + ).resolves.toBeDefined(); + + expect(prisma.invoice.create).not.toHaveBeenCalled(); + expect(prisma.workOrder.update).not.toHaveBeenCalled(); + }); + + it('does not charge when chargeToTenant is false even though tenantChargeAmount is set', async () => { + const { service, prisma } = makeService(); + prisma.workOrder.create.mockResolvedValue( + workOrderRow({ + status: 'completed', + chargeToTenant: false, + tenantChargeAmount: new Prisma.Decimal('75.00'), + }), + ); + + await service.create( + orgId, + actorId, + Role.ORG_ADMIN, + maintenanceRequestId, + { + vendorId: 'vendor-1', + status: 'completed', + }, + ); + + expect(prisma.lease.findFirst).not.toHaveBeenCalled(); + expect(prisma.invoice.create).not.toHaveBeenCalled(); + }); + }); + + describe('create — assignment notification (F5.1)', () => { + it('notifies the assignee when a work order is created with assignedUserId set', async () => { + const { service, prisma, notifications } = makeService(); + prisma.workOrder.create.mockResolvedValue( + workOrderRow({ vendorId: null, assignedUserId: 'user-1', number: 7 }), + ); + + await service.create( + orgId, + actorId, + Role.ORG_ADMIN, + maintenanceRequestId, + { assignedUserId: 'user-1' }, + ); + + expect(notifications.enqueue).toHaveBeenCalledTimes(1); + expect(notifications.enqueue).toHaveBeenCalledWith( + expect.objectContaining({ + orgId, + userId: 'user-1', + type: 'work_order.assigned', + }), + ); + }); + + it('does not notify when the work order is created with a vendor instead of an assignee', async () => { + const { service, prisma, notifications } = makeService(); + prisma.workOrder.create.mockResolvedValue( + workOrderRow({ vendorId: 'vendor-1', assignedUserId: null }), + ); + + await service.create(orgId, actorId, Role.ORG_ADMIN, maintenanceRequestId, { + vendorId: 'vendor-1', + }); + + expect(notifications.enqueue).not.toHaveBeenCalled(); + }); + + it('does not notify when the actor assigns the work order to themself', async () => { + const { service, prisma, notifications } = makeService(); + prisma.workOrder.create.mockResolvedValue( + workOrderRow({ vendorId: null, assignedUserId: actorId }), + ); + + await service.create(orgId, actorId, Role.ORG_ADMIN, maintenanceRequestId, { + assignedUserId: actorId, + }); + + expect(notifications.enqueue).not.toHaveBeenCalled(); + }); + }); + describe('update', () => { const existingRow = () => ({ ...workOrderRow(), @@ -543,6 +903,192 @@ describe('WorkOrdersService', () => { }); }); + describe('update — reassignment notification (F5.1)', () => { + const existingRow = () => ({ + ...workOrderRow({ assignedUserId: 'user-old' }), + maintenanceRequest: { apartmentId, title: 'Leaking faucet' }, + }); + + it('notifies the newly-assigned user on reassignment', async () => { + const { service, prisma, notifications } = makeService({ + workOrder: { findFirst: jest.fn().mockResolvedValue(existingRow()) }, + }); + prisma.workOrder.update.mockResolvedValue( + workOrderRow({ vendorId: null, assignedUserId: 'user-new' }), + ); + + await service.update( + orgId, + actorId, + callerId, + Role.ORG_ADMIN, + maintenanceRequestId, + 'wo-1', + { assignedUserId: 'user-new' }, + ); + + expect(notifications.enqueue).toHaveBeenCalledTimes(1); + expect(notifications.enqueue).toHaveBeenCalledWith( + expect.objectContaining({ + orgId, + userId: 'user-new', + type: 'work_order.assigned', + }), + ); + }); + + it('does not notify when the assignee is unchanged', async () => { + const { service, prisma, notifications } = makeService({ + workOrder: { findFirst: jest.fn().mockResolvedValue(existingRow()) }, + }); + prisma.workOrder.update.mockResolvedValue( + workOrderRow({ assignedUserId: 'user-old', status: 'in_progress' }), + ); + + await service.update( + orgId, + actorId, + callerId, + Role.ORG_ADMIN, + maintenanceRequestId, + 'wo-1', + { status: 'in_progress' }, + ); + + expect(notifications.enqueue).not.toHaveBeenCalled(); + }); + + it('does not notify when the actor reassigns the work order to themself', async () => { + const { service, prisma, notifications } = makeService({ + workOrder: { findFirst: jest.fn().mockResolvedValue(existingRow()) }, + }); + prisma.workOrder.update.mockResolvedValue( + workOrderRow({ vendorId: null, assignedUserId: actorId }), + ); + + await service.update( + orgId, + actorId, + callerId, + Role.ORG_ADMIN, + maintenanceRequestId, + 'wo-1', + { assignedUserId: actorId }, + ); + + expect(notifications.enqueue).not.toHaveBeenCalled(); + }); + }); + + describe('update — tenant charge transition (F3.2)', () => { + const existingInProgressWithCharge = () => ({ + ...workOrderRow({ + status: 'in_progress', + chargeToTenant: true, + tenantChargeAmount: new Prisma.Decimal('120.00'), + }), + maintenanceRequest: { apartmentId, title: 'Leaking faucet' }, + }); + + const activeLeaseRow = { + id: 'lease-1', + buildingId, + status: 'active', + endDate: new Date('2030-01-01T00:00:00.000Z'), + }; + + it('charges the active lease when a chargeToTenant work order transitions to completed', async () => { + const { service, prisma, timeline } = makeService({ + workOrder: { + findFirst: jest + .fn() + .mockResolvedValue(existingInProgressWithCharge()), + update: jest.fn().mockResolvedValue( + workOrderRow({ + status: 'completed', + chargeToTenant: true, + tenantChargeAmount: new Prisma.Decimal('120.00'), + }), + ), + }, + lease: { findFirst: jest.fn().mockResolvedValue(activeLeaseRow) }, + }); + + await service.update( + orgId, + actorId, + callerId, + Role.ORG_ADMIN, + maintenanceRequestId, + 'wo-1', + { status: 'completed' }, + ); + + expect(prisma.lease.findFirst).toHaveBeenCalledWith({ + where: { orgId, apartmentId, status: 'active' }, + orderBy: { startDate: 'desc' }, + }); + expect(prisma.invoice.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + leaseId: 'lease-1', + buildingId, + lineItems: { + create: [ + expect.objectContaining({ + category: 'other', + description: 'Work order WO-000001: Leaking faucet', + }), + ], + }, + }), + }), + ); + // First call is the main status update; the second is the tenantChargedAt + // stamp inside chargeTenantIfDue's transaction. + expect(prisma.workOrder.update).toHaveBeenNthCalledWith(2, { + where: { id: 'wo-1' }, + data: { tenantChargedAt: expect.any(Date) }, + }); + expect(timeline.emit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'work_order.tenant_charged' }), + ); + }); + + it('does not double-charge when re-completing an already-charged work order', async () => { + const { service, prisma } = makeService({ + workOrder: { + findFirst: jest + .fn() + .mockResolvedValue(existingInProgressWithCharge()), + update: jest.fn().mockResolvedValue( + workOrderRow({ + status: 'completed', + chargeToTenant: true, + tenantChargeAmount: new Prisma.Decimal('120.00'), + tenantChargedAt: new Date('2026-01-05T00:00:00.000Z'), + }), + ), + }, + }); + + await service.update( + orgId, + actorId, + callerId, + Role.ORG_ADMIN, + maintenanceRequestId, + 'wo-1', + { status: 'completed' }, + ); + + expect(prisma.lease.findFirst).not.toHaveBeenCalled(); + expect(prisma.invoice.create).not.toHaveBeenCalled(); + // Only the main status update — no second (tenantChargedAt-stamp) call. + expect(prisma.workOrder.update).toHaveBeenCalledTimes(1); + }); + }); + describe('remove', () => { it('deletes the work order, reverts the apartment via onWorkOrderClosed, and emits work_order.deleted', async () => { const { service, prisma, timeline, workOrderApartmentStatus } = diff --git a/apps/api/src/modules/work-orders/work-orders.service.ts b/apps/api/src/modules/work-orders/work-orders.service.ts index 1e8b1e17..c1b4642a 100644 --- a/apps/api/src/modules/work-orders/work-orders.service.ts +++ b/apps/api/src/modules/work-orders/work-orders.service.ts @@ -3,30 +3,141 @@ import { ConflictException, ForbiddenException, Injectable, + Logger, 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 { NotificationsService } from '@/modules/notifications/notifications.service'; +import { LeaseStatusService } from '@/common/lease-status/lease-status.service'; import { WorkOrderApartmentStatusService } from './work-order-apartment-status.service'; import { Role } from '@/common/enums'; -import { WorkOrderResponse } from '@repo/contracts'; +import { + AssignedWorkOrderListResponse, + MaintenanceRequestStatus, + WorkOrderResponse, + formatWorkOrderNumber, +} from '@repo/contracts'; import { formatWorkOrder } from './work-order-formatter'; import { CreateWorkOrderDto } from './dto/create-work-order.dto'; import { UpdateWorkOrderDto } from './dto/update-work-order.dto'; const OPEN_STATUSES = new Set(['scheduled', 'in_progress']); const MAINTENANCE_ALLOWED_FIELDS = new Set(['status', 'resolutionNotes']); +/** How long a tenant has to pay a work-order charge (F3.2, decision D2). */ +const TENANT_CHARGE_DUE_DAYS = 30; +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** Just what {@link WorkOrdersService.chargeTenantIfDue} needs from a freshly created/updated row. */ +type ChargeableWorkOrder = { + id: string; + number: number; + chargeToTenant: boolean; + tenantChargeAmount: Prisma.Decimal | null; + tenantChargedAt: Date | null; +}; @Injectable() export class WorkOrdersService { + private readonly logger = new Logger(WorkOrdersService.name); + constructor( private readonly prisma: PrismaService, private readonly buildingAccess: BuildingAccessService, private readonly timeline: TimelineService, + private readonly notifications: NotificationsService, + private readonly leaseStatus: LeaseStatusService, private readonly workOrderApartmentStatus: WorkOrderApartmentStatusService, ) {} + /** + * F3.2 (decision D2, opt-in/default OFF): when a work order is or becomes + * `completed` and is flagged `chargeToTenant` with a `tenantChargeAmount`, + * bill the apartment's currently active lease via a new one-line-item + * invoice, due in 30 days. Guarded by `tenantChargedAt` so re-completing an + * already-charged work order is a no-op. Never throws — a missing active + * lease is logged and skipped rather than blocking the status transition. + */ + private async chargeTenantIfDue( + orgId: string, + actorId: string, + apartmentId: string, + requestTitle: string, + workOrder: ChargeableWorkOrder, + ): Promise { + if ( + !workOrder.chargeToTenant || + workOrder.tenantChargeAmount === null || + workOrder.tenantChargedAt !== null + ) { + return; + } + + const now = new Date(); + const activeLease = await this.prisma.lease.findFirst({ + where: { orgId, apartmentId, status: 'active' }, + orderBy: { startDate: 'desc' }, + }); + + if ( + !activeLease || + !this.leaseStatus.isEffectivelyActive( + { status: activeLease.status, endDate: activeLease.endDate }, + now, + ) + ) { + this.logger.warn( + `Work order ${workOrder.id} is chargeToTenant but apartment ${apartmentId} has no active lease — skipping tenant charge.`, + ); + return; + } + + const dueDate = new Date( + now.getTime() + TENANT_CHARGE_DUE_DAYS * MS_PER_DAY, + ); + const numberLabel = formatWorkOrderNumber(workOrder.number); + + const invoice = await this.prisma.$transaction(async (tx) => { + const created = await tx.invoice.create({ + data: { + orgId, + buildingId: activeLease.buildingId, + leaseId: activeLease.id, + dueDate, + lineItems: { + create: [ + { + category: 'other', + description: `Work order ${numberLabel}: ${requestTitle}`, + amount: workOrder.tenantChargeAmount, + }, + ], + }, + }, + }); + await tx.workOrder.update({ + where: { id: workOrder.id }, + data: { tenantChargedAt: now }, + }); + return created; + }); + + await this.timeline.emit({ + orgId, + actorId, + action: 'work_order.tenant_charged', + targetType: 'WorkOrder', + targetId: workOrder.id, + metadata: { + invoiceId: invoice.id, + leaseId: activeLease.id, + amount: workOrder.tenantChargeAmount.toString(), + }, + }); + } + async findAllForRequest( orgId: string, callerId: string, @@ -81,6 +192,58 @@ export class WorkOrdersService { return { data: formatWorkOrder(workOrder) }; } + /** + * All work orders assigned to the caller across every maintenance request + * in the org (Sprint F2.2 "My work orders" data source). Scoped by + * assignedUserId = callerId, so no separate building-access check is + * needed — a maintenance user can only ever see their own assignments. + */ + async findAssignedToCaller( + orgId: string, + callerId: string, + ): Promise { + const workOrders = await this.prisma.workOrder.findMany({ + where: { orgId, assignedUserId: callerId }, + include: { + maintenanceRequest: { + select: { + title: true, + status: true, + buildingId: true, + apartmentId: true, + apartment: { + select: { + unitNumber: true, + building: { select: { name: true } }, + }, + }, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + // Active (scheduled/in_progress) work orders surface first; within each + // group the query's createdAt-desc order is preserved because + // Array#sort is a stable sort. + const activeRank = (status: string) => (OPEN_STATUSES.has(status) ? 0 : 1); + const sorted = [...workOrders].sort( + (a, b) => activeRank(a.status) - activeRank(b.status), + ); + + return { + data: sorted.map((w) => ({ + ...formatWorkOrder(w), + requestTitle: w.maintenanceRequest.title, + requestStatus: w.maintenanceRequest.status as MaintenanceRequestStatus, + buildingId: w.maintenanceRequest.buildingId, + buildingName: w.maintenanceRequest.apartment.building.name, + apartmentId: w.maintenanceRequest.apartmentId, + apartmentUnit: w.maintenanceRequest.apartment.unitNumber, + })), + }; + } + // ── CRUD (write) ────────────────────────────────────────────────────────── async create( @@ -98,7 +261,7 @@ export class WorkOrdersService { const request = await this.prisma.maintenanceRequest.findFirst({ where: { id: maintenanceRequestId, orgId }, - select: { apartmentId: true }, + select: { apartmentId: true, title: true }, }); if (!request) { throw new NotFoundException('Maintenance request not found.'); @@ -110,16 +273,30 @@ export class WorkOrdersService { ); } - const workOrder = await this.prisma.workOrder.create({ - data: { - orgId, - maintenanceRequestId, - vendorId: dto.vendorId, - assignedUserId: dto.assignedUserId, - status: dto.status, - cost: dto.cost, - resolutionNotes: dto.resolutionNotes, - }, + // Assign the next org-scoped sequential number under a per-org advisory lock + // so concurrent creates cannot collide on a number. The @@unique([orgId, + // number]) index is the backstop if two writers ever race the lock. + const workOrder = await this.prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${`work_order_number:${orgId}`}))`; + const { _max } = await tx.workOrder.aggregate({ + where: { orgId }, + _max: { number: true }, + }); + const nextNumber = (_max.number ?? 0) + 1; + return tx.workOrder.create({ + data: { + orgId, + number: nextNumber, + maintenanceRequestId, + vendorId: dto.vendorId, + assignedUserId: dto.assignedUserId, + status: dto.status, + cost: dto.cost, + resolutionNotes: dto.resolutionNotes, + chargeToTenant: dto.chargeToTenant, + tenantChargeAmount: dto.tenantChargeAmount, + }, + }); }); await this.workOrderApartmentStatus.onWorkOrderOpened(request.apartmentId); @@ -137,6 +314,36 @@ export class WorkOrdersService { }, }); + // F5.1: notify the assignee — skip silently if there is none (vendor- + // assigned work orders have no in-app user to notify) or if the actor + // assigned it to themself. + if (workOrder.assignedUserId && workOrder.assignedUserId !== actorId) { + await this.notifications.enqueue({ + orgId, + userId: workOrder.assignedUserId, + type: 'work_order.assigned', + title: 'You were assigned a work order', + body: `You've been assigned to work order ${formatWorkOrderNumber(workOrder.number)}: ${request.title}`, + data: { + workOrderId: workOrder.id, + maintenanceRequestId, + numberLabel: formatWorkOrderNumber(workOrder.number), + }, + }); + } + // TODO F5.1 follow-up: role-fanout notifications (e.g. maintenance-created + // -> supervisors) need a shared org-members-by-role helper — out of scope here. + + if (workOrder.status === 'completed') { + await this.chargeTenantIfDue( + orgId, + actorId, + request.apartmentId, + request.title, + workOrder, + ); + } + return { data: formatWorkOrder(workOrder) }; } @@ -151,7 +358,9 @@ export class WorkOrdersService { ): Promise<{ data: WorkOrderResponse }> { const existing = await this.prisma.workOrder.findFirst({ where: { id: workOrderId, orgId, maintenanceRequestId }, - include: { maintenanceRequest: { select: { apartmentId: true } } }, + include: { + maintenanceRequest: { select: { apartmentId: true, title: true } }, + }, }); if (!existing) { throw new NotFoundException('Work order not found.'); @@ -219,6 +428,19 @@ export class WorkOrdersService { } const reassigning = vendorProvided || assigneeProvided; + const completingNow = + dto.status === 'completed' && existing.status !== 'completed'; + + // F5.1: only notify when this update is a genuine reassignment TO a new + // user (not just re-affirming the existing assignee), and never notify + // the actor about assigning it to themself. + const newAssignee = + assigneeProvided && + assignedUserId && + assignedUserId !== existing.assignedUserId && + assignedUserId !== actorId + ? assignedUserId + : null; const workOrder = await this.prisma.workOrder.update({ where: { id: workOrderId }, @@ -230,6 +452,12 @@ export class WorkOrdersService { resolutionNotes: dto.resolutionNotes, }), ...(completedAt !== undefined && { completedAt }), + ...(dto.chargeToTenant !== undefined && { + chargeToTenant: dto.chargeToTenant, + }), + ...(dto.tenantChargeAmount !== undefined && { + tenantChargeAmount: dto.tenantChargeAmount, + }), }, }); @@ -252,6 +480,31 @@ export class WorkOrdersService { metadata: { changes: Object.keys(dto) }, }); + if (newAssignee) { + await this.notifications.enqueue({ + orgId, + userId: newAssignee, + type: 'work_order.assigned', + title: 'You were assigned a work order', + body: `You've been assigned to work order ${formatWorkOrderNumber(workOrder.number)}: ${existing.maintenanceRequest.title}`, + data: { + workOrderId: workOrder.id, + maintenanceRequestId, + numberLabel: formatWorkOrderNumber(workOrder.number), + }, + }); + } + + if (completingNow) { + await this.chargeTenantIfDue( + orgId, + actorId, + existing.maintenanceRequest.apartmentId, + existing.maintenanceRequest.title, + workOrder, + ); + } + return { data: formatWorkOrder(workOrder) }; } diff --git a/apps/web/src/app/[lang]/dashboard/billing/page.tsx b/apps/web/src/app/[lang]/dashboard/billing/page.tsx index bc2d1d83..41a8db17 100644 --- a/apps/web/src/app/[lang]/dashboard/billing/page.tsx +++ b/apps/web/src/app/[lang]/dashboard/billing/page.tsx @@ -1,5 +1,6 @@ import { requireSession } from '@/auth/guards'; 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'; @@ -14,7 +15,7 @@ export default async function DashboardBillingPage({ const locale = isLocale(lang) ? lang : 'en'; const session = await requireSession({ locale }); const role = normalizeRole(session.role ?? session.user?.role); - if (role !== 'org_admin') redirect(`/${locale}/dashboard`); + if (!canAccess(role, 'billing')) redirect(`/${locale}/dashboard`); const dict = await getDictionary(locale); return ; } diff --git a/apps/web/src/app/[lang]/dashboard/layout.tsx b/apps/web/src/app/[lang]/dashboard/layout.tsx index 91067d63..968cefbf 100644 --- a/apps/web/src/app/[lang]/dashboard/layout.tsx +++ b/apps/web/src/app/[lang]/dashboard/layout.tsx @@ -1,4 +1,6 @@ +import { redirect } from 'next/navigation'; import { requireSession, requireActiveOrg } from '@/auth/guards'; +import { normalizeRole } from '@/auth/roles'; import { getDictionary } from '@/i18n/get-dictionary'; import { isLocale } from '@/i18n/config'; import { serverEnv } from '@/lib/env'; @@ -32,6 +34,11 @@ export default async function DashboardLayout({ const locale = isLocale(lang) ? lang : 'en'; const session = await requireSession({ locale }); + // Tenants belong to the resident portal, never the admin dashboard shell. + // Authoritative in-RSC counterpart to the edge redirect in proxy.ts. + const sessionRole = normalizeRole(session.role ?? session.user?.role); + if (sessionRole === 'tenant') redirect(`/${locale}/portal`); + // Authoritative paywall for the WHOLE dashboard subtree (incl. every // sub-page). "Has paid?" is org.status === ACTIVE in the DB — token- // independent, so it correctly bounces a lapsed (PAST_DUE/CANCELED) or @@ -49,7 +56,11 @@ export default async function DashboardLayout({ const userName = session.user?.name ?? session.user?.email ?? ''; return ( -
+ // h-screen + overflow-hidden turns this into a fixed app-shell: the sidebar + // stays viewport-height and the main column scrolls on its own, so a long + // page (e.g. the Activity feed) can never push the sidebar's Sign-out + // control below the fold. See dashboard-sidebar for the internal nav scroll. +
-
{children}
+
{children}
); diff --git a/apps/web/src/app/[lang]/dashboard/page.tsx b/apps/web/src/app/[lang]/dashboard/page.tsx index a328262f..1eb6f9bd 100644 --- a/apps/web/src/app/[lang]/dashboard/page.tsx +++ b/apps/web/src/app/[lang]/dashboard/page.tsx @@ -1,3 +1,4 @@ +import { redirect } from 'next/navigation'; import { requireSession, requireActiveOrg } from '@/auth/guards'; import { normalizeRole } from '@/auth/roles'; import { isLocale } from '@/i18n/config'; @@ -7,7 +8,6 @@ import { SessionRefresher } from '@/components/auth/session-refresher'; import { OrgAdminDashboard } from '@/components/dashboard/org-admin-dashboard'; import { FinanceDashboard } from '@/components/dashboard/finance-dashboard'; import { StaffDashboard } from '@/components/dashboard/staff-dashboard'; -import { TenantDashboard } from '@/components/dashboard/tenant-dashboard'; import { DefaultDashboard } from '@/components/dashboard/default-dashboard'; import type { MeResponse } from '@/types/api'; @@ -75,8 +75,11 @@ export default async function DashboardPage({ if (role === 'supervisor' || role === 'maintenance') { return ; } + // Tenants belong to the resident portal, never the admin dashboard — the + // dashboard layout + proxy already redirect them. This is the last-resort + // guard so a tenant can never render an admin dashboard body. if (role === 'tenant') { - return ; + redirect(`/${locale}/portal`); } return ( diff --git a/apps/web/src/app/[lang]/dashboard/profile/page.tsx b/apps/web/src/app/[lang]/dashboard/profile/page.tsx new file mode 100644 index 00000000..d1d5224d --- /dev/null +++ b/apps/web/src/app/[lang]/dashboard/profile/page.tsx @@ -0,0 +1,111 @@ +import { UserIcon, ExternalLinkIcon } from 'lucide-react'; + +import { requireSession } 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 { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import type { MeResponse } from '@/types/api'; + +async function fetchMe(accessToken: string): Promise { + try { + const res = await fetch(`${serverEnv.API_URL}/me`, { + headers: { Authorization: `Bearer ${accessToken}` }, + cache: 'no-store', + }); + if (!res.ok) return null; + const json = (await res.json()) as MeResponse | { data: MeResponse }; + return 'data' in json ? json.data : json; + } catch { + return null; + } +} + +export default async function ProfilePage({ + params, +}: { + params: Promise<{ lang: string }>; +}) { + const { lang } = await params; + const locale = isLocale(lang) ? lang : 'en'; + const dict = await getDictionary(locale); + const t = dict.profile; + + // Any authenticated role may view their own profile — no role gate here. + // proxy.ts intentionally excludes /dashboard/profile from its permission + // matrix, so this requireSession() call is the sole guard for this page. + const session = await requireSession({ locale }); + + let me: MeResponse | null = null; + if (session.accessToken) { + me = await fetchMe(session.accessToken); + } + + const name = me?.user?.fullName ?? session.user?.name ?? '—'; + const email = me?.user?.email ?? session.user?.email ?? '—'; + const role = normalizeRole(me?.role ?? session.role ?? session.user?.role); + const roleLabel = role ? dict.auth.roles[role] : '—'; + const orgName = me?.org?.name ?? '—'; + const orgStatus = me?.org?.status; + const orgStatusLabel = orgStatus ? dict.billing.status[orgStatus] : null; + + const accountUrl = `${serverEnv.KEYCLOAK_BASE}/realms/${serverEnv.KEYCLOAK_REALM}/account`; + + return ( +
+
+

+ + {t.title} +

+

{t.subtitle}

+
+ +
+
+

{t.name}

+

{name}

+
+
+

{t.email}

+

{email}

+
+
+

{t.role}

+

{roleLabel}

+
+
+

{t.organization}

+
+

{orgName}

+ {orgStatusLabel && ( + + {orgStatusLabel} + + )} +
+
+
+ +
+
+

+ {t.manageAccount} +

+

+ {t.manageAccountHint} +

+
+ +
+
+ ); +} 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 ed05d997..2d1967b5 100644 --- a/apps/web/src/app/[lang]/dashboard/tasks/[id]/page.tsx +++ b/apps/web/src/app/[lang]/dashboard/tasks/[id]/page.tsx @@ -6,6 +6,28 @@ import { isLocale } from '@/i18n/config'; import { getDictionary } from '@/i18n/get-dictionary'; import { MaintenanceRequestDetailPage } from '@/components/dashboard/maintenance-request-detail-page'; +/** + * The NextAuth session object does not expose the Keycloak `sub` (the JWT + * strategy's session callback only surfaces name/email/image by default, and + * this app's own session callback — auth/auth.ts — doesn't add it either). + * `session.accessToken` is the raw Keycloak-issued JWT though, and its `sub` + * claim is exactly the id `WorkOrder.assignedUserId` is compared against + * (per repo convention: users are referenced by their Keycloak `sub`). Decode + * it here rather than widening the shared session type for one call site. + */ +function decodeAccessTokenSub(token: string | undefined): string | undefined { + if (!token) return undefined; + try { + const payload = token.split('.')[1]; + if (!payload) return undefined; + const decoded = Buffer.from(payload, 'base64url').toString('utf8'); + const parsed = JSON.parse(decoded) as { sub?: string }; + return parsed.sub; + } catch { + return undefined; + } +} + export default async function MaintenanceRequestDetailPageRoute({ params, }: { @@ -26,12 +48,15 @@ export default async function MaintenanceRequestDetailPageRoute({ // only update status/resolutionNotes on a Work Order assigned to them // (enforced in WorkOrdersService) — neither is the plain 'tasks' // canWrite() value, which is 'full' for maintenance at the page level. + const callerSub = decodeAccessTokenSub(session.accessToken); + return ( ); diff --git a/apps/web/src/app/[lang]/dashboard/timeline/page.tsx b/apps/web/src/app/[lang]/dashboard/timeline/page.tsx index 7a5b8730..ab89c0a0 100644 --- a/apps/web/src/app/[lang]/dashboard/timeline/page.tsx +++ b/apps/web/src/app/[lang]/dashboard/timeline/page.tsx @@ -4,6 +4,7 @@ import { requireSession } from '@/auth/guards'; import { normalizeRole } from '@/auth/roles'; import { canAccess } from '@/auth/permissions'; import { isLocale } from '@/i18n/config'; +import { getDictionary } from '@/i18n/get-dictionary'; import { TimelineFeed } from '@/components/dashboard/timeline-feed'; export default async function TimelinePageRoute({ @@ -22,10 +23,12 @@ export default async function TimelinePageRoute({ redirect(`/${locale}/dashboard`); } + const dict = await getDictionary(locale); + return (
-

Activity

- +

{dict.nav.timeline}

+
); } diff --git a/apps/web/src/app/[lang]/portal/available-units/page.tsx b/apps/web/src/app/[lang]/portal/available-units/page.tsx new file mode 100644 index 00000000..d25a6dbe --- /dev/null +++ b/apps/web/src/app/[lang]/portal/available-units/page.tsx @@ -0,0 +1,15 @@ +import { isLocale } from '@/i18n/config'; +import { getDictionary } from '@/i18n/get-dictionary'; +import { PortalAvailableUnits } from '@/components/portal/portal-available-units'; + +export default async function PortalAvailableUnitsPage({ + params, +}: { + params: Promise<{ lang: string }>; +}) { + const { lang } = await params; + const locale = isLocale(lang) ? lang : 'en'; + const dict = await getDictionary(locale); + + return ; +} diff --git a/apps/web/src/app/[lang]/portal/layout.tsx b/apps/web/src/app/[lang]/portal/layout.tsx new file mode 100644 index 00000000..d72c20e9 --- /dev/null +++ b/apps/web/src/app/[lang]/portal/layout.tsx @@ -0,0 +1,41 @@ +import { redirect } from 'next/navigation'; +import type { ReactNode } from 'react'; + +import { requireSession } from '@/auth/guards'; +import { dashboardPathForRole, normalizeRole } from '@/auth/roles'; +import { isLocale } from '@/i18n/config'; +import { getDictionary } from '@/i18n/get-dictionary'; +import { PortalShell } from '@/components/portal/portal-shell'; + +/** + * Tenant portal shell (TP2). A visually distinct, light/warm, mobile-first + * resident experience — NOT the admin dashboard chrome. Only the `tenant` + * role belongs here; every other role is bounced back to their dashboard. + * Routing-isolation (roles/permissions/middleware) is owned by the + * orchestrator, so this guard is intentionally self-contained here. + */ +export default async function PortalLayout({ + children, + params, +}: { + children: ReactNode; + 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 (role !== 'tenant') { + redirect(dashboardPathForRole(role ?? 'org_admin', locale)); + } + + const dict = await getDictionary(locale); + const userName = session.user?.name ?? session.user?.email ?? ''; + + return ( + + {children} + + ); +} diff --git a/apps/web/src/app/[lang]/portal/page.tsx b/apps/web/src/app/[lang]/portal/page.tsx new file mode 100644 index 00000000..72d6b09e --- /dev/null +++ b/apps/web/src/app/[lang]/portal/page.tsx @@ -0,0 +1,40 @@ +import { requireSession } from '@/auth/guards'; +import { isLocale } from '@/i18n/config'; +import { getDictionary } from '@/i18n/get-dictionary'; +import { serverEnv } from '@/lib/env'; +import { PortalHome } from '@/components/portal/portal-home'; +import type { MeResponse } from '@/types/api'; + +async function fetchMe(accessToken: string): Promise { + try { + const res = await fetch(`${serverEnv.API_URL}/me`, { + headers: { Authorization: `Bearer ${accessToken}` }, + cache: 'no-store', + }); + if (!res.ok) return null; + const json = (await res.json()) as MeResponse | { data: MeResponse }; + return 'data' in json ? json.data : json; + } catch { + return null; + } +} + +export default async function PortalHomePage({ + params, +}: { + params: Promise<{ lang: string }>; +}) { + const { lang } = await params; + const locale = isLocale(lang) ? lang : 'en'; + const dict = await getDictionary(locale); + + const session = await requireSession({ locale }); + const userName = session.user?.name ?? session.user?.email ?? ''; + + let me: MeResponse | null = null; + if (session.accessToken) { + me = await fetchMe(session.accessToken); + } + + return ; +} diff --git a/apps/web/src/app/[lang]/portal/profile/page.tsx b/apps/web/src/app/[lang]/portal/profile/page.tsx new file mode 100644 index 00000000..ac358360 --- /dev/null +++ b/apps/web/src/app/[lang]/portal/profile/page.tsx @@ -0,0 +1,158 @@ +import { UserIcon, ExternalLinkIcon, KeyRoundIcon } from 'lucide-react'; + +import { requireSession } 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 { getPortalDict } from '@/components/portal/portal-dict'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import type { MeResponse } from '@/types/api'; + +async function fetchMe(accessToken: string): Promise { + try { + const res = await fetch(`${serverEnv.API_URL}/me`, { + headers: { Authorization: `Bearer ${accessToken}` }, + cache: 'no-store', + }); + if (!res.ok) return null; + const json = (await res.json()) as MeResponse | { data: MeResponse }; + return 'data' in json ? json.data : json; + } catch { + return null; + } +} + +export default async function PortalProfilePage({ + params, +}: { + params: Promise<{ lang: string }>; +}) { + const { lang } = await params; + const locale = isLocale(lang) ? lang : 'en'; + const dict = await getDictionary(locale); + // Shared profile labels (same as the admin profile page); the extra + // change-password link copy lives in the portal namespace. + const t = dict.profile; + const tp = getPortalDict(dict, locale).profile; + + const session = await requireSession({ locale }); + + let me: MeResponse | null = null; + if (session.accessToken) { + me = await fetchMe(session.accessToken); + } + + const name = me?.user?.fullName ?? session.user?.name ?? '—'; + const email = me?.user?.email ?? session.user?.email ?? '—'; + const role = normalizeRole(me?.role ?? session.role ?? session.user?.role); + const roleLabel = role ? dict.auth.roles[role] : '—'; + const orgName = me?.org?.name ?? '—'; + + const accountUrl = `${serverEnv.KEYCLOAK_BASE}/realms/${serverEnv.KEYCLOAK_REALM}/account`; + + return ( +
+
+ + + +
+

+ {t.title} +

+

{t.subtitle}

+
+
+ + + + {name} + {email} + + + {roleLabel} + + + {orgName} + + + + {/* Account management (Keycloak console) */} + + + } + title={t.manageAccount} + hint={t.manageAccountHint} + href={accountUrl} + cta={t.manageAccount} + /> +
+ } + title={tp.changePassword} + hint={tp.changePasswordHint} + href={accountUrl} + cta={tp.changePassword} + /> + + +
+ ); +} + +function Field({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+

{label}

+
{children}
+
+ ); +} + +function ActionRow({ + icon, + title, + hint, + href, + cta, +}: { + icon: React.ReactNode; + title: string; + hint: string; + href: string; + cta: string; +}) { + return ( +
+
+ + {icon} + +
+

{title}

+

{hint}

+
+
+ +
+ ); +} diff --git a/apps/web/src/app/[lang]/portal/support/page.tsx b/apps/web/src/app/[lang]/portal/support/page.tsx new file mode 100644 index 00000000..381c9b9d --- /dev/null +++ b/apps/web/src/app/[lang]/portal/support/page.tsx @@ -0,0 +1,15 @@ +import { isLocale } from '@/i18n/config'; +import { getDictionary } from '@/i18n/get-dictionary'; +import { PortalSupport } from '@/components/portal/portal-support'; + +export default async function PortalSupportPage({ + params, +}: { + params: Promise<{ lang: string }>; +}) { + const { lang } = await params; + const locale = isLocale(lang) ? lang : 'en'; + const dict = await getDictionary(locale); + + return ; +} diff --git a/apps/web/src/app/api/apartments/status-sweep/route.ts b/apps/web/src/app/api/apartments/status-sweep/route.ts new file mode 100644 index 00000000..f1fd0f83 --- /dev/null +++ b/apps/web/src/app/api/apartments/status-sweep/route.ts @@ -0,0 +1,7 @@ +import { forwardRoute } from '@/lib/api/forward'; + +export const runtime = 'nodejs'; + +// F4.2 — org_admin on-demand apartment-status expiry sweep. Also runs daily via +// the backend scheduler; this is the on-demand path (and the verify hook). +export const POST = forwardRoute('/apartments/status-sweep'); diff --git a/apps/web/src/app/api/auth/federated-logout/route.ts b/apps/web/src/app/api/auth/federated-logout/route.ts index 8eee3afd..8f2afd3a 100644 --- a/apps/web/src/app/api/auth/federated-logout/route.ts +++ b/apps/web/src/app/api/auth/federated-logout/route.ts @@ -20,12 +20,27 @@ export async function GET(request: NextRequest) { const url = new URL(request.url); const locale = url.searchParams.get('locale') ?? 'en'; + // Resolve the PUBLIC origin. Behind the nginx reverse proxy the Next.js + // standalone server derives `request.url` from its own internal bind address + // (e.g. `https://:3000`), so `url.origin` leaks the container + // host into `post_logout_redirect_uri` — Keycloak then rejects it as an + // invalid redirect URI. Trust the forwarded headers instead (nginx sets + // `Host: $host` and `X-Forwarded-Proto: $scheme`; `trustHost: true` is on in + // auth.config.ts), falling back to the request URL only for local/dev. + const forwardedHost = + request.headers.get('x-forwarded-host') ?? request.headers.get('host'); + const forwardedProto = + request.headers.get('x-forwarded-proto') ?? url.protocol.replace(/:$/, ''); + const origin = forwardedHost + ? `${forwardedProto}://${forwardedHost}` + : url.origin; + const endSession = `${serverEnv.KEYCLOAK_BASE}/realms/${serverEnv.KEYCLOAK_REALM}/protocol/openid-connect/logout`; const params = new URLSearchParams(); if (session?.idToken) { params.set('id_token_hint', session.idToken); } - params.set('post_logout_redirect_uri', `${url.origin}/${locale}`); + params.set('post_logout_redirect_uri', `${origin}/${locale}`); params.set('client_id', serverEnv.OAUTH_CLIENT); return NextResponse.json({ url: `${endSession}?${params.toString()}` }); diff --git a/apps/web/src/app/api/available-units/route.ts b/apps/web/src/app/api/available-units/route.ts new file mode 100644 index 00000000..08924c44 --- /dev/null +++ b/apps/web/src/app/api/available-units/route.ts @@ -0,0 +1,5 @@ +import { forwardRoute } from '@/lib/api/forward'; + +export const runtime = 'nodejs'; + +export const GET = forwardRoute('/available-units'); diff --git a/apps/web/src/app/api/recurring-invoices/run/route.ts b/apps/web/src/app/api/recurring-invoices/run/route.ts new file mode 100644 index 00000000..cacd7df3 --- /dev/null +++ b/apps/web/src/app/api/recurring-invoices/run/route.ts @@ -0,0 +1,7 @@ +import { forwardRoute } from '@/lib/api/forward'; + +export const runtime = 'nodejs'; + +// F3.1 — org_admin "generate rent invoices now" trigger. Also runs daily via the +// backend BullMQ scheduler; this is the on-demand path (and the verify hook). +export const POST = forwardRoute('/recurring-invoices/run'); diff --git a/apps/web/src/app/api/work-orders/assigned-to-me/route.ts b/apps/web/src/app/api/work-orders/assigned-to-me/route.ts new file mode 100644 index 00000000..c5f94a69 --- /dev/null +++ b/apps/web/src/app/api/work-orders/assigned-to-me/route.ts @@ -0,0 +1,5 @@ +import { forwardRoute } from '@/lib/api/forward'; + +export const runtime = 'nodejs'; + +export const GET = forwardRoute('/work-orders/assigned-to-me'); diff --git a/apps/web/src/auth/permissions.ts b/apps/web/src/auth/permissions.ts index 41f7cc9c..aedbc03c 100644 --- a/apps/web/src/auth/permissions.ts +++ b/apps/web/src/auth/permissions.ts @@ -29,6 +29,9 @@ * "notifications" → /dashboard/notifications (a personal inbox, like the * header bell — every role gets 'full', there is no * restricted view of someone else's notifications) + * + * Note: available-units is NOT an admin area — the vacant-units showcase lives + * only in the tenant portal (/[lang]/portal/available-units), display-only. */ import type { Role } from '@/auth/roles'; @@ -80,7 +83,7 @@ export const PERMISSION_MATRIX: PermissionMatrix = { dashboard: 'readonly', buildings: 'readonly', users: 'readonly', - payments: 'readonly', + payments: 'none', reports: 'none', billing: 'none', timeline: 'readonly', diff --git a/apps/web/src/auth/roles.ts b/apps/web/src/auth/roles.ts index d2730ce1..e6c8b210 100644 --- a/apps/web/src/auth/roles.ts +++ b/apps/web/src/auth/roles.ts @@ -35,7 +35,8 @@ export const ROLE_DASHBOARD: Record = { supervisor: '/dashboard', finance: '/dashboard', maintenance: '/dashboard', - tenant: '/dashboard', + // Tenants get the dedicated resident portal, not the admin dashboard shell. + tenant: '/portal', }; /** diff --git a/apps/web/src/components/dashboard/apartment-detail-page.tsx b/apps/web/src/components/dashboard/apartment-detail-page.tsx index 535eb83c..ab9ac281 100644 --- a/apps/web/src/components/dashboard/apartment-detail-page.tsx +++ b/apps/web/src/components/dashboard/apartment-detail-page.tsx @@ -20,6 +20,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; import { Skeleton } from '@/components/ui/skeleton'; import { Textarea } from '@/components/ui/textarea'; import { @@ -141,6 +142,18 @@ function LeaseStatusBadge({ type DialogDict = Dictionary['apartments']['dialog']; +/** + * Today as a local `YYYY-MM-DD` string — matches the value shape of a native + * `` so it can be used directly as `min` and compared + * lexically against form date strings. + */ +function todayDateInputValue(): string { + const d = new Date(); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + return `${d.getFullYear()}-${mm}-${dd}`; +} + function buildLeaseSchema(t: DialogDict) { const numericField = (label: string) => z @@ -161,10 +174,17 @@ function buildLeaseSchema(t: DialogDict) { depositAmount: numericField(t.fields.depositAmount), renewalTerms: z.string().optional(), notes: z.string().optional(), + // F4.3 (decision D3): a new active lease may not silently start in the + // past unless the creator explicitly flags it as an existing lease. + recordExisting: z.boolean(), }) .refine((v) => v.endDate >= v.startDate, { message: t.errors.endAfterStart, path: ['endDate'], + }) + .refine((v) => v.recordExisting || v.startDate >= todayDateInputValue(), { + message: t.errors.pastStartDate, + path: ['startDate'], }); } type LeaseFormValues = z.infer>; @@ -177,6 +197,7 @@ const DEFAULT_VALUES: LeaseFormValues = { depositAmount: '', renewalTerms: '', notes: '', + recordExisting: false, }; function buildRenewSchema(t: DialogDict) { @@ -255,12 +276,16 @@ export function ApartmentDetailPage({ handleSubmit, reset, control, + watch, formState: { errors }, } = useForm({ resolver: zodResolver(leaseSchema), defaultValues: DEFAULT_VALUES, }); + const recordExisting = watch('recordExisting'); + const todayStr = todayDateInputValue(); + const { register: regRenew, handleSubmit: handleRenewSubmit, @@ -284,6 +309,7 @@ export function ApartmentDetailPage({ depositAmount: Number(values.depositAmount), renewalTerms: values.renewalTerms || undefined, notes: values.notes || undefined, + ...(values.recordExisting ? { recordExisting: true } : {}), }, }).unwrap(); toast.success(t.dialog.create.success); @@ -583,6 +609,29 @@ export function ApartmentDetailPage({

)}
+
+
+ ( + + field.onChange(checked === true) + } + /> + )} + /> + +
+

+ {t.dialog.fields.recordExistingHelp} +

+
diff --git a/apps/web/src/components/dashboard/payments-page.tsx b/apps/web/src/components/dashboard/payments-page.tsx index 0a1b651a..613a3c38 100644 --- a/apps/web/src/components/dashboard/payments-page.tsx +++ b/apps/web/src/components/dashboard/payments-page.tsx @@ -55,7 +55,7 @@ export function PaymentsPage({ locale, readonly = false }: PaymentsPageProps) { const dateLocale = locale === 'ar' ? ar : undefined; const { data: paymentsData, isLoading } = useListPaymentsQuery(); - const payments = paymentsData?.data ?? []; + const payments = paymentsData?.items ?? []; const now = new Date(); const currentMonth = now.getMonth(); diff --git a/apps/web/src/components/dashboard/renters-page.tsx b/apps/web/src/components/dashboard/renters-page.tsx index 82ad9fe9..e9c92ba6 100644 --- a/apps/web/src/components/dashboard/renters-page.tsx +++ b/apps/web/src/components/dashboard/renters-page.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { useForm } from 'react-hook-form'; +import { useForm, Controller } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { toast } from 'sonner'; @@ -11,14 +11,19 @@ import { MoreHorizontalIcon, ContactIcon, EyeIcon, + EyeOffIcon, PencilIcon, TrashIcon, + CopyIcon, + CheckIcon, + RefreshCwIcon, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; import { Skeleton } from '@/components/ui/skeleton'; import { Textarea } from '@/components/ui/textarea'; import { @@ -34,6 +39,7 @@ import { DialogContent, DialogHeader, DialogTitle, + DialogDescription, DialogFooter, DialogClose, } from '@/components/ui/dialog'; @@ -51,7 +57,11 @@ import { useUpdateRenterMutation, useDeleteRenterMutation, } from '@/store/api/endpoints/renters.api'; -import type { RenterEffectiveStatus, RenterResponse } from '@/types/api'; +import type { + RenterEffectiveStatus, + RenterResponse, + ApiErrorEnvelope, +} from '@/types/api'; import type { Dictionary } from '@/i18n/get-dictionary'; // ── Status badge ───────────────────────────────────────────────────────────── @@ -109,6 +119,86 @@ const EMPTY_VALUES: RenterFormValues = { notes: '', }; +// ── Create-only: optional tenant portal login section (Sprint TP1) ─────────── +// An admin creating a renter may also mint a Keycloak tenant login in the same +// step. `portalEmail`/`portalPassword` are only required when the toggle is on +// — enforced via `superRefine` so the base `RenterFormValues` shape (shared +// with the edit form, which has no portal-login section) stays untouched. +function buildCreateRenterSchema(t: Dictionary['renters']['dialog']) { + return buildRenterSchema(t) + .extend({ + createPortalLogin: z.boolean(), + portalEmail: z.string().optional(), + portalPassword: z.string().optional(), + }) + .superRefine((values, ctx) => { + if (!values.createPortalLogin) return; + + const email = values.portalEmail?.trim() ?? ''; + if (!email) { + ctx.addIssue({ + code: 'custom', + message: t.portalLogin.emailRequired, + path: ['portalEmail'], + }); + } else if (!z.string().email().safeParse(email).success) { + ctx.addIssue({ + code: 'custom', + message: t.portalLogin.invalidEmail, + path: ['portalEmail'], + }); + } + + const password = values.portalPassword ?? ''; + if (!password) { + ctx.addIssue({ + code: 'custom', + message: t.portalLogin.passwordRequired, + path: ['portalPassword'], + }); + } else if (password.length < 8) { + ctx.addIssue({ + code: 'custom', + message: t.portalLogin.passwordMinLength, + path: ['portalPassword'], + }); + } + }); +} + +type CreateRenterFormValues = z.infer< + ReturnType +>; + +const CREATE_EMPTY_VALUES: CreateRenterFormValues = { + ...EMPTY_VALUES, + createPortalLogin: false, + portalEmail: '', + portalPassword: '', +}; + +const PASSWORD_CHARS = + 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%'; + +/** + * A strong, random password for the "Generate" button. Avoids visually + * ambiguous characters (0/O, 1/l/I) since an admin may need to read it aloud + * or retype it for the tenant. + */ +function generateStrongPassword(length = 14): string { + const bytes = new Uint32Array(length); + if (typeof crypto !== 'undefined' && crypto.getRandomValues) { + crypto.getRandomValues(bytes); + } else { + for (let i = 0; i < length; i++) { + bytes[i] = Math.floor(Math.random() * PASSWORD_CHARS.length); + } + } + return Array.from(bytes, (n) => PASSWORD_CHARS[n % PASSWORD_CHARS.length]).join( + '', + ); +} + // ── Main component ──────────────────────────────────────────────────────────── interface RentersPageProps { @@ -129,17 +219,32 @@ export function RentersPage({ canWrite, locale, dict }: RentersPageProps) { const [createOpen, setCreateOpen] = useState(false); const [editTarget, setEditTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); + const [showPortalPassword, setShowPortalPassword] = useState(false); + // Set once, right after a successful create-with-login — shown exactly once + // for handoff to the tenant, then discarded (never persisted or refetched). + const [portalCredentials, setPortalCredentials] = useState<{ + email: string; + password: string; + } | null>(null); const renterSchema = useMemo(() => buildRenterSchema(t.dialog), [t.dialog]); + const createRenterSchema = useMemo( + () => buildCreateRenterSchema(t.dialog), + [t.dialog], + ); const { register: regCreate, handleSubmit: handleCreate, reset: resetCreate, + control: controlCreate, + watch: watchCreate, + getValues: getCreateValues, + setValue: setCreateValue, formState: { errors: createErrors }, - } = useForm({ - resolver: zodResolver(renterSchema), - defaultValues: EMPTY_VALUES, + } = useForm({ + resolver: zodResolver(createRenterSchema), + defaultValues: CREATE_EMPTY_VALUES, }); const { @@ -152,11 +257,28 @@ export function RentersPage({ canWrite, locale, dict }: RentersPageProps) { defaultValues: EMPTY_VALUES, }); + const createPortalLogin = watchCreate('createPortalLogin'); + function goToRenter(renterId: string) { router.push(`/${locale}/dashboard/renters/${renterId}`); } - async function onCreateSubmit(values: RenterFormValues) { + function closeCreateDialog() { + setCreateOpen(false); + setShowPortalPassword(false); + resetCreate(CREATE_EMPTY_VALUES); + } + + async function onCreateSubmit(values: CreateRenterFormValues) { + // Only forward portalLogin when the toggle is on AND both fields passed + // validation — guards against a stale/partial value if the toggle was + // flipped off after typing. + const portalEmail = values.createPortalLogin + ? (values.portalEmail?.trim() ?? '') + : ''; + const portalPassword = values.createPortalLogin + ? (values.portalPassword ?? '') + : ''; try { await createRenter({ fullName: values.fullName, @@ -165,12 +287,29 @@ export function RentersPage({ canWrite, locale, dict }: RentersPageProps) { emergencyContactName: values.emergencyContactName || undefined, emergencyContactPhone: values.emergencyContactPhone || undefined, notes: values.notes || undefined, + ...(portalEmail && portalPassword + ? { portalLogin: { email: portalEmail, password: portalPassword } } + : {}), }).unwrap(); toast.success(t.dialog.createdToast); setCreateOpen(false); - resetCreate(EMPTY_VALUES); - } catch { - toast.error(t.dialog.createErrorToast); + setShowPortalPassword(false); + if (portalEmail && portalPassword) { + // Hand off the credentials once — the dialog close resets the form, + // so this is the only place they're readable after this point. + setPortalCredentials({ email: portalEmail, password: portalPassword }); + } + resetCreate(CREATE_EMPTY_VALUES); + } catch (err) { + const apiErr = err as Partial; + if (portalEmail && apiErr?.status === 409) { + // Keycloak username (email) already taken — dialog stays open (we + // never call setCreateOpen(false) on this path) so the admin can fix + // the email and resubmit. + toast.error(t.dialog.portalLogin.emailConflictError); + } else { + toast.error(t.dialog.createErrorToast); + } } } @@ -378,7 +517,13 @@ export function RentersPage({ canWrite, locale, dict }: RentersPageProps) {
{/* ── Create Renter Dialog ───────────────────────────────────────────── */} - + { + if (open) setCreateOpen(true); + else closeCreateDialog(); + }} + > {t.dialog.addTitle} @@ -389,17 +534,141 @@ export function RentersPage({ canWrite, locale, dict }: RentersPageProps) { > `, a + // strict superset of `RenterFormValues` (same base fields, plus + // the portal-login ones). `RenterFormFields` only ever + // registers the shared base fields, so this is safe — but TS + // can't verify cross-form-shape register() assignability on + // its own. + register={ + regCreate as unknown as ReturnType< + typeof useForm + >['register'] + } errors={createErrors} t={t.dialog} /> + + {/* ── Optional tenant portal login (Sprint TP1) ─────────────── */} +
+
+ ( + { + const next = checked === true; + field.onChange(next); + // Prefill the login email from the renter's own + // email, if any — only when the admin hasn't already + // typed one in. + if (next && !getCreateValues('portalEmail')) { + const baseEmail = getCreateValues('email'); + if (baseEmail) { + setCreateValue('portalEmail', baseEmail); + } + } + }} + className="mt-0.5" + /> + )} + /> +
+ +

+ {t.dialog.portalLogin.toggleHelp} +

+
+
+ + {createPortalLogin && ( +
+
+ + + {createErrors.portalEmail && ( +

+ {createErrors.portalEmail.message} +

+ )} +
+
+ +
+
+ + +
+ +
+ {createErrors.portalPassword && ( +

+ {createErrors.portalPassword.message} +

+ )} +
+
+ )} +
+ } - onClick={() => { - setCreateOpen(false); - resetCreate(EMPTY_VALUES); - }} + onClick={closeCreateDialog} > {dict.common.cancel} @@ -491,6 +760,97 @@ export function RentersPage({ canWrite, locale, dict }: RentersPageProps) {
+ + {/* ── Portal Login Credentials (shown once for handoff) ─────────────── */} + { + if (!open) setPortalCredentials(null); + }} + > + + + {t.dialog.portalLogin.successTitle} + + {t.dialog.portalLogin.successDescription} + + + {portalCredentials && ( +
+ + +
+ )} + + } + onClick={() => setPortalCredentials(null)} + > + {t.dialog.portalLogin.done} + + +
+
+ + ); +} + +// ── Portal login credentials row (copy-to-clipboard) ────────────────────────── + +function CredentialRow({ + label, + value, + copyLabel, + copiedText, +}: { + label: string; + value: string; + copyLabel: string; + copiedText: string; +}) { + const [copied, setCopied] = useState(false); + + async function handleCopy() { + try { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard API unavailable/denied — the value is still visible to + // select and copy manually. + } + } + + return ( +
+ +
+ + {value} + + +
+ {copied && ( +

{copiedText}

+ )}
); } diff --git a/apps/web/src/components/dashboard/reports-page.tsx b/apps/web/src/components/dashboard/reports-page.tsx index 51767239..ff710d1d 100644 --- a/apps/web/src/components/dashboard/reports-page.tsx +++ b/apps/web/src/components/dashboard/reports-page.tsx @@ -245,6 +245,9 @@ export function ReportsPage({ locale, dict }: ReportsPageProps) {

{t.title}

{t.subtitle}

+

+ {t.methodologyNote} +

{/* Date-range filter */} @@ -315,6 +318,7 @@ export function ReportsPage({ locale, dict }: ReportsPageProps) { label={t.kpis.mtdIncome} value={money.format(Number(summary.mtdIncome))} icon={} + hint={t.kpis.incomeHint} /> ) } + hint={t.kpis.netHint} /> )} @@ -339,6 +344,7 @@ export function ReportsPage({ locale, dict }: ReportsPageProps) { label={t.kpis.rangeIncome} value={money.format(Number(summary.range.income))} icon={} + hint={t.kpis.incomeHint} /> ) } + hint={t.kpis.netHint} /> )} diff --git a/apps/web/src/components/dashboard/staff-dashboard.tsx b/apps/web/src/components/dashboard/staff-dashboard.tsx index 8fc98d35..137b1bd6 100644 --- a/apps/web/src/components/dashboard/staff-dashboard.tsx +++ b/apps/web/src/components/dashboard/staff-dashboard.tsx @@ -12,9 +12,14 @@ import { } from 'lucide-react'; import { useListMaintenanceRequestsQuery } from '@/store/api/endpoints/maintenance-requests.api'; import { useListTimelineQuery } from '@/store/api/endpoints/timeline.api'; +import { useGetAssignedWorkOrdersQuery } from '@/store/api/endpoints/work-orders.api'; import { maintenanceStats, topActiveRequests } from '@/lib/dashboard-kpis'; import { KpiTile } from '@/components/dashboard/kpi-tile'; -import type { MaintenanceRequestPriority, MeResponse } from '@/types/api'; +import type { + MaintenanceRequestPriority, + MeResponse, + WorkOrderStatus, +} from '@/types/api'; import type { Dictionary } from '@/i18n/get-dictionary'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; @@ -38,20 +43,36 @@ const PRIORITY_CLASS: Record = { low: 'bg-gray-500/15 text-gray-600 border-gray-200', }; -export function StaffDashboard({ me, locale, dict }: StaffDashboardProps) { +const WORK_ORDER_STATUS_CLASS: Record = { + scheduled: 'bg-amber-50 text-amber-700 border-amber-200', + in_progress: 'bg-blue-50 text-blue-700 border-blue-200', + completed: 'bg-emerald-50 text-emerald-700 border-emerald-200', + canceled: 'bg-muted text-muted-foreground border-transparent', +}; + +export function StaffDashboard({ + me, + locale, + role, + dict, +}: StaffDashboardProps) { const t = dict.dashboard; const k = t.kpi; + const m = dict.maintenance; const dateLocale = locale === 'ar' ? ar : undefined; const base = `/${locale}/dashboard`; + const isMaintenance = role === 'maintenance'; const { data: requests, isLoading: requestsLoading } = useListMaintenanceRequestsQuery(); const { data: timelineData, isLoading: timelineLoading } = useListTimelineQuery({ limit: 5 }); + const { data: assignedWorkOrders, isLoading: assignedLoading } = + useGetAssignedWorkOrdersQuery(undefined, { skip: !isMaintenance }); const stats = maintenanceStats(requests); const attention = topActiveRequests(requests, 5); - const timelineEvents = timelineData?.data ?? []; + const timelineEvents = timelineData?.items ?? []; const userName = me?.user?.fullName ?? ''; return ( @@ -65,22 +86,80 @@ export function StaffDashboard({ me, locale, dict }: StaffDashboardProps) {

{k.staffOverview}

+ {/* My work orders — maintenance-role only. Surfaced above the KPI row + so a maintenance user sees their own assigned work first. */} + {isMaintenance && ( +
+
+

+ {k.myWorkOrders.title} +

+

+ {k.myWorkOrders.subtitle} +

+
+
+ {assignedLoading ? ( +
+ {[0, 1, 2].map((i) => ( + + ))} +
+ ) : !assignedWorkOrders || assignedWorkOrders.length === 0 ? ( +

+ {k.myWorkOrders.empty} +

+ ) : ( +
    + {assignedWorkOrders.map((row) => ( +
  • + +
    +

    + {row.numberLabel} · {row.requestTitle} +

    +

    + {row.buildingName} + {row.apartmentUnit + ? ` · ${k.unit} ${row.apartmentUnit}` + : ''} +

    +
    + + {m.workOrderStatus[row.status]} + + + +
  • + ))} +
+ )} +
+
+ )} + {/* KPI row — live from the maintenance-request list (building-scoped server-side; the one org-wide list both supervisor and maintenance - can read). All tiles deep-link into the tasks module. */} + can read). All tiles deep-link into the filtered tasks module. */}
} - href={`${base}/tasks`} + href={`${base}/tasks?status=open`} loading={requestsLoading} /> } - href={`${base}/tasks`} + href={`${base}/tasks?status=in_progress`} loading={requestsLoading} /> 0 ? 'negative' : 'neutral'} icon={} - href={`${base}/tasks`} + href={`${base}/tasks?priority=urgent`} loading={requestsLoading} /> 0 ? 'positive' : 'neutral'} icon={} - href={`${base}/tasks`} + href={`${base}/tasks?status=resolved`} loading={requestsLoading} />
@@ -122,7 +201,7 @@ export function StaffDashboard({ me, locale, dict }: StaffDashboardProps) { {attention.map((req) => (
  • diff --git a/apps/web/src/components/dashboard/support-page.tsx b/apps/web/src/components/dashboard/support-page.tsx index e41c305a..c7f4cb32 100644 --- a/apps/web/src/components/dashboard/support-page.tsx +++ b/apps/web/src/components/dashboard/support-page.tsx @@ -43,10 +43,14 @@ import type { Dictionary } from '@/i18n/get-dictionary'; // ── Static option lists (labels resolved from the dictionary at render) ─────── +// Maintenance is intentionally excluded from the CREATE form: a tenant's +// category:'maintenance' ticket can never be actioned by the maintenance role +// (resolve/close excludes them) — the real channel for that is a +// MaintenanceRequest, not a support ticket. Existing tickets already tagged +// 'maintenance' still render fine (CATEGORY_STYLES/t.categories keep the key). const CATEGORIES: SupportTicketCategory[] = [ 'general', 'billing', - 'maintenance', 'technical', 'other', ]; diff --git a/apps/web/src/components/dashboard/tasks-page.tsx b/apps/web/src/components/dashboard/tasks-page.tsx index 413a5614..4da1b833 100644 --- a/apps/web/src/components/dashboard/tasks-page.tsx +++ b/apps/web/src/components/dashboard/tasks-page.tsx @@ -1,7 +1,7 @@ 'use client'; -import { useMemo, useState } from 'react'; -import { useRouter } from 'next/navigation'; +import { useEffect, useMemo, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; import { useForm, Controller } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; @@ -62,6 +62,7 @@ import { useListBuildingsQuery } from '@/store/api/endpoints/buildings.api'; import { useListFloorsQuery } from '@/store/api/endpoints/floors.api'; import { useListApartmentsQuery } from '@/store/api/endpoints/apartments.api'; import { useListRentersQuery } from '@/store/api/endpoints/renters.api'; +import { useListAllLeasesQuery } from '@/store/api/endpoints/leases.api'; import type { MaintenanceRequestPriority, MaintenanceRequestResponse, @@ -174,6 +175,8 @@ type EditFormValues = z.infer>; // ── Main component ──────────────────────────────────────────────────────────── +const ALL = '__all__'; + interface TasksPageProps { /** When false (non-admin), hide all write actions. */ canWrite: boolean; @@ -184,6 +187,7 @@ interface TasksPageProps { export function TasksPage({ canWrite, locale, dict }: TasksPageProps) { const t = dict.tasks; const router = useRouter(); + const searchParams = useSearchParams(); const { data: requests, isLoading, @@ -192,6 +196,18 @@ export function TasksPage({ canWrite, locale, dict }: TasksPageProps) { const { data: buildings } = useListBuildingsQuery(); const { data: renters } = useListRentersQuery(); + // Dashboard KPI tiles deep-link here with `?status=` / `?priority=` to land + // pre-filtered (e.g. a supervisor's "urgent" tile). Unknown/absent values + // fall back to "all". + const [statusFilter, setStatusFilter] = useState(() => { + const param = searchParams.get('status'); + return param && (STATUSES as string[]).includes(param) ? param : ALL; + }); + const [priorityFilter, setPriorityFilter] = useState(() => { + const param = searchParams.get('priority'); + return param && (PRIORITIES as string[]).includes(param) ? param : ALL; + }); + const [createMaintenanceRequest, { isLoading: creating }] = useCreateMaintenanceRequestMutation(); const [updateMaintenanceRequest, { isLoading: updating }] = @@ -217,6 +233,7 @@ export function TasksPage({ canWrite, locale, dict }: TasksPageProps) { reset: resetCreate, watch: watchCreate, setValue: setCreateValue, + getValues: getCreateValues, formState: { errors: createErrors }, } = useForm({ resolver: zodResolver(createSchema), @@ -225,6 +242,8 @@ export function TasksPage({ canWrite, locale, dict }: TasksPageProps) { const createBuildingId = watchCreate('buildingId'); const createFloorId = watchCreate('floorId'); + const createApartmentId = watchCreate('apartmentId'); + const createRenterId = watchCreate('renterId'); const { data: createFloors } = useListFloorsQuery(createBuildingId, { skip: !createBuildingId, @@ -234,6 +253,53 @@ export function TasksPage({ canWrite, locale, dict }: TasksPageProps) { { skip: !createBuildingId || !createFloorId }, ); + // A maintenance request belongs to a property; the renter is derived from it. + // When the user picks an apartment that has an active lease, auto-fill the + // renter from that lease so they don't have to hand-pick (and can't mismatch) + // it. Apartments with no active lease fall back to manual selection. + const { data: allLeases } = useListAllLeasesQuery(); + useEffect(() => { + if (!createApartmentId) return; + const activeLease = allLeases?.find( + (l) => + l.apartmentId === createApartmentId && l.effectiveStatus === 'active', + ); + // Guard against ping-ponging with the renter→apartment effect below: only + // write when the value actually needs to change. + if ( + activeLease && + getCreateValues('renterId') !== activeLease.renterId + ) { + setCreateValue('renterId', activeLease.renterId, { + shouldValidate: true, + }); + } + }, [createApartmentId, allLeases, setCreateValue, getCreateValues]); + + // Reverse of the above: picking a renter with exactly one active lease + // fills building → floor → apartment from that lease (building must be set + // first since floor/apartment options are dependent queries). A renter with + // zero or multiple active leases leaves the fields for manual selection. + useEffect(() => { + if (!createRenterId) return; + const matches = allLeases?.filter( + (l) => l.renterId === createRenterId && l.effectiveStatus === 'active', + ); + if (!matches || matches.length !== 1) return; + const [lease] = matches; + if (getCreateValues('buildingId') !== lease.buildingId) { + setCreateValue('buildingId', lease.buildingId, { shouldValidate: true }); + } + if (getCreateValues('floorId') !== lease.floorId) { + setCreateValue('floorId', lease.floorId, { shouldValidate: true }); + } + if (getCreateValues('apartmentId') !== lease.apartmentId) { + setCreateValue('apartmentId', lease.apartmentId, { + shouldValidate: true, + }); + } + }, [createRenterId, allLeases, setCreateValue, getCreateValues]); + const editSchema = useMemo( () => buildEditSchema(t.dialog.edit), [t.dialog.edit], @@ -314,6 +380,20 @@ export function TasksPage({ canWrite, locale, dict }: TasksPageProps) { router.push(`/${locale}/dashboard/tasks/${requestId}`); } + const hasAnyRequests = (requests?.length ?? 0) > 0; + + const filteredRequests = useMemo(() => { + return (requests ?? []).filter((request) => { + if (statusFilter !== ALL && request.status !== statusFilter) { + return false; + } + if (priorityFilter !== ALL && request.priority !== priorityFilter) { + return false; + } + return true; + }); + }, [requests, statusFilter, priorityFilter]); + return (
    {/* Header */} @@ -345,6 +425,65 @@ export function TasksPage({ canWrite, locale, dict }: TasksPageProps) {
    + {/* Filters */} + {hasAnyRequests && ( +
    +
    + + +
    +
    + + +
    +
    + )} + {/* Requests table */}
    @@ -391,7 +530,7 @@ export function TasksPage({ canWrite, locale, dict }: TasksPageProps) { {t.list.loadError} - ) : requests?.length === 0 ? ( + ) : !hasAnyRequests ? ( + ) : filteredRequests.length === 0 ? ( + + + {t.list.noMatch} + + ) : ( - requests?.map((request) => ( + filteredRequests.map((request) => ( = { - open: 'bg-amber-50 text-amber-700 border-amber-200', - partially_paid: 'bg-blue-50 text-blue-700 border-blue-200', - paid: 'bg-emerald-50 text-emerald-700 border-emerald-200', - overdue: 'bg-red-50 text-red-700 border-red-200', -}; - -const REQUEST_STATUS_STYLES: Record = { - open: 'bg-amber-50 text-amber-700 border-amber-200', - in_progress: 'bg-blue-50 text-blue-700 border-blue-200', - resolved: 'bg-emerald-50 text-emerald-700 border-emerald-200', - closed: 'bg-muted text-muted-foreground border-transparent', -}; - -const PRIORITY_STYLES: Record = { - low: 'bg-muted text-muted-foreground border-transparent', - medium: 'bg-blue-50 text-blue-700 border-blue-200', - high: 'bg-orange-50 text-orange-700 border-orange-200', - urgent: 'bg-red-50 text-red-700 border-red-200', -}; - -const PRIORITIES: MaintenanceRequestPriority[] = [ - 'low', - 'medium', - 'high', - 'urgent', -]; - -const OPEN_STATUSES: MaintenanceRequestStatus[] = ['open', 'in_progress']; - -// ── Form ────────────────────────────────────────────────────────────────────── - -type RequestFormValues = { - title: string; - description: string; - priority: MaintenanceRequestPriority; -}; - -const EMPTY_VALUES: RequestFormValues = { - title: '', - description: '', - priority: 'medium', -}; - -interface TenantDashboardProps { - me: MeResponse | null; - locale: string; - dict: Dictionary; -} - -export function TenantDashboard({ me, locale, dict }: TenantDashboardProps) { - const isAr = locale === 'ar'; - const t = dict.dashboard.tenant; - const money = useMoney(locale); - - const { - data: overview, - isLoading, - isError, - } = useGetTenantOverviewQuery(); - const [createRequest, { isLoading: creating }] = - useCreateTenantMaintenanceRequestMutation(); - const { data: timelineData, isLoading: timelineLoading } = - useListTimelineQuery({ limit: 5 }); - - const [createOpen, setCreateOpen] = useState(false); - - const dateFmt = useMemo( - () => - new Intl.DateTimeFormat(locale, { - year: 'numeric', - month: 'short', - day: 'numeric', - }), - [locale], - ); - const fmtDate = (iso: string) => dateFmt.format(new Date(iso)); - - const schema = useMemo( - () => - z.object({ - title: z.string().trim().min(1, t.form.titleRequired), - description: z.string(), - priority: z.enum(['low', 'medium', 'high', 'urgent']), - }), - [t], - ); - - const { - register, - control, - handleSubmit, - reset, - formState: { errors }, - } = useForm({ - resolver: zodResolver(schema), - defaultValues: EMPTY_VALUES, - }); - - const events = timelineData?.data ?? []; - const requests = overview?.maintenanceRequests ?? []; - const openCount = requests.filter((r) => - OPEN_STATUSES.includes(r.status), - ).length; - const canOpen = canOpenRequest(overview); - - async function onSubmit(values: RequestFormValues) { - try { - await createRequest({ - title: values.title.trim(), - description: values.description.trim() || undefined, - priority: values.priority, - }).unwrap(); - toast.success(t.requests.created); - setCreateOpen(false); - reset(EMPTY_VALUES); - } catch { - toast.error(t.requests.createError); - } - } - - // ── Header ────────────────────────────────────────────────────────────────── - const header = ( - - - - {`${t.welcome}${me?.user?.fullName ? `, ${me.user.fullName}` : ''}`} - - - {me?.org?.name ? `${t.subtitle} · ${me.org.name}` : t.subtitle} - - - - ); - - if (isLoading) { - return ( -
    - {header} -
    - - - -
    - -
    - ); - } - - if (isError) { - return ( -
    - {header} -
    - {t.loadError} -
    -
    - ); - } - - // Not linked yet — warm empty state, no numbers. - if (!overview?.linked) { - return ( -
    - {header} - - - -
    -

    {t.notLinkedTitle}

    -

    - {t.notLinkedDesc} -

    -
    -
    -
    - -
    - ); - } - - const lease = overview.lease; - const balance = overview.balance; - const invoices = overview.invoices; - - return ( -
    - {header} - - {/* KPI row */} -
    - } - hint={ - balanceTone(balance.outstanding) === 'negative' - ? t.balance.due - : t.balance.settled - } - /> - } - /> - } - hint={t.requests.ofTotal.replace('{count}', String(requests.length))} - /> -
    - - {/* My lease */} - - - - {t.lease.title} - - - - {lease ? ( -
    - - {lease.unitNumber} - - {' · '} - {lease.buildingName} - - - - - - - {fmtDate(lease.startDate)} - {` ${t.lease.to} `} - {fmtDate(lease.endDate)} - - - - {money.format(parseFloat(lease.rentAmount))} - - - - - {money.format(parseFloat(lease.depositAmount))} - - -
    - ) : ( -

    {t.lease.none}

    - )} -
    -
    - - {/* My invoices */} - - - - {t.invoices.title} - - - - {invoices.length === 0 ? ( -

    - - {t.invoices.empty} -

    - ) : ( -
    -
    - - - - - - - - - - - {invoices.map((inv) => ( - - - - - - - - ))} - -
    - {t.invoices.dueDate} - - {t.invoices.invoiced} - - {t.invoices.paid} - - {t.invoices.balance} - - {t.invoices.status} -
    {fmtDate(inv.dueDate)} - {money.format(parseFloat(inv.invoiced))} - - {money.format(parseFloat(inv.paid))} - - {money.format(parseFloat(inv.balance))} - - - {t.invoiceStatus[inv.status]} - -
    -
    - )} - - - - {/* My maintenance requests */} - - - - {t.requests.title} - - - - - {!canOpen && ( -

    - {t.requests.noLease} -

    - )} - {requests.length === 0 ? ( -

    - - {canOpen ? t.requests.emptyActive : t.requests.empty} -

    - ) : ( -
      - {requests.map((r) => ( -
    • -
      - - {t.requestStatus[r.status]} - - - {t.priority[r.priority]} - - - {t.requests.unit} {r.unitNumber} · {fmtDate(r.createdAt)} - -
      -

      - {r.title} -

      - {r.description && ( -

      - {r.description} -

      - )} -
    • - ))} -
    - )} -
    -
    - - {/* Your activity */} - - - {/* Create request dialog */} - - - - {t.form.title} - -
    -
    - - - {errors.title && ( -

    - {errors.title.message} -

    - )} -
    - -
    - - ( - - )} - /> -
    - -
    - -