eletrotupi / tcc/ commit / 8480619

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 files
Parents 19821da
Changed 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
})