eletrotupi / tcc/ commit / 1230d18

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 files
Parents 82219bf
Changed 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
})