api/data-export: include insights in the archive
The export covered every other record but dropped the generated insights, so an archive left out the observations the app had produced from that very data. The worker now gathers them into insights.csv, translating the type and period labels and keeping the metadata as JSON
Pedro Lucas Porcellis porcellis@eletrotupi.com · 23 days ago
1f2f0a154b75b7aab487b802e714150fc0eab4f5 Browse filesParents
1230d18Changed files
5
Additions
+111
Deletions
0
api/src/services/export/data.gatherer.ts
1 1
import {
2 2
Activity,
3 3
Appointment,
4 4
CareAction,
5 +
Insight,
5 6
MedicineLog,
6 7
MedicineRegimen,
7 8
Mood,
8 9
MoodComponent,
9 10
SleepRecord,
25 26
careActions: CareAction[];
26 27
medicineLogs: MedicineLog[];
27 28
appointments: Appointment[];
28 29
activities: Activity[];
29 30
medicineRegimens: MedicineRegimen[];
31 +
insights: Insight[];
30 32
};
31 33
32 34
export async function gatherExportData(userId: number): Promise<ExportData> {
33 35
const user = await findUserById(userId);
34 36
41 43
sleepRecords,
42 44
triggers,
43 45
triggerMoodLinks,
44 46
careActions,
45 47
medicineRegimens,
48 +
insights,
46 49
] = await Promise.all([
47 50
prisma.mood.findMany({
48 51
where: { userId },
49 52
include: { moodComponents: true },
50 53
orderBy: { moment: "asc" },
68 71
}),
69 72
prisma.medicineRegimen.findMany({
70 73
where: { userId },
71 74
orderBy: { createdAt: "asc" },
72 75
}),
76 +
prisma.insight.findMany({
77 +
where: { userId },
78 +
orderBy: { generatedAt: "asc" },
79 +
}),
73 80
]);
74 81
75 82
return {
76 83
user,
77 84
moods,
88 95
),
89 96
activities: careActions.flatMap((action) =>
90 97
action.activity ? [action.activity] : [],
91 98
),
92 99
medicineRegimens,
100 +
insights,
93 101
};
94 102
}
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,
6 6
appointmentTable,
7 7
careActionTable,
8 +
insightTable,
8 9
medicineLogTable,
9 10
medicineRegimenTable,
10 11
moodComponentTable,
11 12
moodTable,
12 13
profileTable,
28 29
careActions,
29 30
medicineLogs,
30 31
appointments,
31 32
activities,
32 33
medicineRegimens,
34 +
insights,
33 35
} = await gatherExportData(userId);
34 36
const generatedAt = new Date();
35 37
36 38
const files: ExportFile[] = [
37 39
serializeCsv(profileTable, [user]),
43 45
serializeCsv(careActionTable, careActions),
44 46
serializeCsv(medicineLogTable, medicineLogs),
45 47
serializeCsv(appointmentTable, appointments),
46 48
serializeCsv(activityTable, activities),
47 49
serializeCsv(medicineRegimenTable, medicineRegimens),
50 +
serializeCsv(insightTable, insights),
48 51
];
49 52
const manifest = buildManifest(files, generatedAt);
50 53
51 54
return {
52 55
filename: `nexo-dados-${generatedAt.toISOString().slice(0, 10)}.zip`,
53 56
buffer: await buildZip([...files, manifest]),
54 57
};
55 58
}
api/src/services/export/label.map.ts
1 1
import {
2 2
ActivityType,
3 3
AppointmentType,
4 4
BaseMoodOption,
5 5
CareActionType,
6 +
InsightPeriod,
7 +
InsightType,
6 8
IntensityLevel,
7 9
MedicinePeriodicity,
8 10
MoodComponentOption,
9 11
TriggerType,
10 12
} from "@prisma/client";
85 87
THREE_TIMES_DAILY: "3x ao dia",
86 88
WEEKLY: "Semanal",
87 89
BIWEEKLY: "Quinzenal",
88 90
MONTHLY: "Mensal",
89 91
};
92 +
93 +
export const insightTypeLabel: Record<InsightType, string> = {
94 +
MOOD_TREND: "Tendência de Humor",
95 +
ENERGY_SLEEP_CORRELATION: "Sono e Energia",
96 +
TRIGGER_PATTERN: "Padrão de Gatilho",
97 +
WEEKLY_SUMMARY: "Resumo Semanal",
98 +
STREAK: "Sequência",
99 +
DAILY_ENERGY: "Energia do Dia",
100 +
DAILY_SLEEP: "Sono",
101 +
};
102 +
103 +
export const insightPeriodLabel: Record<InsightPeriod, string> = {
104 +
DAILY: "Diário",
105 +
WEEKLY: "Semanal",
106 +
MONTHLY: "Mensal",
107 +
};
api/src/services/export/table.descriptors.ts
1 1
import {
2 2
Activity,
3 3
Appointment,
4 4
CareAction,
5 +
Insight,
5 6
MedicineLog,
6 7
MedicineRegimen,
7 8
Mood,
8 9
MoodComponent,
9 10
SleepRecord,
15 16
import {
16 17
activityCategoryLabel,
17 18
appointmentTypeLabel,
18 19
baseMoodLabel,
19 20
careActionTypeLabel,
21 +
insightPeriodLabel,
22 +
insightTypeLabel,
20 23
intensityLabel,
21 24
medicinePeriodicityLabel,
22 25
moodComponentLabel,
23 26
triggerTypeLabel,
24 27
} from "@app/services/export/label.map";
25 28
import { CsvTable } from "@app/services/export/types";
26 29
27 30
const booleanLabel = (value: boolean): string => (value ? "Sim" : "Não");
31 +
32 +
const jsonValue = (value: Insight["metadata"]): string | null =>
33 +
value === null ? null : JSON.stringify(value);
28 34
29 35
// Sleep records carry a day-granular date, so they drop the time and offset.
30 36
const dateOnly = (value: Date): string => value.toISOString().slice(0, 10);
31 37
32 38
export const profileTable: CsvTable<User> = {
181 187
{ header: "Ativo", value: (regimen) => booleanLabel(regimen.active) },
182 188
{ header: "Criado em", value: (regimen) => regimen.createdAt },
183 189
{ header: "Atualizado em", value: (regimen) => regimen.updatedAt },
184 190
],
185 191
};
192 +
193 +
export const insightTable: CsvTable<Insight> = {
194 +
filename: "insights.csv",
195 +
columns: [
196 +
{ header: "id", value: (insight) => insight.id },
197 +
{ header: "Tipo", value: (insight) => insightTypeLabel[insight.type] },
198 +
{ header: "Período", value: (insight) => insightPeriodLabel[insight.period] },
199 +
{ header: "Título", value: (insight) => insight.title },
200 +
{ header: "Corpo", value: (insight) => insight.body },
201 +
{ header: "Metadados", value: (insight) => jsonValue(insight.metadata) },
202 +
{ header: "Início do período", value: (insight) => insight.periodStart },
203 +
{ header: "Fim do período", value: (insight) => insight.periodEnd },
204 +
{ header: "Gerado em", value: (insight) => insight.generatedAt },
205 +
],
206 +
};
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'
64 64
const zip = await JSZip.loadAsync(attachment.content)
65 65
expect(Object.keys(zip.files).sort()).toEqual([
66 66
'activities.csv',
67 67
'appointments.csv',
68 68
'care_actions.csv',
69 +
'insights.csv',
69 70
'leia-me.md',
70 71
'medicine_logs.csv',
71 72
'medicine_regimens.csv',
72 73
'mood_components.csv',
73 74
'moods.csv',
423 424
expect(manifest).toContain('`care_actions.csv` — 3 registros')
424 425
expect(manifest).toContain('`medicine_logs.csv` — 1 registro')
425 426
expect(manifest).toContain('`appointments.csv` — 1 registro')
426 427
expect(manifest).toContain('`activities.csv` — 1 registro')
427 428
expect(manifest).toContain('`medicine_regimens.csv` — 1 registro')
429 +
})
430 +
431 +
it('exports insights with pt-BR labels and JSON metadata', async () => {
432 +
const user = await createTestUser(db, { email: 'insights@example.com' })
433 +
await db.insight.create({
434 +
data: {
435 +
userId: user.id,
436 +
type: 'WEEKLY_SUMMARY',
437 +
period: 'WEEKLY',
438 +
title: 'Sua semana',
439 +
body: 'Você registrou sete dias.',
440 +
metadata: { note: 'bom, "ótimo"' },
441 +
generatedAt: new Date('2026-03-08T09:00:00.000Z'),
442 +
periodStart: new Date('2026-03-01T00:00:00.000Z'),
443 +
periodEnd: new Date('2026-03-07T23:59:59.000Z'),
444 +
},
445 +
})
446 +
await db.insight.create({
447 +
data: {
448 +
userId: user.id,
449 +
type: 'STREAK',
450 +
period: 'DAILY',
451 +
title: 'Sequência de 3 dias',
452 +
body: 'Continue assim!',
453 +
generatedAt: new Date('2026-03-09T09:00:00.000Z'),
454 +
periodStart: new Date('2026-03-09T00:00:00.000Z'),
455 +
periodEnd: new Date('2026-03-09T23:59:59.000Z'),
456 +
},
457 +
})
458 +
459 +
await exportProcessor(buildJob({ userId: user.id }))
460 +
461 +
const { attachment } = await attachmentFromLastEmail()
462 +
const zip = await JSZip.loadAsync(attachment.content)
463 +
464 +
const insightsCsv = await zip.file('insights.csv')!.async('string')
465 +
const lines = csvLines(insightsCsv)
466 +
expect(lines[0]).toContain('Tipo')
467 +
expect(lines[0]).toContain('Período')
468 +
expect(lines[0]).toContain('Título')
469 +
expect(lines[0]).toContain('Corpo')
470 +
expect(lines[0]).toContain('Metadados')
471 +
expect(lines[0]).toContain('Início do período')
472 +
expect(lines[0]).toContain('Fim do período')
473 +
expect(lines[0]).toContain('Gerado em')
474 +
expect(lines).toHaveLength(3)
475 +
476 +
expect(insightsCsv).toContain('"Resumo Semanal"')
477 +
expect(insightsCsv).toContain('"Semanal"')
478 +
expect(insightsCsv).toContain('"Sequência"')
479 +
expect(insightsCsv).toContain('"Diário"')
480 +
481 +
// The JSON travels as one quoted cell; un-escaping it yields the original.
482 +
const metadataCell = '"{""note"":""bom, \\""ótimo\\""""}"'
483 +
expect(insightsCsv).toContain(metadataCell)
484 +
const unescaped = metadataCell.slice(1, -1).replace(/""/g, '"')
485 +
expect(JSON.parse(unescaped)).toEqual({ note: 'bom, "ótimo"' })
486 +
487 +
const manifest = await zip.file('leia-me.md')!.async('string')
488 +
expect(manifest).toContain('`insights.csv` — 2 registros')
428 489
})
429 490
430 491
it('preserves commas, quotes and newlines inside a field', async () => {
431 492
const user = await createTestUser(db, {
432 493
email: 'quoted@example.com',
473 534
await expect(
474 535
exportProcessor({ name: 'export:unknown', data: {} } as any)
475 536
).rejects.toThrow('Unknown export job')
476 537
})
477 538
})