/opt/mawid/apps/api/src/summary
Edit: /opt/mawid/apps/api/src/summary/weekly-summary.worker.ts (3683B)
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Queue, Worker } from 'bullmq';
import IORedis from 'ioredis';
import { t, type Language } from '@mawid/shared';
import { utcToWallTime } from '../agent/tz';
import { PrismaService } from '../prisma/prisma.service';
import { OwnerNotifierService } from '../whatsapp/owner-notifier.service';
import { SummaryService } from './summary.service';
const QUEUE_NAME = 'weekly-summary';
export const SUMMARY_AUDIT_ACTION = 'owner:weekly_summary';
/**
* Sends the weekly summary to each clinic owner on Monday 09:00 *clinic time*
* (PROJECT_PLAN Phase 6 item 5). An hourly BullMQ repeatable job ticks; the
* handler picks clinics whose local time is Monday 09:xx and that haven't been
* summarized in the last 20h (idempotent across restarts/retries).
*/
@Injectable()
export class WeeklySummaryWorker implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(WeeklySummaryWorker.name);
private worker?: Worker;
private queue?: Queue;
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService,
private readonly summary: SummaryService,
private readonly ownerNotifier: OwnerNotifierService,
) {}
async onModuleInit() {
const connection = () =>
new IORedis(this.config.getOrThrow
('REDIS_URL'), { maxRetriesPerRequest: null });
this.queue = new Queue(QUEUE_NAME, { connection: connection() });
await this.queue.upsertJobScheduler('weekly-summary-tick', { pattern: '0 * * * *' });
this.worker = new Worker(QUEUE_NAME, () => this.tick(), {
connection: connection(),
concurrency: 1,
});
this.worker.on('failed', (_job, err) => this.logger.warn(`tick failed: ${err.message}`));
}
async onModuleDestroy() {
await this.worker?.close();
await this.queue?.close();
}
async tick(now: Date = new Date()): Promise {
const clinics = await this.prisma.clinic.findMany();
for (const clinic of clinics) {
if (!this.isSummaryHour(clinic.timezone, now)) continue;
if (await this.alreadySentRecently(clinic.id, now)) continue;
await this.sendSummary(clinic.id, clinic.defaultLanguage, now);
}
}
isSummaryHour(timezone: string, now: Date): boolean {
const local = utcToWallTime(timezone, now);
return local.weekday === 'mon' && local.hour === 9;
}
private async alreadySentRecently(clinicId: string, now: Date): Promise {
const recent = await this.prisma.auditLog.findFirst({
where: {
clinicId,
action: SUMMARY_AUDIT_ACTION,
createdAt: { gte: new Date(now.getTime() - 20 * 3600_000) },
},
});
return recent !== null;
}
async sendSummary(clinicId: string, language: string, now: Date): Promise {
const numbers = await this.summary.computeWeekly(clinicId, now);
const lang: Language = (['ar', 'tr', 'en'] as const).includes(language as Language)
? (language as Language)
: 'en';
await this.ownerNotifier.sendText(
clinicId,
t('weeklySummaryOwner', lang, {
appointments: String(numbers.appointments),
cancellations: String(numbers.cancellations),
noShows: String(numbers.noShows),
confirmedAfterReminder: String(numbers.confirmedAfterReminder),
refilledSlots: String(numbers.refilledSlots),
}),
);
await this.prisma.auditLog.create({
data: {
clinicId,
actor: 'system',
action: SUMMARY_AUDIT_ACTION,
meta: JSON.parse(JSON.stringify(numbers)),
},
});
}
}