eletrotupi / tcc/ commit / 60e277a

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 files
Parents 1b8d5d7
Changed 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
})