/opt/mawid/apps/api/src/summary
NameSizeModeActions
summary.controller.ts3910644editdlrm
summary.e2e.spec.ts57520644editdlrm
summary.module.ts4320644editdlrm
summary.service.ts17800644editdlrm
weekly-summary.worker.ts36830644editdlrm
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)), }, }); } }