api: carry notifications in the data export
The export mailed everything the user had logged but nothing the app had told them, so notifications now ship as their own CSV. It follows the existing descriptor path: a gatherer entry, a notificationTable with labels and the regimen relation, and a manifest line
Pedro Lucas Porcellis porcellis@eletrotupi.com · 11 days ago
60e277a93c467e6f0fff6c664ec0aae0eb2c4535 Browse filesParents
1b8d5d7Changed files
5
Additions
+118
Deletions
0
api/src/services/export/data.gatherer.ts
1 1
import {
2 2
Activity,
3 3
Appointment,
4 4
CareAction,
5 5
Insight,
6 6
MedicineLog,
7 7
MedicineRegimen,
8 8
Mood,
9 9
MoodComponent,
10 +
Notification,
10 11
SleepRecord,
11 12
Trigger,
12 13
TriggerMoodLink,
13 14
User,
14 15
} from "@prisma/client";
27 28
medicineLogs: MedicineLog[];
28 29
appointments: Appointment[];
29 30
activities: Activity[];
30 31
medicineRegimens: MedicineRegimen[];
31 32
insights: Insight[];
33 +
notifications: Notification[];
32 34
};
33 35
34 36
export async function gatherExportData(userId: number): Promise<ExportData> {
35 37
const user = await findUserById(userId);
36 38
44 46
triggers,
45 47
triggerMoodLinks,
46 48
careActions,
47 49
medicineRegimens,
48 50
insights,
51 +
notifications,
49 52
] = await Promise.all([
50 53
prisma.mood.findMany({
51 54
where: { userId },
52 55
include: { moodComponents: true },
53 56
orderBy: { moment: "asc" },
75 78
}),
76 79
prisma.insight.findMany({
77 80
where: { userId },
78 81
orderBy: { generatedAt: "asc" },
79 82
}),
83 +
prisma.notification.findMany({
84 +
where: { userId },
85 +
orderBy: { scheduledFor: "asc" },
86 +
}),
80 87
]);
81 88
82 89
return {
83 90
user,
84 91
moods,
96 103
activities: careActions.flatMap((action) =>
97 104
action.activity ? [action.activity] : [],
98 105
),
99 106
medicineRegimens,
100 107
insights,
108 +
notifications,
101 109
};
102 110
}
api/src/services/export/export.service.ts
1 1
import { serializeCsv } from "@app/services/export/csv.serializer";
2 2
import { gatherExportData } from "@app/services/export/data.gatherer";
3 3
import { buildManifest } from "@app/services/export/manifest.builder";
4 4
import {
5 5
activityTable,
8 8
insightTable,
9 9
medicineLogTable,
10 10
medicineRegimenTable,
11 11
moodComponentTable,
12 12
moodTable,
13 +
notificationTable,
13 14
profileTable,
14 15
sleepRecordTable,
15 16
triggerMoodLinkTable,
16 17
triggerTable,
17 18
} from "@app/services/export/table.descriptors";
30 31
medicineLogs,
31 32
appointments,
32 33
activities,
33 34
medicineRegimens,
34 35
insights,
36 +
notifications,
35 37
} = await gatherExportData(userId);
36 38
const generatedAt = new Date();
37 39
38 40
const files: ExportFile[] = [
39 41
serializeCsv(profileTable, [user]),
46 48
serializeCsv(medicineLogTable, medicineLogs),
47 49
serializeCsv(appointmentTable, appointments),
48 50
serializeCsv(activityTable, activities),
49 51
serializeCsv(medicineRegimenTable, medicineRegimens),
50 52
serializeCsv(insightTable, insights),
53 +
serializeCsv(notificationTable, notifications),
51 54
];
52 55
const manifest = buildManifest(files, generatedAt);
53 56
54 57
return {
55 58
filename: `nexo-dados-${generatedAt.toISOString().slice(0, 10)}.zip`,
56 59
buffer: await buildZip([...files, manifest]),
57 60
};
58 61
}
api/src/services/export/label.map.ts
1 1
import {
2 2
ActivityType,
3 3
AppointmentType,
4 4
BaseMoodOption,
5 5
CareActionType,
6 6
InsightPeriod,
7 7
InsightType,
8 8
IntensityLevel,
9 9
MedicinePeriodicity,
10 10
MoodComponentOption,
11 +
NotificationKind,
11 12
TriggerType,
12 13
} from "@prisma/client";
13 14
14 15
// Temporary copy of the labels the app shows. The app-wide map will eventually
15 16
// live with the i18n labels work; these are intentionally pt-BR only.
103 104
export const insightPeriodLabel: Record<InsightPeriod, string> = {
104 105
DAILY: "Diário",
105 106
WEEKLY: "Semanal",
106 107
MONTHLY: "Mensal",
107 108
};
109 +
110 +
export const notificationKindLabel: Record<NotificationKind, string> = {
111 +
DAILY_REMINDER: "Lembrete diário",
112 +
MEDICINE_REMINDER: "Lembrete de medicação",
113 +
INSIGHT_ALERT: "Alerta de indicador",
114 +
};
api/src/services/export/table.descriptors.ts
1 1
import {
2 2
Activity,
3 3
Appointment,
4 4
CareAction,
5 5
Insight,
6 6
MedicineLog,
7 7
MedicineRegimen,
8 8
Mood,
9 9
MoodComponent,
10 +
Notification,
10 11
SleepRecord,
11 12
Trigger,
12 13
TriggerMoodLink,
13 14
User,
14 15
} from "@prisma/client";
21 22
insightPeriodLabel,
22 23
insightTypeLabel,
23 24
intensityLabel,
24 25
medicinePeriodicityLabel,
25 26
moodComponentLabel,
27 +
notificationKindLabel,
26 28
triggerTypeLabel,
27 29
} from "@app/services/export/label.map";
28 30
import { CsvTable } from "@app/services/export/types";
29 31
30 32
const booleanLabel = (value: boolean): string => (value ? "Sim" : "Não");
206 208
{ header: "Início do período", value: (insight) => insight.periodStart },
207 209
{ header: "Fim do período", value: (insight) => insight.periodEnd },
208 210
{ header: "Gerado em", value: (insight) => insight.generatedAt },
209 211
],
210 212
};
213 +
214 +
export const notificationTable: CsvTable<Notification> = {
215 +
filename: "notifications.csv",
216 +
columns: [
217 +
{ header: "id", value: (notification) => notification.id },
218 +
{
219 +
header: "Tipo",
220 +
value: (notification) => notificationKindLabel[notification.kind],
221 +
},
222 +
{ header: "Título", value: (notification) => notification.title },
223 +
{ header: "Corpo", value: (notification) => notification.body },
224 +
{
225 +
header: "Agendada para",
226 +
value: (notification) => notification.scheduledFor,
227 +
},
228 +
{ header: "Expira em", value: (notification) => notification.expiresAt },
229 +
{ header: "Enviada em", value: (notification) => notification.pushedAt },
230 +
{ header: "regimen_id", value: (notification) => notification.regimenId },
231 +
],
232 +
};
api/tests/jobs/export-data.test.ts
1 1
import { describe, it, expect, afterAll, beforeEach, vi } from 'vitest'
2 2
import JSZip from 'jszip'
3 3
import { exportProcessor } from '@app/lib/queue/processors/exports'
4 4
import { ExportJobName } from '@app/lib/queue/types'
5 5
import { buildPrisma, cleanupTestDb, createTestUser } from '../test-helper'
70 70
'leia-me.md',
71 71
'medicine_logs.csv',
72 72
'medicine_regimens.csv',
73 73
'mood_components.csv',
74 74
'moods.csv',
75 +
'notifications.csv',
75 76
'profile.csv',
76 77
'sleep_records.csv',
77 78
'trigger_mood_links.csv',
78 79
'triggers.csv',
79 80
])
485 486
const unescaped = metadataCell.slice(1, -1).replace(/""/g, '"')
486 487
expect(JSON.parse(unescaped)).toEqual({ note: 'bom, "ótimo"' })
487 488
488 489
const manifest = await zip.file('leia-me.md')!.async('string')
489 490
expect(manifest).toContain('`insights.csv` — 2 registros')
491 +
})
492 +
493 +
it('exports notifications with pt-BR labels and the relation column', async () => {
494 +
const user = await createTestUser(db, { email: 'notifications@example.com' })
495 +
const regimen = await db.medicineRegimen.create({
496 +
data: {
497 +
userId: user.id,
498 +
name: 'Sertralina',
499 +
dosage: '50mg',
500 +
periodicity: 'DAILY',
501 +
scheduledAt: ['08:00'],
502 +
active: true,
503 +
},
504 +
})
505 +
await db.notification.create({
506 +
data: {
507 +
userId: user.id,
508 +
kind: 'MEDICINE_REMINDER',
509 +
title: 'Hora de tomar Sertralina',
510 +
body: '50mg',
511 +
scheduledFor: new Date('2026-03-05T08:00:00.000Z'),
512 +
expiresAt: new Date('2026-03-06T02:59:59.999Z'),
513 +
pushedAt: new Date('2026-03-05T08:00:01.000Z'),
514 +
regimenId: regimen.id,
515 +
},
516 +
})
517 +
await db.notification.create({
518 +
data: {
519 +
userId: user.id,
520 +
kind: 'DAILY_REMINDER',
521 +
title: 'Como você está?',
522 +
body: 'Registre seu humor para acompanhar sua saúde.',
523 +
scheduledFor: new Date('2026-03-04T22:00:00.000Z'),
524 +
expiresAt: new Date('2026-03-05T02:59:59.999Z'),
525 +
},
526 +
})
527 +
await db.notification.create({
528 +
data: {
529 +
userId: user.id,
530 +
kind: 'INSIGHT_ALERT',
531 +
title: 'Trabalho aparece muito',
532 +
body: 'Trabalho tem aparecido bastante nos seus gatilhos.',
533 +
scheduledFor: new Date('2026-03-06T12:00:00.000Z'),
534 +
},
535 +
})
536 +
537 +
await exportProcessor(buildJob({ userId: user.id }))
538 +
539 +
const { attachment } = await attachmentFromLastEmail()
540 +
const zip = await JSZip.loadAsync(attachment.content)
541 +
542 +
const csv = await zip.file('notifications.csv')!.async('string')
543 +
const lines = csvLines(csv)
544 +
expect(lines[0]).toContain('Tipo')
545 +
expect(lines[0]).toContain('Título')
546 +
expect(lines[0]).toContain('Corpo')
547 +
expect(lines[0]).toContain('Agendada para')
548 +
expect(lines[0]).toContain('Expira em')
549 +
expect(lines[0]).toContain('Enviada em')
550 +
expect(lines[0]).toContain('regimen_id')
551 +
552 +
// Every row ships, the expired reminder included
553 +
expect(lines).toHaveLength(4)
554 +
expect(csv).toContain('"Lembrete de medicação"')
555 +
expect(csv).toContain('"Lembrete diário"')
556 +
expect(csv).toContain('"Alerta de indicador"')
557 +
expect(csv).toContain('"Hora de tomar Sertralina"')
558 +
559 +
const header = lines[0].split(',').map((field) => field.replace(/"/g, ''))
560 +
const regimenIndex = header.indexOf('regimen_id')
561 +
const medicineRow = lines
562 +
.slice(1)
563 +
.find((row) => row.includes('"Lembrete de medicação"'))!
564 +
expect(Number(medicineRow.split(',')[regimenIndex])).toBe(regimen.id)
565 +
566 +
const manifest = await zip.file('leia-me.md')!.async('string')
567 +
expect(manifest).toContain('`notifications.csv` — 3 registros')
490 568
})
491 569
492 570
it('preserves commas, quotes and newlines inside a field', async () => {
493 571
const user = await createTestUser(db, {
494 572
email: 'quoted@example.com',
535 613
await expect(
536 614
exportProcessor({ name: 'export:unknown', data: {} } as any)
537 615
).rejects.toThrow('Unknown export job')
538 616
})
539 617
})