Skip to content

Commit 1993720

Browse files
committed
fix(jobs): correct mission event type when mission deleted is null
1 parent 7f10060 commit 1993720

4 files changed

Lines changed: 267 additions & 1 deletion

File tree

api/scripts/README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,19 @@ Ce répertoire contient des scripts de maintenance/migration pour l’API. Les s
5454
- Usage: Supprime les organisations en doublon qui n'ont plus de mission rattachée. Par defaut, suppression directe.
5555
- Options: `--dry-run` pour simuler, `--title` pour cibler un nom précis.
5656

57+
- **fix-mission-event-undelete-type.ts**
58+
59+
- Exécution:
60+
- Exécution réelle (par défaut): `npx ts-node scripts/fix-mission-event-undelete-type.ts`
61+
- Preview: `npx ts-node scripts/fix-mission-event-undelete-type.ts --dry-run`
62+
- Exécution réelle avec batch: `npx ts-node scripts/fix-mission-event-undelete-type.ts --batch 1000`
63+
- Usage: Corrige les lignes `mission_event` incohérentes où `type='delete'` alors que `changes.deletedAt.current = null` et `changes.deletedAt.previous` est non nul. Ces lignes sont passées en `type='update'`.
64+
- Options:
65+
- `--dry-run` : simule uniquement, sans mise à jour en base.
66+
- `--sample <taille>` : taille de l'échantillon affiché avant traitement (défaut: 20).
67+
- `--batch <taille>` : taille de lot pour le traitement par pagination/cursor sur `id` (défaut: 1000).
68+
- Prérequis: Nécessite l'accès à Postgres `core`.
69+
5770
- **fixtures/**
5871

5972
- Scripts d’initialisation/d’échantillonnage de données (voir `scripts/fixtures/README.md`), dont:
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/**
2+
* Corrige les mission_event incoherents:
3+
* - type = 'delete'
4+
* - changes.deletedAt.current = null
5+
* - changes.deletedAt.previous non null
6+
*
7+
* Dans ce cas, le type attendu est 'update' (reactivation / undelete).
8+
*
9+
* Execution:
10+
* npx ts-node scripts/fix-mission-event-undelete-type.ts
11+
* npx ts-node scripts/fix-mission-event-undelete-type.ts --dry-run
12+
* npx ts-node scripts/fix-mission-event-undelete-type.ts --dry-run --sample 50
13+
* npx ts-node scripts/fix-mission-event-undelete-type.ts --batch 1000
14+
*
15+
* Comportement:
16+
* - par defaut: applique l'update en base
17+
* - avec --dry-run: preview (aucune ecriture)
18+
*/
19+
import dotenv from "dotenv";
20+
dotenv.config();
21+
22+
import { prismaCore } from "../src/db/postgres";
23+
24+
type CandidateRow = {
25+
id: string;
26+
mission_id: string;
27+
type: string;
28+
created_at: Date;
29+
updated_at: Date;
30+
deleted_at_previous: string | null;
31+
};
32+
33+
const parseFlagValue = (flag: string): string | null => {
34+
const index = process.argv.indexOf(flag);
35+
if (index === -1) {
36+
return null;
37+
}
38+
return process.argv[index + 1] ?? null;
39+
};
40+
41+
const isDryRun = process.argv.includes("--dry-run");
42+
const sampleSize = Math.max(Number(parseFlagValue("--sample") ?? "20"), 1);
43+
const batchSize = Math.max(Number(parseFlagValue("--batch") ?? "1000"), 1);
44+
45+
const run = async () => {
46+
const startedAt = new Date();
47+
console.log(`[MissionEventFix] Started at ${startedAt.toISOString()}`);
48+
console.log(`[MissionEventFix] Mode: ${isDryRun ? "dry-run" : "execute"}`);
49+
console.log(`[MissionEventFix] Batch size: ${batchSize}`);
50+
51+
await prismaCore.$connect();
52+
53+
const [countRow] = await prismaCore.$queryRaw<{ total: number }[]>`
54+
select count(*)::int as total
55+
from "mission_event" me
56+
where
57+
me."type" = 'delete'
58+
and jsonb_typeof(me."changes") = 'object'
59+
and me."changes" ? 'deletedAt'
60+
and jsonb_typeof(me."changes"->'deletedAt') = 'object'
61+
and (me."changes"->'deletedAt'->'current') = 'null'::jsonb
62+
and (me."changes"->'deletedAt'->>'previous') is not null
63+
`;
64+
65+
const total = countRow?.total ?? 0;
66+
console.log(`[MissionEventFix] Lignes candidates: ${total}`);
67+
68+
if (!total) {
69+
console.log("[MissionEventFix] Rien a corriger.");
70+
return;
71+
}
72+
73+
const sample = await prismaCore.$queryRaw<CandidateRow[]>`
74+
select
75+
me."id",
76+
me."mission_id",
77+
me."type",
78+
me."created_at",
79+
me."updated_at",
80+
me."changes"->'deletedAt'->>'previous' as deleted_at_previous
81+
from "mission_event" me
82+
where
83+
me."type" = 'delete'
84+
and jsonb_typeof(me."changes") = 'object'
85+
and me."changes" ? 'deletedAt'
86+
and jsonb_typeof(me."changes"->'deletedAt') = 'object'
87+
and (me."changes"->'deletedAt'->'current') = 'null'::jsonb
88+
and (me."changes"->'deletedAt'->>'previous') is not null
89+
order by me."updated_at" desc
90+
limit ${sampleSize}
91+
`;
92+
93+
console.log(`[MissionEventFix] Echantillon (max ${sampleSize}):`);
94+
for (const row of sample) {
95+
console.log(
96+
`- id=${row.id} mission_id=${row.mission_id} type=${row.type} previous=${row.deleted_at_previous} updated_at=${row.updated_at.toISOString()}`
97+
);
98+
}
99+
100+
if (isDryRun) {
101+
console.log("[MissionEventFix] Dry-run uniquement. Relancer sans --dry-run pour appliquer.");
102+
return;
103+
}
104+
105+
let updatedTotal = 0;
106+
let lastSeenId: string | null = null;
107+
108+
while (true) {
109+
const batch: { id: string }[] = await prismaCore.$queryRaw<{ id: string }[]>`
110+
select me."id"
111+
from "mission_event" me
112+
where
113+
me."type" = 'delete'
114+
and jsonb_typeof(me."changes") = 'object'
115+
and me."changes" ? 'deletedAt'
116+
and jsonb_typeof(me."changes"->'deletedAt') = 'object'
117+
and (me."changes"->'deletedAt'->'current') = 'null'::jsonb
118+
and (me."changes"->'deletedAt'->>'previous') is not null
119+
and (${lastSeenId}::uuid is null or me."id" > ${lastSeenId}::uuid)
120+
order by me."id" asc
121+
limit ${batchSize}
122+
`;
123+
124+
if (!batch.length) {
125+
break;
126+
}
127+
128+
const ids: string[] = batch.map((row: { id: string }) => row.id);
129+
lastSeenId = ids[ids.length - 1] ?? lastSeenId;
130+
131+
const result = await prismaCore.missionEvent.updateMany({
132+
where: { id: { in: ids } },
133+
data: { type: "update" },
134+
});
135+
updatedTotal += result.count;
136+
137+
console.log(`[MissionEventFix] Batch corrige: ${result.count} (total: ${updatedTotal})`);
138+
}
139+
140+
console.log(`[MissionEventFix] Lignes corrigees: ${updatedTotal}`);
141+
};
142+
143+
const shutdown = async (exitCode: number) => {
144+
await prismaCore.$disconnect().catch(() => undefined);
145+
process.exit(exitCode);
146+
};
147+
148+
run()
149+
.then(async () => {
150+
await shutdown(0);
151+
})
152+
.catch(async (error) => {
153+
console.error("[MissionEventFix] Failed:", error);
154+
await shutdown(1);
155+
});
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const mocks = vi.hoisted(() => ({
4+
findMissionsBy: vi.fn(),
5+
createMission: vi.fn(),
6+
updateMission: vi.fn(),
7+
createMissionEvents: vi.fn(),
8+
findPublisherOrganizations: vi.fn(),
9+
buildPublisherOrganizationPayload: vi.fn(),
10+
isPublisherOrganizationUpToDate: vi.fn(),
11+
upsertPublisherOrganizationPayload: vi.fn(),
12+
getMissionChanges: vi.fn(),
13+
}));
14+
15+
vi.mock("../../../../services/mission", () => ({
16+
missionService: {
17+
findMissionsBy: mocks.findMissionsBy,
18+
create: mocks.createMission,
19+
update: mocks.updateMission,
20+
},
21+
}));
22+
23+
vi.mock("../../../../services/mission-event", () => ({
24+
missionEventService: {
25+
createMissionEvents: mocks.createMissionEvents,
26+
},
27+
}));
28+
29+
vi.mock("../../../../repositories/publisher-organization", () => ({
30+
publisherOrganizationRepository: {
31+
findMany: mocks.findPublisherOrganizations,
32+
},
33+
}));
34+
35+
vi.mock("../organization", () => ({
36+
buildPublisherOrganizationPayload: mocks.buildPublisherOrganizationPayload,
37+
isPublisherOrganizationUpToDate: mocks.isPublisherOrganizationUpToDate,
38+
upsertPublisherOrganizationPayload: mocks.upsertPublisherOrganizationPayload,
39+
}));
40+
41+
vi.mock("../../../../utils/mission", () => ({
42+
EVENT_TYPES: {
43+
CREATE: "create",
44+
UPDATE: "update",
45+
DELETE: "delete",
46+
},
47+
getMissionChanges: mocks.getMissionChanges,
48+
}));
49+
50+
import { bulkDB } from "../db";
51+
52+
describe("bulkDB mission event type", () => {
53+
beforeEach(() => {
54+
vi.clearAllMocks();
55+
mocks.buildPublisherOrganizationPayload.mockReturnValue(null);
56+
mocks.isPublisherOrganizationUpToDate.mockReturnValue(true);
57+
mocks.findPublisherOrganizations.mockResolvedValue([]);
58+
mocks.createMissionEvents.mockResolvedValue(1);
59+
});
60+
61+
it("tracks undelete (deletedAt -> null) as update", async () => {
62+
mocks.findMissionsBy.mockResolvedValue([
63+
{
64+
id: "mission-1",
65+
clientId: "client-1",
66+
organizationClientId: null,
67+
},
68+
]);
69+
mocks.updateMission.mockResolvedValue({
70+
id: "mission-1",
71+
clientId: "client-1",
72+
organizationClientId: null,
73+
deletedAt: null,
74+
});
75+
mocks.getMissionChanges.mockReturnValue({
76+
deletedAt: {
77+
previous: "2026-01-05T05:15:09.598Z",
78+
current: null,
79+
},
80+
});
81+
82+
const result = await bulkDB(
83+
[{ clientId: "client-1", organizationClientId: null } as any],
84+
{ id: "publisher-1", name: "Publisher 1" } as any,
85+
{ createdCount: 0, updatedCount: 0, deletedCount: 0 } as any,
86+
{ recordMissionEvents: true }
87+
);
88+
89+
expect(result).toBe(true);
90+
expect(mocks.createMissionEvents).toHaveBeenCalledWith([
91+
expect.objectContaining({
92+
missionId: "mission-1",
93+
type: "update",
94+
}),
95+
]);
96+
});
97+
});

api/src/jobs/import-missions/utils/db.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,10 @@ export const bulkDB = async (
114114
if (changes) {
115115
const updated = await missionService.update(current.id, missionInput as MissionUpdatePatch);
116116
existingMap.set(missionInput.clientId, updated);
117+
const isDeletion = changes.deletedAt ? changes.deletedAt.current !== null : false;
117118
missionEvents.push({
118119
missionId: current.id,
119-
type: changes.deletedAt?.current === null ? EVENT_TYPES.DELETE : EVENT_TYPES.UPDATE,
120+
type: isDeletion ? EVENT_TYPES.DELETE : EVENT_TYPES.UPDATE,
120121
changes,
121122
});
122123
importDoc.updatedCount += 1;

0 commit comments

Comments
 (0)