Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -110,6 +113,9 @@ import { TenantModule } from '@/modules/tenant/tenant.module';
SupportTicketsModule,
ReportsModule,
TenantModule,
RecurringInvoicesModule,
ApartmentStatusSweepModule,
AvailableUnitsModule,
],
providers: [
{ provide: APP_GUARD, useClass: ThrottlerGuard },
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void> {
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)}`,
);
}
}
}
Original file line number Diff line number Diff line change
@@ -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 * * *';
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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 {}
Original file line number Diff line number Diff line change
@@ -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<void> {
this.logger.debug(`Starting apartment-status sweep (job ${job.id})`);
await this.apartmentStatusSweep.sweepAll();
}
}
Original file line number Diff line number Diff line change
@@ -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<Record<string, jest.Mock>>;
} = {},
) {
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 } },
},
});
});
});
});
Original file line number Diff line number Diff line change
@@ -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<ApartmentStatusSweepResponse> {
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<ApartmentStatusSweepResponse> {
return this.sweep(orgId, asOf);
}

/** Sweep every org — the daily scheduler's entry point. */
async sweepAll(asOf: Date = new Date()): Promise<ApartmentStatusSweepResponse> {
const result = await this.sweep(undefined, asOf);
this.logger.log(
`Apartment status sweep: reverted=${result.reverted} considered=${result.considered}`,
);
return result;
}
}
Loading
Loading