eletrotupi / tcc/ commit / 82219bf

api/data-export: include sleep records and triggers in the archive

The export only carried the profile and mood entries, so the timeline
around each mood entry was missing. It now adds the sleep records,
triggers, and trigger links, translates the trigger categories to pt-BR,
and lists the new files in the manifest
Pedro Lucas Porcellis porcellis@eletrotupi.com · 24 days ago 82219bffa59da16277fbdf343d327a77d7714dfc Browse files
Parents 8480619
Changed files
5
Additions
+195
Deletions
-8
api/src/services/export/data.gatherer.ts
1 -
import { Mood, MoodComponent, User } from "@prisma/client";
1 +
import {
2 +
  Mood,
3 +
  MoodComponent,
4 +
  SleepRecord,
5 +
  Trigger,
6 +
  TriggerMoodLink,
7 +
  User,
8 +
} from "@prisma/client";
2 9

3 10
import { prisma } from "@app/lib/prisma";
4 11
import { findUserById } from "@app/services/user.service";
5 12

6 13
export type ExportData = {
7 14
  user: User;
8 15
  moods: Mood[];
9 16
  moodComponents: MoodComponent[];
17 +
  sleepRecords: SleepRecord[];
18 +
  triggers: Trigger[];
19 +
  triggerMoodLinks: TriggerMoodLink[];
10 20
};
11 21

12 22
export async function gatherExportData(userId: number): Promise<ExportData> {
13 23
  const user = await findUserById(userId);
14 24

15 25
  if (!user) {
16 26
    throw new Error(`Usuário ${userId} não encontrado para exportação`);
17 27
  }
18 28

19 -
  const moods = await prisma.mood.findMany({
20 -
    where: { userId },
21 -
    include: { moodComponents: true },
22 -
    orderBy: { moment: "asc" },
23 -
  });
29 +
  const [moods, sleepRecords, triggers, triggerMoodLinks] = await Promise.all([
30 +
    prisma.mood.findMany({
31 +
      where: { userId },
32 +
      include: { moodComponents: true },
33 +
      orderBy: { moment: "asc" },
34 +
    }),
35 +
    prisma.sleepRecord.findMany({
36 +
      where: { userId },
37 +
      orderBy: { date: "asc" },
38 +
    }),
39 +
    prisma.trigger.findMany({
40 +
      where: { userId },
41 +
      orderBy: { moment: "asc" },
42 +
    }),
43 +
    prisma.triggerMoodLink.findMany({
44 +
      where: { trigger: { userId } },
45 +
      orderBy: { linkedAt: "asc" },
46 +
    }),
47 +
  ]);
24 48

25 49
  return {
26 50
    user,
27 51
    moods,
28 52
    moodComponents: moods.flatMap((mood) => mood.moodComponents),
53 +
    sleepRecords,
54 +
    triggers,
55 +
    triggerMoodLinks,
29 56
  };
30 57
}
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
  moodComponentTable,
6 6
  moodTable,
7 7
  profileTable,
8 +
  sleepRecordTable,
9 +
  triggerMoodLinkTable,
10 +
  triggerTable,
8 11
} from "@app/services/export/table.descriptors";
9 12
import { buildZip } from "@app/services/export/zip.builder";
10 13
import { ExportArchive, ExportFile } from "@app/services/export/types";
11 14

12 15
export async function buildExportArchive(userId: number): Promise<ExportArchive> {
13 -
  const { user, moods, moodComponents } = await gatherExportData(userId);
16 +
  const {
17 +
    user,
18 +
    moods,
19 +
    moodComponents,
20 +
    sleepRecords,
21 +
    triggers,
22 +
    triggerMoodLinks,
23 +
  } = await gatherExportData(userId);
14 24
  const generatedAt = new Date();
15 25

16 26
  const files: ExportFile[] = [
17 27
    serializeCsv(profileTable, [user]),
18 28
    serializeCsv(moodTable, moods),
19 29
    serializeCsv(moodComponentTable, moodComponents),
30 +
    serializeCsv(sleepRecordTable, sleepRecords),
31 +
    serializeCsv(triggerTable, triggers),
32 +
    serializeCsv(triggerMoodLinkTable, triggerMoodLinks),
20 33
  ];
21 34
  const manifest = buildManifest(files, generatedAt);
22 35

23 36
  return {
24 37
    filename: `nexo-dados-${generatedAt.toISOString().slice(0, 10)}.zip`,
25 38
    buffer: await buildZip([...files, manifest]),
26 39
  };
27 40
}
api/src/services/export/label.map.ts
1 1
import {
2 2
  BaseMoodOption,
3 3
  IntensityLevel,
4 4
  MoodComponentOption,
5 +
  TriggerType,
5 6
} from "@prisma/client";
6 7

7 8
// Temporary copy of the labels the app shows. The app-wide map will eventually
8 9
// live with the i18n labels work; these are intentionally pt-BR only.
9 10
export const baseMoodLabel: Record<BaseMoodOption, string> = {
34 35
export const intensityLabel: Record<IntensityLevel, string> = {
35 36
  LIGHT: "Suave",
36 37
  MODERATE: "Moderada",
37 38
  HIGH: "Intensa",
38 39
};
40 +

41 +
export const triggerTypeLabel: Record<TriggerType, string> = {
42 +
  SOCIAL: "Social",
43 +
  WORK: "Trabalho",
44 +
  HEALTH: "Saúde",
45 +
  PHYSICAL: "Físico",
46 +
  FAMILY: "Família",
47 +
  THERAPY: "Terapia",
48 +
  INTERNAL: "Interno",
49 +
  OTHER: "Outro",
50 +
};
api/src/services/export/table.descriptors.ts
1 -
import { Mood, MoodComponent, User } from "@prisma/client";
1 +
import {
2 +
  Mood,
3 +
  MoodComponent,
4 +
  SleepRecord,
5 +
  Trigger,
6 +
  TriggerMoodLink,
7 +
  User,
8 +
} from "@prisma/client";
2 9

3 10
import {
4 11
  baseMoodLabel,
5 12
  intensityLabel,
6 13
  moodComponentLabel,
14 +
  triggerTypeLabel,
7 15
} from "@app/services/export/label.map";
8 16
import { CsvTable } from "@app/services/export/types";
9 17

10 18
const booleanLabel = (value: boolean): string => (value ? "Sim" : "Não");
19 +

20 +
// Sleep records carry a day-granular date, so they drop the time and offset.
21 +
const dateOnly = (value: Date): string => value.toISOString().slice(0, 10);
11 22

12 23
export const profileTable: CsvTable<User> = {
13 24
  filename: "profile.csv",
14 25
  columns: [
15 26
    { header: "id", value: (user) => user.id },
53 64
      header: "Intensidade",
54 65
      value: (component) => intensityLabel[component.intensity],
55 66
    },
56 67
  ],
57 68
};
69 +

70 +
export const sleepRecordTable: CsvTable<SleepRecord> = {
71 +
  filename: "sleep_records.csv",
72 +
  columns: [
73 +
    { header: "id", value: (record) => record.id },
74 +
    { header: "Data", value: (record) => dateOnly(record.date) },
75 +
    { header: "Média", value: (record) => record.average },
76 +
    { header: "Anotações", value: (record) => record.annotations },
77 +
    { header: "Criado em", value: (record) => record.createdAt },
78 +
    { header: "Atualizado em", value: (record) => record.updatedAt },
79 +
  ],
80 +
};
81 +

82 +
export const triggerTable: CsvTable<Trigger> = {
83 +
  filename: "triggers.csv",
84 +
  columns: [
85 +
    { header: "id", value: (trigger) => trigger.id },
86 +
    {
87 +
      header: "Categoria",
88 +
      value: (trigger) => triggerTypeLabel[trigger.category],
89 +
    },
90 +
    { header: "Momento", value: (trigger) => trigger.moment },
91 +
    { header: "Comentário", value: (trigger) => trigger.comment },
92 +
    { header: "Criado em", value: (trigger) => trigger.createdAt },
93 +
    { header: "Atualizado em", value: (trigger) => trigger.updatedAt },
94 +
  ],
95 +
};
96 +

97 +
export const triggerMoodLinkTable: CsvTable<TriggerMoodLink> = {
98 +
  filename: "trigger_mood_links.csv",
99 +
  columns: [
100 +
    { header: "id", value: (link) => link.id },
101 +
    { header: "trigger_id", value: (link) => link.triggerId },
102 +
    { header: "mood_id", value: (link) => link.moodId },
103 +
    { header: "Impacto percebido", value: (link) => link.perceivedImpact },
104 +
    { header: "Vinculado em", value: (link) => link.linkedAt },
105 +
  ],
106 +
};
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'
65 65
    expect(Object.keys(zip.files).sort()).toEqual([
66 66
      'leia-me.md',
67 67
      'mood_components.csv',
68 68
      'moods.csv',
69 69
      'profile.csv',
70 +
      'sleep_records.csv',
71 +
      'trigger_mood_links.csv',
72 +
      'triggers.csv',
70 73
    ])
71 74

72 75
    const csv = await zip.file('profile.csv')!.async('string')
73 76
    expect(csv.startsWith('\ufeff')).toBe(true)
74 77

169 172
    expect(componentsCsv).toContain('"Moderada"')
170 173

171 174
    const manifest = await zip.file('leia-me.md')!.async('string')
172 175
    expect(manifest).toContain('`moods.csv` — 2 registros')
173 176
    expect(manifest).toContain('`mood_components.csv` — 2 registros')
177 +
  })
178 +

179 +
  it('exports sleep records, triggers and their mood links', async () => {
180 +
    const user = await createTestUser(db, { email: 'timeline@example.com' })
181 +
    const mood = await db.mood.create({
182 +
      data: {
183 +
        userId: user.id,
184 +
        selectedMood: 'GOOD',
185 +
        anxietyLevel: 3,
186 +
        stressLevel: 3,
187 +
        energyLevel: 6,
188 +
        moment: new Date('2026-03-05T10:00:00.000Z'),
189 +
      },
190 +
    })
191 +
    await db.sleepRecord.create({
192 +
      data: {
193 +
        userId: user.id,
194 +
        date: new Date('2026-03-05T00:00:00.000Z'),
195 +
        average: 7.5,
196 +
        annotations: 'Dormi bem, "profundo"',
197 +
      },
198 +
    })
199 +
    const trigger = await db.trigger.create({
200 +
      data: {
201 +
        userId: user.id,
202 +
        category: 'WORK',
203 +
        moment: new Date('2026-03-05T14:30:00.000Z'),
204 +
        comment: 'Reunião difícil',
205 +
      },
206 +
    })
207 +
    await db.trigger.create({
208 +
      data: {
209 +
        userId: user.id,
210 +
        category: 'HEALTH',
211 +
        moment: new Date('2026-03-05T18:00:00.000Z'),
212 +
        comment: 'Consulta',
213 +
      },
214 +
    })
215 +
    await db.triggerMoodLink.create({
216 +
      data: {
217 +
        triggerId: trigger.id,
218 +
        moodId: mood.id,
219 +
        perceivedImpact: 4,
220 +
        linkedAt: new Date('2026-03-05T15:00:00.000Z'),
221 +
      },
222 +
    })
223 +

224 +
    await exportProcessor(buildJob({ userId: user.id }))
225 +

226 +
    const { attachment } = await attachmentFromLastEmail()
227 +
    const zip = await JSZip.loadAsync(attachment.content)
228 +

229 +
    const sleepCsv = await zip.file('sleep_records.csv')!.async('string')
230 +
    const sleepLines = csvLines(sleepCsv)
231 +
    expect(sleepLines[0]).toContain('Data')
232 +
    expect(sleepLines[0]).toContain('Média')
233 +
    expect(sleepLines[0]).toContain('Anotações')
234 +
    expect(sleepLines[1]).toContain('"2026-03-05"')
235 +
    expect(sleepLines[1]).toContain('7.5')
236 +
    expect(sleepLines[1]).toContain('"Dormi bem, ""profundo"""')
237 +

238 +
    const triggersCsv = await zip.file('triggers.csv')!.async('string')
239 +
    const triggerLines = csvLines(triggersCsv)
240 +
    expect(triggerLines[0]).toContain('Categoria')
241 +
    expect(triggerLines[0]).toContain('Comentário')
242 +
    expect(triggerLines[1]).toContain('"Trabalho"')
243 +
    expect(triggerLines[1]).toContain('"Reunião difícil"')
244 +
    expect(triggerLines[2]).toContain('"Saúde"')
245 +

246 +
    const linksCsv = await zip.file('trigger_mood_links.csv')!.async('string')
247 +
    const linkLines = csvLines(linksCsv)
248 +
    expect(linkLines[0]).toContain('Impacto percebido')
249 +
    expect(linkLines[0]).toContain('Vinculado em')
250 +

251 +
    const header = linkLines[0].split(',').map((field) => field.replace(/"/g, ''))
252 +
    const fields = linkLines[1].split(',')
253 +
    expect(Number(fields[header.indexOf('trigger_id')])).toBe(trigger.id)
254 +
    expect(Number(fields[header.indexOf('mood_id')])).toBe(mood.id)
255 +

256 +
    const manifest = await zip.file('leia-me.md')!.async('string')
257 +
    expect(manifest).toContain('`sleep_records.csv` — 1 registro')
258 +
    expect(manifest).toContain('`triggers.csv` — 2 registros')
259 +
    expect(manifest).toContain('`trigger_mood_links.csv` — 1 registro')
174 260
  })
175 261

176 262
  it('preserves commas, quotes and newlines inside a field', async () => {
177 263
    const user = await createTestUser(db, {
178 264
      email: 'quoted@example.com',
219 305
    await expect(
220 306
      exportProcessor({ name: 'export:unknown', data: {} } as any)
221 307
    ).rejects.toThrow('Unknown export job')
222 308
  })
223 309
})