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
33 changes: 31 additions & 2 deletions app/api/bot/callback/route.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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 });
}

Expand Down
25 changes: 24 additions & 1 deletion app/api/cron/auto-escalate-audit-reports/route.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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({
Expand All @@ -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,
})),
});
}

Expand Down Expand Up @@ -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 },
});
}

Expand Down
54 changes: 50 additions & 4 deletions lib/db/services/auditReports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -265,8 +265,8 @@ export async function markAuditResponded(
groupId: string,
status: string,
comment: string | null,
): Promise<void> {
await db
): Promise<{ requestedAt: Date; escalatedAt: Date | null; projectName: string | null } | null> {
const rows = await db
.update(auditReportRequests)
.set({
respondedAt: new Date(),
Expand All @@ -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 {
Expand Down Expand Up @@ -375,4 +381,44 @@ export async function markAuditEscalated(id: number): Promise<void> {
.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<EscalatedUnansweredItem[]> {
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 };
52 changes: 52 additions & 0 deletions lib/services/teams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
const card = buildAdaptiveCard([
Expand Down
Loading