diff --git a/app/api/bot/callback/route.ts b/app/api/bot/callback/route.ts index 286a5a9..c72d3b7 100644 --- a/app/api/bot/callback/route.ts +++ b/app/api/bot/callback/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { markRdvConfirmed } from '@/lib/db/services/groupStatuses'; import { markAuditResponded, getStudentDisplayNames, getGroupMemberNames } from '@/lib/db/services/auditReports'; -import { sendTeamsFormsCard, buildReplyCard, buildBookedCard } from '@/lib/services/teams'; +import { sendTeamsFormsCard, buildReplyCard, buildBookedCard, buildReportReceivedCard } from '@/lib/services/teams'; import { getPromotionByEventId } from '@/lib/config/promotions'; export const dynamic = 'force-dynamic'; @@ -97,9 +97,19 @@ export async function POST(request: NextRequest) { // (a) Enregistrer la réponse de l'auditeur EN PREMIER (rapport d'audit) : // ainsi un échec Teams ne fait pas perdre la réponse ni la confirmation. + // Si la demande avait été escaladée (carte rouge « sans réponse » déjà + // postée), on garde l'info pour poster la carte verte correspondante. + let respondedAfterEscalation: { requestedAt: Date; escalatedAt: Date; projectName: string | null } | null = null; if (context.type === 'audit_report' && context.auditorLogin && context.groupId) { try { - await markAuditResponded(context.auditorLogin, context.groupId, status, comment || null); + const prev = await markAuditResponded(context.auditorLogin, context.groupId, status, comment || null); + if (prev?.escalatedAt) { + respondedAfterEscalation = { + requestedAt: prev.requestedAt, + escalatedAt: prev.escalatedAt, + projectName: prev.projectName, + }; + } } catch (e) { console.error('[bot/callback] markAuditResponded échec:', e); } @@ -134,6 +144,25 @@ export async function POST(request: NextRequest) { console.error('[bot/callback] sendTeamsFormsCard (reply) échec:', e); } + // (c) Carte verte « rapport reçu après relance » : pendant de la carte + // rouge d'escalade, sur le même canal — un ⚠️ sans ✅ = toujours en + // attente. Non bloquant. + if (respondedAfterEscalation && context.auditorLogin) { + try { + await sendTeamsFormsCard( + buildReportReceivedCard({ + auditorLogin: context.auditorLogin, + projectName: respondedAfterEscalation.projectName ?? context.projectName, + requestedAt: respondedAfterEscalation.requestedAt, + escalatedAt: respondedAfterEscalation.escalatedAt, + status, + }), + ); + } catch (e) { + console.error('[bot/callback] sendTeamsFormsCard (report received) échec:', e); + } + } + return NextResponse.json({ ok: true }); } diff --git a/app/api/cron/auto-escalate-audit-reports/route.ts b/app/api/cron/auto-escalate-audit-reports/route.ts index 02e0286..77abc68 100644 --- a/app/api/cron/auto-escalate-audit-reports/route.ts +++ b/app/api/cron/auto-escalate-audit-reports/route.ts @@ -1,11 +1,16 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAuditRequestsToEscalate, + getEscalatedUnanswered, markAuditEscalated, buildAuditReportReminderMessage, getGroupMemberNames, } from '@/lib/db/services/auditReports'; -import { sendTeamsFormsCard, buildEscalationCard } from '@/lib/services/teams'; +import { + sendTeamsFormsCard, + buildEscalationCard, + buildEscalatedUnansweredRecapCard, +} from '@/lib/services/teams'; import { getDiscordIdByLogin } from '@/lib/db/services/discordUsers'; import { sendDiscordDM } from '@/lib/services/discord'; import { notifyViaBot } from '@/lib/services/bot-notify'; @@ -39,6 +44,10 @@ export async function GET(request: NextRequest) { const suiviUrl = `${BASE_URL}/code-reviews/audit-reports`; const requests = await getAuditRequestsToEscalate(); + // Récap « toujours sans réponse après relance » : escalades d'un run + // précédent (> 20 h) toujours silencieuses. Le cron tourne 1×/jour (8h30 + // lun-sam via dashboard-daily-cron.sh) → au plus une carte récap par jour. + const stillUnanswered = await getEscalatedUnanswered(); if (dry) { return NextResponse.json({ @@ -51,6 +60,11 @@ export async function GET(request: NextRequest) { projectName: r.projectName, requestedAt: r.requestedAt, })), + stillUnanswered: stillUnanswered.map((r) => ({ + auditorLogin: r.auditorLogin, + projectName: r.projectName, + escalatedAt: r.escalatedAt, + })), }); } @@ -112,11 +126,20 @@ export async function GET(request: NextRequest) { } } + // Carte récap (une seule, best-effort) si des relances restent sans réponse. + let recapSent = false; + if (stillUnanswered.length > 0) { + recapSent = await sendTeamsFormsCard( + buildEscalatedUnansweredRecapCard({ items: stillUnanswered, suiviUrl }), + ); + } + return NextResponse.json({ success: true, checked: requests.length, escalated, errors, + recap: { count: stillUnanswered.length, sent: recapSent }, }); } diff --git a/lib/db/services/auditReports.ts b/lib/db/services/auditReports.ts index 511133e..8c9ca80 100644 --- a/lib/db/services/auditReports.ts +++ b/lib/db/services/auditReports.ts @@ -2,7 +2,7 @@ import { db } from '../config'; import { students } from '../schema/students'; import { discordUsers } from '../schema/discordUsers'; import { auditReportRequests } from '../schema/auditReportRequests'; -import { eq, and, inArray, isNull } from 'drizzle-orm'; +import { eq, and, inArray, isNull, isNotNull, lt } from 'drizzle-orm'; import { zone01Graphql } from '@/lib/services/zone01-graphql'; import { getAllPromotions } from '@/lib/config/promotions'; import { getMandatoryProjectNames } from '@/lib/config/projects'; @@ -265,8 +265,8 @@ export async function markAuditResponded( groupId: string, status: string, comment: string | null, -): Promise { - await db +): Promise<{ requestedAt: Date; escalatedAt: Date | null; projectName: string | null } | null> { + const rows = await db .update(auditReportRequests) .set({ respondedAt: new Date(), @@ -278,7 +278,13 @@ export async function markAuditResponded( eq(auditReportRequests.auditorLogin, auditorLogin), eq(auditReportRequests.groupId, groupId), ), - ); + ) + .returning({ + requestedAt: auditReportRequests.requestedAt, + escalatedAt: auditReportRequests.escalatedAt, + projectName: auditReportRequests.projectName, + }); + return rows[0] ?? null; } export interface AuditRequestToEscalate { @@ -375,4 +381,44 @@ export async function markAuditEscalated(id: number): Promise { .where(eq(auditReportRequests.id, id)); } +export interface EscalatedUnansweredItem { + auditorLogin: string; + projectName: string | null; + requestedAt: Date; + escalatedAt: Date; +} + +/** + * Récap quotidien — demandes déjà escaladées (carte rouge envoyée) et TOUJOURS + * sans réponse. On exclut les escalades de moins de `minAgeHours` (celles du + * run courant : leur carte rouge vient d'être postée, inutile de les relister). + * Même filtre « projets obligatoires » que l'escalade. + */ +export async function getEscalatedUnanswered( + minAgeHours = 20, +): Promise { + const cutoff = new Date(Date.now() - minAgeHours * 3600_000); + const rows = await db + .select({ + auditorLogin: auditReportRequests.auditorLogin, + projectName: auditReportRequests.projectName, + requestedAt: auditReportRequests.requestedAt, + escalatedAt: auditReportRequests.escalatedAt, + }) + .from(auditReportRequests) + .where( + and( + isNull(auditReportRequests.respondedAt), + isNotNull(auditReportRequests.escalatedAt), + lt(auditReportRequests.escalatedAt, cutoff), + ), + ); + + const mandatory = await getMandatoryProjectNames(); + return rows + .filter((r) => mandatory.has((r.projectName ?? '').toLowerCase())) + .map((r) => ({ ...r, escalatedAt: r.escalatedAt! })) + .sort((a, b) => a.escalatedAt.getTime() - b.escalatedAt.getTime()); +} + export { SINCE_DAYS_DEFAULT, ESCALATE_AFTER_BUSINESS_DAYS }; diff --git a/lib/services/teams.ts b/lib/services/teams.ts index 80fbf20..622b90a 100644 --- a/lib/services/teams.ts +++ b/lib/services/teams.ts @@ -238,6 +238,58 @@ export function buildEscalationCard(opts: { return buildAdaptiveCard(body, actions); } +/** + * Carte verte « rapport reçu après relance » (pendant de buildEscalationCard). + * Postée sur le même canal que la carte rouge : dans le fil, un ⚠️ sans ✅ + * correspondant = auditeur toujours silencieux. + */ +export function buildReportReceivedCard(opts: { + auditorLogin: string; + projectName?: string | null; + requestedAt: Date; + escalatedAt: Date; + status?: string | null; +}): object { + const facts = [ + { title: 'Auditeur', value: opts.auditorLogin }, + ...(opts.projectName ? [{ title: 'Projet', value: opts.projectName }] : []), + { title: 'Demandé le', value: opts.requestedAt.toLocaleDateString('fr-FR') }, + { title: 'Relancé le', value: opts.escalatedAt.toLocaleDateString('fr-FR') }, + ...(opts.status ? [{ title: 'Statut', value: opts.status }] : []), + ]; + const body: AdaptiveElement[] = [ + textBlock('✅ Rapport d\'audit reçu (après relance)', { size: 'Large', weight: 'Bolder', color: 'Good' }), + textBlock('L\'auditeur a répondu suite à la relance.', { isSubtle: true }), + factSet(facts), + ]; + return buildAdaptiveCard(body); +} + +/** + * Carte récap « toujours sans réponse après relance » : liste les auditeurs + * déjà relancés (carte rouge envoyée) et toujours silencieux. Remplace le + * pointage manuel du canal. + */ +export function buildEscalatedUnansweredRecapCard(opts: { + items: { auditorLogin: string; projectName: string | null; escalatedAt: Date }[]; + suiviUrl?: string; +}): object { + const body: AdaptiveElement[] = [ + textBlock('⚠️ Toujours sans réponse après relance', { size: 'Large', weight: 'Bolder', color: 'Attention' }), + textBlock( + `${opts.items.length} rapport(s) d'audit toujours en attente malgré la relance.`, + { isSubtle: true }, + ), + ...opts.items.map((i) => + textBlock( + `• **${i.auditorLogin}** — ${i.projectName ?? '—'} (relancé le ${i.escalatedAt.toLocaleDateString('fr-FR')})`, + ), + ), + ]; + const actions = opts.suiviUrl ? [openUrlAction('Voir les rapports d\'audit', opts.suiviUrl)] : []; + return buildAdaptiveCard(body, actions); +} + /** Raccourci texte simple (titre + lignes), pour les notifications ponctuelles. */ export async function sendTeamsMessage(title: string, lines: string[]): Promise { const card = buildAdaptiveCard([