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 filesParents
8480619Changed 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
})