eletrotupi / tcc/ commit / 1f2f0a1

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 files
Parents 1230d18
Changed 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
})