api/data-export: include mood entries in the archive
The export carried only the profile CSV, so the moods, components, and intensities the user logged were absent from a feature meant to hand over their whole record. The archive now writes moods.csv and mood_components.csv with translated strings names and values and keeps mood_id pointing at the mood row it belongs to
Pedro Lucas Porcellis porcellis@eletrotupi.com · 24 days ago
8480619e654d675a3b0327e5df6b8b94dab2c32b Browse filesParents
19821daChanged files
5
Additions
+201
Deletions
-12
/dev/null → api/src/services/export/data.gatherer.ts
1 +
import { Mood, MoodComponent, User } from "@prisma/client";
2 +
3 +
import { prisma } from "@app/lib/prisma";
4 +
import { findUserById } from "@app/services/user.service";
5 +
6 +
export type ExportData = {
7 +
user: User;
8 +
moods: Mood[];
9 +
moodComponents: MoodComponent[];
10 +
};
11 +
12 +
export async function gatherExportData(userId: number): Promise<ExportData> {
13 +
const user = await findUserById(userId);
14 +
15 +
if (!user) {
16 +
throw new Error(`Usuário ${userId} não encontrado para exportação`);
17 +
}
18 +
19 +
const moods = await prisma.mood.findMany({
20 +
where: { userId },
21 +
include: { moodComponents: true },
22 +
orderBy: { moment: "asc" },
23 +
});
24 +
25 +
return {
26 +
user,
27 +
moods,
28 +
moodComponents: moods.flatMap((mood) => mood.moodComponents),
29 +
};
30 +
}
api/src/services/export/export.service.ts
1 -
import { findUserById } from "@app/services/user.service";
2 1
import { serializeCsv } from "@app/services/export/csv.serializer";
2 +
import { gatherExportData } from "@app/services/export/data.gatherer";
3 3
import { buildManifest } from "@app/services/export/manifest.builder";
4 -
import { profileTable } from "@app/services/export/table.descriptors";
4 +
import {
5 +
moodComponentTable,
6 +
moodTable,
7 +
profileTable,
8 +
} from "@app/services/export/table.descriptors";
5 9
import { buildZip } from "@app/services/export/zip.builder";
6 10
import { ExportArchive, ExportFile } from "@app/services/export/types";
7 11
8 12
export async function buildExportArchive(userId: number): Promise<ExportArchive> {
9 -
const user = await findUserById(userId);
10 -
11 -
if (!user) {
12 -
throw new Error(`Usuário ${userId} não encontrado para exportação`);
13 -
}
14 -
13 +
const { user, moods, moodComponents } = await gatherExportData(userId);
15 14
const generatedAt = new Date();
16 15
17 -
const files: ExportFile[] = [serializeCsv(profileTable, [user])];
16 +
const files: ExportFile[] = [
17 +
serializeCsv(profileTable, [user]),
18 +
serializeCsv(moodTable, moods),
19 +
serializeCsv(moodComponentTable, moodComponents),
20 +
];
18 21
const manifest = buildManifest(files, generatedAt);
19 22
20 23
return {
21 24
filename: `nexo-dados-${generatedAt.toISOString().slice(0, 10)}.zip`,
22 25
buffer: await buildZip([...files, manifest]),
23 26
};
24 27
}
/dev/null → api/src/services/export/label.map.ts
1 +
import {
2 +
BaseMoodOption,
3 +
IntensityLevel,
4 +
MoodComponentOption,
5 +
} from "@prisma/client";
6 +
7 +
// Temporary copy of the labels the app shows. The app-wide map will eventually
8 +
// live with the i18n labels work; these are intentionally pt-BR only.
9 +
export const baseMoodLabel: Record<BaseMoodOption, string> = {
10 +
SAD: "Triste",
11 +
NEUTRAL: "Neutro",
12 +
GOOD: "Bem",
13 +
GREAT: "Ótimo",
14 +
ANGRY: "Irritado",
15 +
};
16 +
17 +
export const moodComponentLabel: Record<MoodComponentOption, string> = {
18 +
JOY: "Alegria",
19 +
ANGER: "Raiva",
20 +
SADNESS: "Tristeza",
21 +
FEAR: "Medo",
22 +
GUILT: "Culpa",
23 +
FRUSTRATION: "Frustração",
24 +
CALM: "Calmo",
25 +
MOTIVATED: "Motivado",
26 +
TIREDNESS: "Cansaço",
27 +
GRATITUDE: "Gratidão",
28 +
FOCUS: "Foco",
29 +
RESTLESS: "Inquieto",
30 +
RELAXED: "Relaxado",
31 +
OVERWHELMED: "Sobrecarga",
32 +
};
33 +
34 +
export const intensityLabel: Record<IntensityLevel, string> = {
35 +
LIGHT: "Suave",
36 +
MODERATE: "Moderada",
37 +
HIGH: "Intensa",
38 +
};
api/src/services/export/table.descriptors.ts
1 -
import { User } from "@prisma/client";
1 +
import { Mood, MoodComponent, User } from "@prisma/client";
2 2
3 +
import {
4 +
baseMoodLabel,
5 +
intensityLabel,
6 +
moodComponentLabel,
7 +
} from "@app/services/export/label.map";
3 8
import { CsvTable } from "@app/services/export/types";
4 9
5 10
const booleanLabel = (value: boolean): string => (value ? "Sim" : "Não");
6 11
7 12
export const profileTable: CsvTable<User> = {
19 24
{ header: "Lembrete diário", value: (user) => user.dailyReminderTime },
20 25
{ header: "Criado em", value: (user) => user.createdAt },
21 26
{ header: "Atualizado em", value: (user) => user.updatedAt },
22 27
],
23 28
};
29 +
30 +
export const moodTable: CsvTable<Mood> = {
31 +
filename: "moods.csv",
32 +
columns: [
33 +
{ header: "id", value: (mood) => mood.id },
34 +
{ header: "Humor", value: (mood) => baseMoodLabel[mood.selectedMood] },
35 +
{ header: "Ansiedade", value: (mood) => mood.anxietyLevel },
36 +
{ header: "Estresse", value: (mood) => mood.stressLevel },
37 +
{ header: "Energia", value: (mood) => mood.energyLevel },
38 +
{ header: "Momento", value: (mood) => mood.moment },
39 +
{ header: "Anotação", value: (mood) => mood.annotation },
40 +
],
41 +
};
42 +
43 +
export const moodComponentTable: CsvTable<MoodComponent> = {
44 +
filename: "mood_components.csv",
45 +
columns: [
46 +
{ header: "id", value: (component) => component.id },
47 +
{ header: "mood_id", value: (component) => component.moodId },
48 +
{
49 +
header: "Sentimento",
50 +
value: (component) => moodComponentLabel[component.component],
51 +
},
52 +
{
53 +
header: "Intensidade",
54 +
value: (component) => intensityLabel[component.intensity],
55 +
},
56 +
],
57 +
};
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'
32 32
const [attachment] = payload.attachments!
33 33
34 34
return { payload, attachment }
35 35
}
36 36
37 +
const csvLines = (csv: string): string[] =>
38 +
csv.slice(1).split('\r\n').filter(Boolean)
39 +
37 40
afterAll(() => db.$disconnect())
38 41
beforeEach(async () => {
39 42
await cleanupTestDb(db)
40 43
mockedSendEmail.mockClear()
41 44
mockedSendEmail.mockResolvedValue({ data: { id: 'fake-email-id' }, error: null } as any)
57 60
expect(payload.to).toBe('export@example.com')
58 61
expect(payload.subject).toBe('Seus dados do nexo')
59 62
expect(attachment.filename).toMatch(/^nexo-dados-\d{4}-\d{2}-\d{2}\.zip$/)
60 63
61 64
const zip = await JSZip.loadAsync(attachment.content)
62 -
expect(Object.keys(zip.files).sort()).toEqual(['leia-me.md', 'profile.csv'])
65 +
expect(Object.keys(zip.files).sort()).toEqual([
66 +
'leia-me.md',
67 +
'mood_components.csv',
68 +
'moods.csv',
69 +
'profile.csv',
70 +
])
63 71
64 72
const csv = await zip.file('profile.csv')!.async('string')
65 73
expect(csv.startsWith('\ufeff')).toBe(true)
66 74
67 -
const lines = csv.slice(1).split('\r\n').filter(Boolean)
75 +
const lines = csvLines(csv)
68 76
expect(lines).toHaveLength(2)
69 77
expect(lines[0]).toContain('Nome')
70 78
expect(lines[0]).toContain('Email')
71 79
72 80
const [row] = lines.slice(1)
85 93
86 94
const manifest = await zip.file('leia-me.md')!.async('string')
87 95
expect(manifest).toContain('`profile.csv`')
88 96
expect(manifest).toContain('1 registro')
89 97
expect(manifest).toContain('_id')
98 +
})
99 +
100 +
it('exports mood entries and their components with pt-BR labels', async () => {
101 +
const user = await createTestUser(db, { email: 'moods@example.com' })
102 +
const firstMood = await db.mood.create({
103 +
data: {
104 +
userId: user.id,
105 +
selectedMood: 'GREAT',
106 +
anxietyLevel: 1,
107 +
stressLevel: 2,
108 +
energyLevel: 9,
109 +
moment: new Date('2026-03-05T10:15:00.000Z'),
110 +
annotation: 'Bom, "ótimo"\ndia',
111 +
moodComponents: {
112 +
create: [{ component: 'JOY', intensity: 'HIGH' }],
113 +
},
114 +
},
115 +
include: { moodComponents: true },
116 +
})
117 +
const secondMood = await db.mood.create({
118 +
data: {
119 +
userId: user.id,
120 +
selectedMood: 'SAD',
121 +
anxietyLevel: 6,
122 +
stressLevel: 7,
123 +
energyLevel: 3,
124 +
moment: new Date('2026-03-06T21:40:00.000Z'),
125 +
moodComponents: {
126 +
create: [{ component: 'GRATITUDE', intensity: 'MODERATE' }],
127 +
},
128 +
},
129 +
include: { moodComponents: true },
130 +
})
131 +
132 +
await exportProcessor(buildJob({ userId: user.id }))
133 +
134 +
const { attachment } = await attachmentFromLastEmail()
135 +
const zip = await JSZip.loadAsync(attachment.content)
136 +
137 +
const moodsCsv = await zip.file('moods.csv')!.async('string')
138 +
const moodLines = csvLines(moodsCsv)
139 +
expect(moodLines[0]).toContain('Humor')
140 +
expect(moodLines[0]).toContain('Anotação')
141 +
expect(moodLines[1]).toContain('"Ótimo"')
142 +
expect(moodLines[1]).toContain('"Bom, ""ótimo""\ndia"')
143 +
expect(moodLines[2]).toContain('"Triste"')
144 +
145 +
const componentsCsv = await zip.file('mood_components.csv')!.async('string')
146 +
const componentLines = csvLines(componentsCsv)
147 +
expect(componentLines[0]).toContain('Sentimento')
148 +
expect(componentLines[0]).toContain('Intensidade')
149 +
150 +
const moodIdFor = new Map(
151 +
[...firstMood.moodComponents, ...secondMood.moodComponents].map((component) => [
152 +
component.id,
153 +
component.moodId,
154 +
]),
155 +
)
156 +
const header = componentLines[0].split(',').map((field) => field.replace(/"/g, ''))
157 +
const idIndex = header.indexOf('id')
158 +
const moodIdIndex = header.indexOf('mood_id')
159 +
160 +
const componentRows = componentLines.slice(1)
161 +
expect(componentRows).toHaveLength(2)
162 +
for (const row of componentRows) {
163 +
const fields = row.split(',')
164 +
expect(Number(fields[moodIdIndex])).toBe(moodIdFor.get(Number(fields[idIndex])))
165 +
}
166 +
expect(componentsCsv).toContain('"Alegria"')
167 +
expect(componentsCsv).toContain('"Intensa"')
168 +
expect(componentsCsv).toContain('"Gratidão"')
169 +
expect(componentsCsv).toContain('"Moderada"')
170 +
171 +
const manifest = await zip.file('leia-me.md')!.async('string')
172 +
expect(manifest).toContain('`moods.csv` — 2 registros')
173 +
expect(manifest).toContain('`mood_components.csv` — 2 registros')
90 174
})
91 175
92 176
it('preserves commas, quotes and newlines inside a field', async () => {
93 177
const user = await createTestUser(db, {
94 178
email: 'quoted@example.com',
135 219
await expect(
136 220
exportProcessor({ name: 'export:unknown', data: {} } as any)
137 221
).rejects.toThrow('Unknown export job')
138 222
})
139 223
})