api/data-export: include care actions in the archive
The export covered moods, sleep, and triggers but left out the care actions, so medicines, consultations, and activities never travelled with the user's record. The archive now writes care_actions.csv plus the medicine logs, appointments, activities, and standing regimens, with the subtype labels and periodicities translated and readable
Pedro Lucas Porcellis porcellis@eletrotupi.com · 23 days ago
1230d1819bd28727595923820528cbb25d702eb8 Browse filesParents
82219bfChanged files
5
Additions
+339
Deletions
-1
api/src/services/export/data.gatherer.ts
1 1
import {
2 +
Activity,
3 +
Appointment,
4 +
CareAction,
5 +
MedicineLog,
6 +
MedicineRegimen,
2 7
Mood,
3 8
MoodComponent,
4 9
SleepRecord,
5 10
Trigger,
6 11
TriggerMoodLink,
15 20
moods: Mood[];
16 21
moodComponents: MoodComponent[];
17 22
sleepRecords: SleepRecord[];
18 23
triggers: Trigger[];
19 24
triggerMoodLinks: TriggerMoodLink[];
25 +
careActions: CareAction[];
26 +
medicineLogs: MedicineLog[];
27 +
appointments: Appointment[];
28 +
activities: Activity[];
29 +
medicineRegimens: MedicineRegimen[];
20 30
};
21 31
22 32
export async function gatherExportData(userId: number): Promise<ExportData> {
23 33
const user = await findUserById(userId);
24 34
25 35
if (!user) {
26 36
throw new Error(`Usuário ${userId} não encontrado para exportação`);
27 37
}
28 38
29 -
const [moods, sleepRecords, triggers, triggerMoodLinks] = await Promise.all([
39 +
const [
40 +
moods,
41 +
sleepRecords,
42 +
triggers,
43 +
triggerMoodLinks,
44 +
careActions,
45 +
medicineRegimens,
46 +
] = await Promise.all([
30 47
prisma.mood.findMany({
31 48
where: { userId },
32 49
include: { moodComponents: true },
33 50
orderBy: { moment: "asc" },
34 51
}),
42 59
}),
43 60
prisma.triggerMoodLink.findMany({
44 61
where: { trigger: { userId } },
45 62
orderBy: { linkedAt: "asc" },
46 63
}),
64 +
prisma.careAction.findMany({
65 +
where: { userId },
66 +
include: { medicineLog: true, appointment: true, activity: true },
67 +
orderBy: { moment: "asc" },
68 +
}),
69 +
prisma.medicineRegimen.findMany({
70 +
where: { userId },
71 +
orderBy: { createdAt: "asc" },
72 +
}),
47 73
]);
48 74
49 75
return {
50 76
user,
51 77
moods,
52 78
moodComponents: moods.flatMap((mood) => mood.moodComponents),
53 79
sleepRecords,
54 80
triggers,
55 81
triggerMoodLinks,
82 +
careActions,
83 +
medicineLogs: careActions.flatMap((action) =>
84 +
action.medicineLog ? [action.medicineLog] : [],
85 +
),
86 +
appointments: careActions.flatMap((action) =>
87 +
action.appointment ? [action.appointment] : [],
88 +
),
89 +
activities: careActions.flatMap((action) =>
90 +
action.activity ? [action.activity] : [],
91 +
),
92 +
medicineRegimens,
56 93
};
57 94
}
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 +
activityTable,
6 +
appointmentTable,
7 +
careActionTable,
8 +
medicineLogTable,
9 +
medicineRegimenTable,
5 10
moodComponentTable,
6 11
moodTable,
7 12
profileTable,
8 13
sleepRecordTable,
9 14
triggerMoodLinkTable,
18 23
moods,
19 24
moodComponents,
20 25
sleepRecords,
21 26
triggers,
22 27
triggerMoodLinks,
28 +
careActions,
29 +
medicineLogs,
30 +
appointments,
31 +
activities,
32 +
medicineRegimens,
23 33
} = await gatherExportData(userId);
24 34
const generatedAt = new Date();
25 35
26 36
const files: ExportFile[] = [
27 37
serializeCsv(profileTable, [user]),
28 38
serializeCsv(moodTable, moods),
29 39
serializeCsv(moodComponentTable, moodComponents),
30 40
serializeCsv(sleepRecordTable, sleepRecords),
31 41
serializeCsv(triggerTable, triggers),
32 42
serializeCsv(triggerMoodLinkTable, triggerMoodLinks),
43 +
serializeCsv(careActionTable, careActions),
44 +
serializeCsv(medicineLogTable, medicineLogs),
45 +
serializeCsv(appointmentTable, appointments),
46 +
serializeCsv(activityTable, activities),
47 +
serializeCsv(medicineRegimenTable, medicineRegimens),
33 48
];
34 49
const manifest = buildManifest(files, generatedAt);
35 50
36 51
return {
37 52
filename: `nexo-dados-${generatedAt.toISOString().slice(0, 10)}.zip`,
38 53
buffer: await buildZip([...files, manifest]),
39 54
};
40 55
}
api/src/services/export/label.map.ts
1 1
import {
2 +
ActivityType,
3 +
AppointmentType,
2 4
BaseMoodOption,
5 +
CareActionType,
3 6
IntensityLevel,
7 +
MedicinePeriodicity,
4 8
MoodComponentOption,
5 9
TriggerType,
6 10
} from "@prisma/client";
7 11
8 12
// Temporary copy of the labels the app shows. The app-wide map will eventually
46 50
FAMILY: "Família",
47 51
THERAPY: "Terapia",
48 52
INTERNAL: "Interno",
49 53
OTHER: "Outro",
50 54
};
55 +
56 +
export const careActionTypeLabel: Record<CareActionType, string> = {
57 +
MEDICINE: "Medicação",
58 +
APPOINTMENT: "Consulta",
59 +
ACTIVITY: "Atividade",
60 +
};
61 +
62 +
export const appointmentTypeLabel: Record<AppointmentType, string> = {
63 +
ANALYST: "Analista",
64 +
PSYCHIATRIST: "Psiquiatra",
65 +
GP: "Clínico geral",
66 +
NUTRITIONIST: "Nutricionista",
67 +
PHYSIOTHERAPIST: "Fisioterapeuta",
68 +
OTHER: "Consulta",
69 +
};
70 +
71 +
export const activityCategoryLabel: Record<ActivityType, string> = {
72 +
WALK: "Caminhada",
73 +
YOGA: "Yoga",
74 +
GYM: "Academia",
75 +
MEDITATION: "Meditação",
76 +
SOCIAL: "Momento social",
77 +
CREATIVE: "Atividade criativa",
78 +
OTHER: "Atividade",
79 +
};
80 +
81 +
export const medicinePeriodicityLabel: Record<MedicinePeriodicity, string> = {
82 +
ONCE: "Uma vez",
83 +
DAILY: "Diário",
84 +
TWICE_DAILY: "2x ao dia",
85 +
THREE_TIMES_DAILY: "3x ao dia",
86 +
WEEKLY: "Semanal",
87 +
BIWEEKLY: "Quinzenal",
88 +
MONTHLY: "Mensal",
89 +
};
api/src/services/export/table.descriptors.ts
1 1
import {
2 +
Activity,
3 +
Appointment,
4 +
CareAction,
5 +
MedicineLog,
6 +
MedicineRegimen,
2 7
Mood,
3 8
MoodComponent,
4 9
SleepRecord,
5 10
Trigger,
6 11
TriggerMoodLink,
7 12
User,
8 13
} from "@prisma/client";
9 14
10 15
import {
16 +
activityCategoryLabel,
17 +
appointmentTypeLabel,
11 18
baseMoodLabel,
19 +
careActionTypeLabel,
12 20
intensityLabel,
21 +
medicinePeriodicityLabel,
13 22
moodComponentLabel,
14 23
triggerTypeLabel,
15 24
} from "@app/services/export/label.map";
16 25
import { CsvTable } from "@app/services/export/types";
17 26
102 111
{ header: "mood_id", value: (link) => link.moodId },
103 112
{ header: "Impacto percebido", value: (link) => link.perceivedImpact },
104 113
{ header: "Vinculado em", value: (link) => link.linkedAt },
105 114
],
106 115
};
116 +
117 +
export const careActionTable: CsvTable<CareAction> = {
118 +
filename: "care_actions.csv",
119 +
columns: [
120 +
{ header: "id", value: (action) => action.id },
121 +
{ header: "Tipo", value: (action) => careActionTypeLabel[action.type] },
122 +
{ header: "Momento", value: (action) => action.moment },
123 +
{ header: "trigger_id", value: (action) => action.triggerId },
124 +
{ header: "mood_id", value: (action) => action.moodId },
125 +
{ header: "Criado em", value: (action) => action.createdAt },
126 +
{ header: "Atualizado em", value: (action) => action.updatedAt },
127 +
],
128 +
};
129 +
130 +
export const medicineLogTable: CsvTable<MedicineLog> = {
131 +
filename: "medicine_logs.csv",
132 +
columns: [
133 +
{ header: "id", value: (log) => log.id },
134 +
{ header: "care_action_id", value: (log) => log.careActionId },
135 +
{ header: "regimen_id", value: (log) => log.regimenId },
136 +
{ header: "Tomado em", value: (log) => log.takenAt },
137 +
],
138 +
};
139 +
140 +
export const appointmentTable: CsvTable<Appointment> = {
141 +
filename: "appointments.csv",
142 +
columns: [
143 +
{ header: "id", value: (appointment) => appointment.id },
144 +
{
145 +
header: "care_action_id",
146 +
value: (appointment) => appointment.careActionId,
147 +
},
148 +
{
149 +
header: "Tipo",
150 +
value: (appointment) => appointmentTypeLabel[appointment.type],
151 +
},
152 +
{ header: "Duração", value: (appointment) => appointment.duration },
153 +
{ header: "Observação", value: (appointment) => appointment.note },
154 +
],
155 +
};
156 +
157 +
export const activityTable: CsvTable<Activity> = {
158 +
filename: "activities.csv",
159 +
columns: [
160 +
{ header: "id", value: (activity) => activity.id },
161 +
{ header: "care_action_id", value: (activity) => activity.careActionId },
162 +
{
163 +
header: "Tipo",
164 +
value: (activity) => activityCategoryLabel[activity.type],
165 +
},
166 +
{ header: "Duração", value: (activity) => activity.duration },
167 +
],
168 +
};
169 +
170 +
export const medicineRegimenTable: CsvTable<MedicineRegimen> = {
171 +
filename: "medicine_regimens.csv",
172 +
columns: [
173 +
{ header: "id", value: (regimen) => regimen.id },
174 +
{ header: "Nome", value: (regimen) => regimen.name },
175 +
{ header: "Dosagem", value: (regimen) => regimen.dosage },
176 +
{
177 +
header: "Periodicidade",
178 +
value: (regimen) => medicinePeriodicityLabel[regimen.periodicity],
179 +
},
180 +
{ header: "Horários", value: (regimen) => regimen.scheduledAt.join(" · ") },
181 +
{ header: "Ativo", value: (regimen) => booleanLabel(regimen.active) },
182 +
{ header: "Criado em", value: (regimen) => regimen.createdAt },
183 +
{ header: "Atualizado em", value: (regimen) => regimen.updatedAt },
184 +
],
185 +
};
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'
61 61
expect(payload.subject).toBe('Seus dados do nexo')
62 62
expect(attachment.filename).toMatch(/^nexo-dados-\d{4}-\d{2}-\d{2}\.zip$/)
63 63
64 64
const zip = await JSZip.loadAsync(attachment.content)
65 65
expect(Object.keys(zip.files).sort()).toEqual([
66 +
'activities.csv',
67 +
'appointments.csv',
68 +
'care_actions.csv',
66 69
'leia-me.md',
70 +
'medicine_logs.csv',
71 +
'medicine_regimens.csv',
67 72
'mood_components.csv',
68 73
'moods.csv',
69 74
'profile.csv',
70 75
'sleep_records.csv',
71 76
'trigger_mood_links.csv',
255 260
256 261
const manifest = await zip.file('leia-me.md')!.async('string')
257 262
expect(manifest).toContain('`sleep_records.csv` — 1 registro')
258 263
expect(manifest).toContain('`triggers.csv` — 2 registros')
259 264
expect(manifest).toContain('`trigger_mood_links.csv` — 1 registro')
265 +
})
266 +
267 +
it('exports care actions, their subtypes and medicine regimens', async () => {
268 +
const user = await createTestUser(db, { email: 'care@example.com' })
269 +
const mood = await db.mood.create({
270 +
data: {
271 +
userId: user.id,
272 +
selectedMood: 'GOOD',
273 +
anxietyLevel: 3,
274 +
stressLevel: 3,
275 +
energyLevel: 6,
276 +
moment: new Date('2026-03-05T10:00:00.000Z'),
277 +
},
278 +
})
279 +
const trigger = await db.trigger.create({
280 +
data: {
281 +
userId: user.id,
282 +
category: 'WORK',
283 +
moment: new Date('2026-03-05T14:00:00.000Z'),
284 +
},
285 +
})
286 +
const regimen = await db.medicineRegimen.create({
287 +
data: {
288 +
userId: user.id,
289 +
name: 'Sertralina',
290 +
dosage: '50mg',
291 +
periodicity: 'DAILY',
292 +
scheduledAt: ['08:00', '20:00'],
293 +
active: true,
294 +
},
295 +
})
296 +
297 +
const medicineAction = await db.careAction.create({
298 +
data: {
299 +
userId: user.id,
300 +
type: 'MEDICINE',
301 +
moment: new Date('2026-03-05T08:05:00.000Z'),
302 +
triggerId: trigger.id,
303 +
moodId: mood.id,
304 +
medicineLog: {
305 +
create: {
306 +
regimenId: regimen.id,
307 +
takenAt: new Date('2026-03-05T08:05:00.000Z'),
308 +
},
309 +
},
310 +
},
311 +
include: { medicineLog: true },
312 +
})
313 +
const appointmentAction = await db.careAction.create({
314 +
data: {
315 +
userId: user.id,
316 +
type: 'APPOINTMENT',
317 +
moment: new Date('2026-03-06T15:00:00.000Z'),
318 +
appointment: {
319 +
create: { type: 'ANALYST', duration: 50, note: 'Primeira sessão' },
320 +
},
321 +
},
322 +
include: { appointment: true },
323 +
})
324 +
const activityAction = await db.careAction.create({
325 +
data: {
326 +
userId: user.id,
327 +
type: 'ACTIVITY',
328 +
moment: new Date('2026-03-07T07:30:00.000Z'),
329 +
activity: { create: { type: 'WALK', duration: 30 } },
330 +
},
331 +
include: { activity: true },
332 +
})
333 +
334 +
await exportProcessor(buildJob({ userId: user.id }))
335 +
336 +
const { attachment } = await attachmentFromLastEmail()
337 +
const zip = await JSZip.loadAsync(attachment.content)
338 +
339 +
for (const file of [
340 +
'care_actions.csv',
341 +
'medicine_logs.csv',
342 +
'appointments.csv',
343 +
'activities.csv',
344 +
'medicine_regimens.csv',
345 +
]) {
346 +
expect(zip.file(file)).not.toBeNull()
347 +
}
348 +
349 +
const careCsv = await zip.file('care_actions.csv')!.async('string')
350 +
const careLines = csvLines(careCsv)
351 +
expect(careLines[0]).toContain('Tipo')
352 +
expect(careLines[0]).toContain('trigger_id')
353 +
expect(careLines[0]).toContain('mood_id')
354 +
expect(careLines[0]).toContain('Criado em')
355 +
expect(careLines[0]).toContain('Atualizado em')
356 +
expect(careCsv).toContain('"Medicação"')
357 +
expect(careCsv).toContain('"Consulta"')
358 +
expect(careCsv).toContain('"Atividade"')
359 +
360 +
const careHeader = careLines[0].split(',').map((field) => field.replace(/"/g, ''))
361 +
const idIndex = careHeader.indexOf('id')
362 +
const triggerIndex = careHeader.indexOf('trigger_id')
363 +
const moodIndex = careHeader.indexOf('mood_id')
364 +
const careIds = new Set(
365 +
careLines.slice(1).map((row) => Number(row.split(',')[idIndex])),
366 +
)
367 +
expect(careIds.size).toBe(3)
368 +
369 +
const medicineRow = careLines.slice(1).find((row) => row.includes('"Medicação"'))!
370 +
const medicineFields = medicineRow.split(',')
371 +
expect(Number(medicineFields[triggerIndex])).toBe(trigger.id)
372 +
expect(Number(medicineFields[moodIndex])).toBe(mood.id)
373 +
374 +
const logsCsv = await zip.file('medicine_logs.csv')!.async('string')
375 +
const logLines = csvLines(logsCsv)
376 +
expect(logLines[0]).toContain('care_action_id')
377 +
expect(logLines[0]).toContain('regimen_id')
378 +
expect(logLines[0]).toContain('Tomado em')
379 +
const logHeader = logLines[0].split(',').map((field) => field.replace(/"/g, ''))
380 +
const logFields = logLines[1].split(',')
381 +
expect(Number(logFields[logHeader.indexOf('care_action_id')])).toBe(medicineAction.id)
382 +
expect(Number(logFields[logHeader.indexOf('regimen_id')])).toBe(regimen.id)
383 +
384 +
const appointmentCsv = await zip.file('appointments.csv')!.async('string')
385 +
const appointmentLines = csvLines(appointmentCsv)
386 +
expect(appointmentLines[0]).toContain('Tipo')
387 +
expect(appointmentLines[0]).toContain('Duração')
388 +
expect(appointmentLines[0]).toContain('Observação')
389 +
expect(appointmentCsv).toContain('"Analista"')
390 +
expect(appointmentCsv).toContain('50')
391 +
expect(appointmentCsv).toContain('"Primeira sessão"')
392 +
const appointmentHeader = appointmentLines[0]
393 +
.split(',')
394 +
.map((field) => field.replace(/"/g, ''))
395 +
expect(
396 +
Number(appointmentLines[1].split(',')[appointmentHeader.indexOf('care_action_id')]),
397 +
).toBe(appointmentAction.id)
398 +
399 +
const activityCsv = await zip.file('activities.csv')!.async('string')
400 +
const activityLines = csvLines(activityCsv)
401 +
expect(activityLines[0]).toContain('Duração')
402 +
expect(activityCsv).toContain('"Caminhada"')
403 +
expect(activityCsv).toContain('30')
404 +
const activityHeader = activityLines[0].split(',').map((field) => field.replace(/"/g, ''))
405 +
expect(
406 +
Number(activityLines[1].split(',')[activityHeader.indexOf('care_action_id')]),
407 +
).toBe(activityAction.id)
408 +
409 +
const regimenCsv = await zip.file('medicine_regimens.csv')!.async('string')
410 +
const regimenLines = csvLines(regimenCsv)
411 +
expect(regimenLines[0]).toContain('Nome')
412 +
expect(regimenLines[0]).toContain('Dosagem')
413 +
expect(regimenLines[0]).toContain('Periodicidade')
414 +
expect(regimenLines[0]).toContain('Horários')
415 +
expect(regimenLines[0]).toContain('Ativo')
416 +
expect(regimenLines[1]).toContain('"Sertralina"')
417 +
expect(regimenLines[1]).toContain('"50mg"')
418 +
expect(regimenLines[1]).toContain('"Diário"')
419 +
expect(regimenLines[1]).toContain('"08:00 · 20:00"')
420 +
expect(regimenLines[1]).toContain('"Sim"')
421 +
422 +
const manifest = await zip.file('leia-me.md')!.async('string')
423 +
expect(manifest).toContain('`care_actions.csv` — 3 registros')
424 +
expect(manifest).toContain('`medicine_logs.csv` — 1 registro')
425 +
expect(manifest).toContain('`appointments.csv` — 1 registro')
426 +
expect(manifest).toContain('`activities.csv` — 1 registro')
427 +
expect(manifest).toContain('`medicine_regimens.csv` — 1 registro')
260 428
})
261 429
262 430
it('preserves commas, quotes and newlines inside a field', async () => {
263 431
const user = await createTestUser(db, {
264 432
email: 'quoted@example.com',
305 473
await expect(
306 474
exportProcessor({ name: 'export:unknown', data: {} } as any)
307 475
).rejects.toThrow('Unknown export job')
308 476
})
309 477
})