all/data-export: deliver the archive by email
Tapping the export button now confirms against your own address and asks the API to build a zip file of your data, which a background job emails as an attachment and retries, mailing a failure notice only on the last attempt. The row previously showed a toast promising an email while no endpoint, job, or file existed
Pedro Lucas Porcellis porcellis@eletrotupi.com · 24 days ago
19821da369359c6ba1cc9b4afa45b47d0e72f116 Browse filesParents
0401453Changed files
25
Additions
+682
Deletions
-12
api/package-lock.json
1 1
{
2 2
"name": "orbit-api",
3 3
"version": "0.0.0",
4 4
"lockfileVersion": 3,
5 5
"requires": true,
19 19
"bcryptjs": "^3.0.3",
20 20
"better-sqlite3": "^12.5.0",
21 21
"body-parser": "^2.2.2",
22 22
"bullmq": "^5.76.7",
23 23
"cors": "^2.8.6",
24 +
"csv-stringify": "^6.9.0",
24 25
"date-fns": "^4.1.0",
25 26
"express": "^5.2.1",
26 27
"jsonwebtoken": "^9.0.3",
28 +
"jszip": "^3.10.2",
27 29
"morgan": "^1.10.1",
28 30
"multer": "^2.1.1",
29 31
"multer-s3": "^3.0.1",
30 32
"pg": "^8.20.0",
31 33
"pino": "^10.3.1",
4311 4313
"deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.",
4312 4314
"dev": true,
4313 4315
"hasInstallScript": true,
4314 4316
"license": "MIT"
4315 4317
},
4318 +
"node_modules/core-util-is": {
4319 +
"version": "1.0.3",
4320 +
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
4321 +
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
4322 +
"license": "MIT"
4323 +
},
4316 4324
"node_modules/cors": {
4317 4325
"version": "2.8.6",
4318 4326
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
4319 4327
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
4320 4328
"license": "MIT",
4443 4451
"node_modules/csstype": {
4444 4452
"version": "3.2.3",
4445 4453
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
4446 4454
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
4447 4455
"devOptional": true,
4456 +
"license": "MIT"
4457 +
},
4458 +
"node_modules/csv-stringify": {
4459 +
"version": "6.9.0",
4460 +
"resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.9.0.tgz",
4461 +
"integrity": "sha512-6b3e29CILFfSQ2nDRuZMRROzQOpPuJ+s8zPB5H7eK5ncXBbFOyVuRLU5Sx/zAUmVri3Ly49GyC8NEme4SVSAbA==",
4448 4462
"license": "MIT"
4449 4463
},
4450 4464
"node_modules/date-fns": {
4451 4465
"version": "4.1.0",
4452 4466
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
5652 5666
"url": "https://feross.org/support"
5653 5667
}
5654 5668
],
5655 5669
"license": "BSD-3-Clause"
5656 5670
},
5671 +
"node_modules/immediate": {
5672 +
"version": "3.0.6",
5673 +
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
5674 +
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
5675 +
"license": "MIT"
5676 +
},
5657 5677
"node_modules/inherits": {
5658 5678
"version": "2.0.4",
5659 5679
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
5660 5680
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
5661 5681
"license": "ISC"
5785 5805
"version": "1.2.4",
5786 5806
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
5787 5807
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
5788 5808
"license": "MIT"
5789 5809
},
5810 +
"node_modules/isarray": {
5811 +
"version": "1.0.0",
5812 +
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
5813 +
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
5814 +
"license": "MIT"
5815 +
},
5790 5816
"node_modules/isexe": {
5791 5817
"version": "2.0.0",
5792 5818
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
5793 5819
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
5794 5820
"devOptional": true,
5865 5891
"engines": {
5866 5892
"node": ">=12",
5867 5893
"npm": ">=6"
5868 5894
}
5869 5895
},
5896 +
"node_modules/jszip": {
5897 +
"version": "3.10.2",
5898 +
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz",
5899 +
"integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==",
5900 +
"license": "(MIT OR GPL-3.0-or-later)",
5901 +
"dependencies": {
5902 +
"lie": "~3.3.0",
5903 +
"pako": "~1.0.2",
5904 +
"readable-stream": "~2.3.6",
5905 +
"setimmediate": "^1.0.5"
5906 +
}
5907 +
},
5908 +
"node_modules/jszip/node_modules/readable-stream": {
5909 +
"version": "2.3.8",
5910 +
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
5911 +
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
5912 +
"license": "MIT",
5913 +
"dependencies": {
5914 +
"core-util-is": "~1.0.0",
5915 +
"inherits": "~2.0.3",
5916 +
"isarray": "~1.0.0",
5917 +
"process-nextick-args": "~2.0.0",
5918 +
"safe-buffer": "~5.1.1",
5919 +
"string_decoder": "~1.1.1",
5920 +
"util-deprecate": "~1.0.1"
5921 +
}
5922 +
},
5923 +
"node_modules/jszip/node_modules/safe-buffer": {
5924 +
"version": "5.1.2",
5925 +
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
5926 +
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
5927 +
"license": "MIT"
5928 +
},
5929 +
"node_modules/jszip/node_modules/string_decoder": {
5930 +
"version": "1.1.1",
5931 +
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
5932 +
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
5933 +
"license": "MIT",
5934 +
"dependencies": {
5935 +
"safe-buffer": "~5.1.0"
5936 +
}
5937 +
},
5870 5938
"node_modules/jwa": {
5871 5939
"version": "2.0.1",
5872 5940
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
5873 5941
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
5874 5942
"license": "MIT",
5884 5952
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
5885 5953
"license": "MIT",
5886 5954
"dependencies": {
5887 5955
"jwa": "^2.0.1",
5888 5956
"safe-buffer": "^5.0.1"
5957 +
}
5958 +
},
5959 +
"node_modules/lie": {
5960 +
"version": "3.3.0",
5961 +
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
5962 +
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
5963 +
"license": "MIT",
5964 +
"dependencies": {
5965 +
"immediate": "~3.0.5"
5889 5966
}
5890 5967
},
5891 5968
"node_modules/lilconfig": {
5892 5969
"version": "2.1.0",
5893 5970
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
7242 7319
"license": "MIT",
7243 7320
"engines": {
7244 7321
"node": ">= 0.6"
7245 7322
}
7246 7323
},
7324 +
"node_modules/process-nextick-args": {
7325 +
"version": "2.0.1",
7326 +
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
7327 +
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
7328 +
"license": "MIT"
7329 +
},
7247 7330
"node_modules/process-warning": {
7248 7331
"version": "5.0.0",
7249 7332
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
7250 7333
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
7251 7334
"funding": [
7901 7984
"send": "^1.2.0"
7902 7985
},
7903 7986
"engines": {
7904 7987
"node": ">= 18"
7905 7988
}
7989 +
},
7990 +
"node_modules/setimmediate": {
7991 +
"version": "1.0.5",
7992 +
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
7993 +
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
7994 +
"license": "MIT"
7906 7995
},
7907 7996
"node_modules/setprototypeof": {
7908 7997
"version": "1.2.0",
7909 7998
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
7910 7999
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
9075 9164
"zod": "^3.25.0 || ^4.0.0"
9076 9165
}
9077 9166
}
9078 9167
}
9079 9168
}
api/package.json
1 1
{
2 2
"name": "orbit-api",
3 3
"version": "0.0.0",
4 4
"description": "",
5 5
"license": "ISC",
32 32
"bcryptjs": "^3.0.3",
33 33
"better-sqlite3": "^12.5.0",
34 34
"body-parser": "^2.2.2",
35 35
"bullmq": "^5.76.7",
36 36
"cors": "^2.8.6",
37 +
"csv-stringify": "^6.9.0",
37 38
"date-fns": "^4.1.0",
38 39
"express": "^5.2.1",
39 40
"jsonwebtoken": "^9.0.3",
41 +
"jszip": "^3.10.2",
40 42
"morgan": "^1.10.1",
41 43
"multer": "^2.1.1",
42 44
"multer-s3": "^3.0.1",
43 45
"pg": "^8.20.0",
44 46
"pino": "^10.3.1",
68 70
"typescript": "^6.0.2",
69 71
"vite-tsconfig-paths": "^5.1.4",
70 72
"vitest": "^3.0.0"
71 73
}
72 74
}
/dev/null → api/src/controllers/exports.ts
1 +
import { Response, NextFunction } from "express";
2 +
3 +
import { getQueue } from "@app/lib/queue";
4 +
import { ExportJobName } from "@app/lib/queue/types";
5 +
import { AuthenticatedRequest } from "@app/middleware/auth";
6 +
7 +
export const ExportsController = {
8 +
create: async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
9 +
try {
10 +
await getQueue("exports").add(ExportJobName.Data, {
11 +
userId: req.userId!,
12 +
});
13 +
14 +
return res.status(202).json({
15 +
message:
16 +
"Tudo certo! Você receberá seus dados por e-mail em instantes.",
17 +
});
18 +
} catch (err) {
19 +
next(err);
20 +
}
21 +
},
22 +
};
api/src/createApp.ts
1 1
import express from "express";
2 2
import cors from "cors";
3 3
import bodyParser from "body-parser";
4 4
import morgan from "morgan";
5 5
import mainRouter from "@app/routes/main";
9 9
import triggerRouter from "@app/routes/triggers";
10 10
import sleepRecordRouter from "@app/routes/sleepRecords";
11 11
import insightRouter from "@app/routes/insights";
12 12
import careActionsRouter from "@app/routes/careActions";
13 13
import medicineRegimensRouter from "@app/routes/medicineRegimens";
14 +
import exportsRouter from "@app/routes/exports";
14 15
import reportRouter from "@app/reports/report.router";
15 16
import { errorHandler } from "@app/middleware/errorHandler";
16 17
import { PrismaClient } from "@prisma/client";
17 18
import {
18 19
bootWorkers,
65 66
app.use("/sleep_records", sleepRecordRouter);
66 67
app.use("/triggers", triggerRouter);
67 68
app.use("/insights", insightRouter);
68 69
app.use("/care-actions", careActionsRouter);
69 70
app.use("/medicine-regimens", medicineRegimensRouter);
71 +
app.use("/exports", exportsRouter);
70 72
app.use("/reports", reportRouter);
71 73
72 74
app.use(errorHandler); // always last
73 75
74 76
// TODO: flesh the actual page here
85 87
86 88
const shutdown = async () => {
87 89
await Promise.all([closeAllWorkers(), closeAllQueues()]);
88 90
process.exit(0);
89 91
};
api/src/lib/queue/QueueRegistry.ts
1 1
import { Queue, QueueOptions } from "bullmq";
2 2
3 -
export type QueueName = "mail" | "insights" | "medicine-reminders" | "daily-reminders";
3 +
export type QueueName =
4 +
| "mail"
5 +
| "insights"
6 +
| "medicine-reminders"
7 +
| "daily-reminders"
8 +
| "exports";
4 9
5 10
const queues = new Map<QueueName, Queue>();
6 11
7 12
const defaultOpts: QueueOptions = {
8 13
connection: {
26 31
}
27 32
28 33
export async function closeAllQueues(): Promise<void> {
29 34
await Promise.all([...queues.values()].map((q) => q.close()));
30 35
}
api/src/lib/queue/WorkerRegistry.ts
1 1
import { Worker, WorkerOptions, Processor } from "bullmq";
2 2
import { getQueue, QueueName } from "@app/lib/queue/QueueRegistry";
3 3
import { mailProcessor } from "@app/lib/queue/processors/mail";
4 4
import { insightProcessor } from "@app/lib/queue/processors/insights";
5 5
import { medicineReminderProcessor } from "@app/lib/queue/processors/medicineReminders";
6 6
import { dailyReminderProcessor } from "@app/lib/queue/processors/dailyReminders";
7 +
import { exportProcessor } from "@app/lib/queue/processors/exports";
7 8
import { InsightJobName } from "@app/lib/queue/types";
8 9
9 10
type ProcessorMap = Record<QueueName, Processor>;
10 11
11 12
const processorMap: ProcessorMap = {
12 13
mail: mailProcessor,
13 14
insights: insightProcessor,
14 15
"medicine-reminders": medicineReminderProcessor,
15 16
"daily-reminders": dailyReminderProcessor,
17 +
exports: exportProcessor,
16 18
};
17 19
18 20
// Per-queue concurrency
19 21
// TODO: When we process images, we need to bump for that queue
20 22
const concurrencyMap: Record<QueueName, number> = {
21 23
mail: 10,
22 24
insights: 5,
23 25
"medicine-reminders": 5,
24 26
"daily-reminders": 5,
27 +
exports: 5,
25 28
};
26 29
27 30
const workers: Worker[] = [];
28 31
29 32
export function bootWorkers(): void {
65 68
}
66 69
67 70
export async function closeAllWorkers(): Promise<void> {
68 71
await Promise.all(workers.map((w) => w.close()));
69 72
}
/dev/null → api/src/lib/queue/processors/exports.ts
1 +
import { Job } from "bullmq";
2 +
3 +
import {
4 +
ExportJobData,
5 +
ExportJobName,
6 +
ExportPayload,
7 +
} from "@app/lib/queue/types";
8 +
import { buildExportArchive } from "@app/services/export/export.service";
9 +
import {
10 +
sendExportFailedEmail,
11 +
sendExportReadyEmail,
12 +
} from "@app/services/mail";
13 +
14 +
export async function exportProcessor(
15 +
job: Job<ExportJobData["data"]>,
16 +
): Promise<void> {
17 +
switch (job.name as ExportJobName) {
18 +
case ExportJobName.Data: {
19 +
const { userId } = job.data as ExportPayload;
20 +
21 +
try {
22 +
const archive = await buildExportArchive(userId);
23 +
await sendExportReadyEmail(userId, archive);
24 +
} catch (err) {
25 +
// Only the last attempt notifies the user; earlier ones are retried
26 +
const attempts = job.opts.attempts ?? 1;
27 +
const isFinalAttempt = job.attemptsMade + 1 >= attempts;
28 +
29 +
if (isFinalAttempt) {
30 +
try {
31 +
await sendExportFailedEmail(userId);
32 +
} catch (mailErr) {
33 +
console.error(
34 +
`[exports] failed to send the failure email for user ${userId}:`,
35 +
mailErr,
36 +
);
37 +
}
38 +
}
39 +
40 +
throw err;
41 +
}
42 +
43 +
break;
44 +
}
45 +
46 +
default:
47 +
throw new Error(`Unknown export job: ${job.name}`);
48 +
}
49 +
}
api/src/lib/queue/types.ts
1 1
// Mail queue
2 2
export enum MailJobName {
3 3
WelcomeEmail = "mail:welcome",
4 4
PasswordReset = "mail:password-reset",
5 5
ActivateAccountEmail = "mail:activate-account",
62 62
}
63 63
64 64
export type DailyReminderPayload = {
65 65
userId: number;
66 66
};
67 +
68 +
// Export queue
69 +
export enum ExportJobName {
70 +
Data = "export:data",
71 +
}
72 +
73 +
export type ExportPayload = {
74 +
userId: number;
75 +
};
76 +
77 +
export type ExportJobData = {
78 +
name: ExportJobName.Data;
79 +
data: ExportPayload;
80 +
};
/dev/null → api/src/routes/exports.ts
1 +
import { Router } from "express";
2 +
3 +
import { ExportsController } from "@app/controllers/exports";
4 +
import { requireAuth } from "@app/middleware/auth";
5 +
6 +
const router = Router();
7 +
8 +
router.post("/", requireAuth, ExportsController.create);
9 +
10 +
export default router;
/dev/null → api/src/services/export/csv.serializer.ts
1 +
import { stringify } from "csv-stringify/sync";
2 +
3 +
import { CsvTable, CsvValue, ExportFile } from "@app/services/export/types";
4 +
5 +
// Dates become ISO-8601 strings so they are quoted like any other string and
6 +
// sort unambiguously regardless of the reader's locale.
7 +
const normalize = (value: CsvValue): string | number | null => {
8 +
if (value === null || value === undefined) return null;
9 +
if (value instanceof Date) return value.toISOString();
10 +
11 +
return value;
12 +
};
13 +
14 +
export function serializeCsv<Row>(table: CsvTable<Row>, rows: Row[]): ExportFile {
15 +
const records = rows.map((row) => {
16 +
const record: Record<string, string | number | null> = {};
17 +
18 +
for (const column of table.columns) {
19 +
record[column.header] = normalize(column.value(row));
20 +
}
21 +
22 +
return record;
23 +
});
24 +
25 +
const body = stringify(records, {
26 +
header: true,
27 +
columns: table.columns.map((column) => column.header),
28 +
record_delimiter: "\r\n",
29 +
quoted_string: true,
30 +
});
31 +
32 +
return {
33 +
filename: table.filename,
34 +
content: Buffer.from(`\ufeff${body}`, "utf8"),
35 +
rows: rows.length,
36 +
};
37 +
}
/dev/null → api/src/services/export/export.service.ts
1 +
import { findUserById } from "@app/services/user.service";
2 +
import { serializeCsv } from "@app/services/export/csv.serializer";
3 +
import { buildManifest } from "@app/services/export/manifest.builder";
4 +
import { profileTable } from "@app/services/export/table.descriptors";
5 +
import { buildZip } from "@app/services/export/zip.builder";
6 +
import { ExportArchive, ExportFile } from "@app/services/export/types";
7 +
8 +
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 +
15 +
const generatedAt = new Date();
16 +
17 +
const files: ExportFile[] = [serializeCsv(profileTable, [user])];
18 +
const manifest = buildManifest(files, generatedAt);
19 +
20 +
return {
21 +
filename: `nexo-dados-${generatedAt.toISOString().slice(0, 10)}.zip`,
22 +
buffer: await buildZip([...files, manifest]),
23 +
};
24 +
}
/dev/null → api/src/services/export/manifest.builder.ts
1 +
import { ExportFile } from "@app/services/export/types";
2 +
3 +
const countLabel = (rows: number): string =>
4 +
rows === 1 ? "1 registro" : `${rows} registros`;
5 +
6 +
export function buildManifest(files: ExportFile[], generatedAt: Date): ExportFile {
7 +
const fileLines = files.map(
8 +
(file) => `- \`${file.filename}\` — ${countLabel(file.rows)}`,
9 +
);
10 +
11 +
const content = [
12 +
"# Seus dados do nexo",
13 +
"",
14 +
`Arquivo gerado em ${generatedAt.toISOString()}.`,
15 +
"",
16 +
"Este pacote reúne os seus dados do nexo, com um arquivo CSV para cada tipo",
17 +
"de registro.",
18 +
"",
19 +
"## Arquivos",
20 +
"",
21 +
...fileLines,
22 +
"",
23 +
"## Como ler os arquivos",
24 +
"",
25 +
"As colunas `id` e as terminadas em `_id` são identificadores técnicos. Elas",
26 +
"ligam os registros entre si: por exemplo, um `mood_id` aponta para o `id` do",
27 +
"registro correspondente em `moods.csv`. Mantivemos esses nomes para que você",
28 +
"consiga cruzar as tabelas.",
29 +
"",
30 +
].join("\n");
31 +
32 +
return {
33 +
filename: "leia-me.md",
34 +
content: Buffer.from(content, "utf8"),
35 +
rows: 0,
36 +
};
37 +
}
/dev/null → api/src/services/export/table.descriptors.ts
1 +
import { User } from "@prisma/client";
2 +
3 +
import { CsvTable } from "@app/services/export/types";
4 +
5 +
const booleanLabel = (value: boolean): string => (value ? "Sim" : "Não");
6 +
7 +
export const profileTable: CsvTable<User> = {
8 +
filename: "profile.csv",
9 +
columns: [
10 +
{ header: "id", value: (user) => user.id },
11 +
{ header: "Nome", value: (user) => user.firstName },
12 +
{ header: "Sobrenome", value: (user) => user.lastName },
13 +
{ header: "Email", value: (user) => user.email },
14 +
{ header: "Ativo", value: (user) => booleanLabel(user.active) },
15 +
{
16 +
header: "Notificações",
17 +
value: (user) => booleanLabel(user.notificationsEnabled),
18 +
},
19 +
{ header: "Lembrete diário", value: (user) => user.dailyReminderTime },
20 +
{ header: "Criado em", value: (user) => user.createdAt },
21 +
{ header: "Atualizado em", value: (user) => user.updatedAt },
22 +
],
23 +
};
/dev/null → api/src/services/export/types.ts
1 +
export type CsvValue = string | number | Date | null | undefined;
2 +
3 +
export type CsvColumn<Row> = {
4 +
header: string;
5 +
value: (row: Row) => CsvValue;
6 +
};
7 +
8 +
export type CsvTable<Row> = {
9 +
filename: string;
10 +
columns: CsvColumn<Row>[];
11 +
};
12 +
13 +
export type ExportFile = {
14 +
filename: string;
15 +
content: Buffer;
16 +
rows: number;
17 +
};
18 +
19 +
export type ExportArchive = {
20 +
filename: string;
21 +
buffer: Buffer;
22 +
};
/dev/null → api/src/services/export/zip.builder.ts
1 +
import JSZip from "jszip";
2 +
3 +
import { ExportFile } from "@app/services/export/types";
4 +
5 +
export async function buildZip(files: ExportFile[]): Promise<Buffer> {
6 +
const zip = new JSZip();
7 +
8 +
for (const file of files) {
9 +
zip.file(file.filename, file.content);
10 +
}
11 +
12 +
return zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" });
13 +
}
api/src/services/mail/index.ts
1 1
export { sendWelcomeEmail } from '@app/services/mail/welcome';
2 2
export { sendResetPasswordEmail } from '@app/services/mail/sendPasswordReset'
3 3
export { sendActivateAccountEmail } from '@app/services/mail/sendActivateAccountEmail';
4 +
export { sendExportReadyEmail } from '@app/services/mail/sendExportReady';
5 +
export { sendExportFailedEmail } from '@app/services/mail/sendExportFailed';
/dev/null → api/src/services/mail/sendExportFailed.ts
1 +
import { sendEmail } from '@app/lib/mail';
2 +
import { findUserById } from '@app/services/user.service';
3 +
4 +
const email = (firstName: string) => (`
5 +
Oi, ${firstName}.
6 +
7 +
Infelizmente não conseguimos preparar o arquivo com os seus dados do nexo
8 +
agora. Nada se perdeu — é só tentar de novo pela opção "Exportar Meus Dados"
9 +
nas configurações.
10 +
11 +
Qualquer coisa prende o grito!
12 +
`);
13 +
14 +
export async function sendExportFailedEmail(userId: number): Promise<void> {
15 +
const user = await findUserById(userId);
16 +
17 +
const { error } = await sendEmail({
18 +
to: user!.email,
19 +
subject: 'Não conseguimos exportar seus dados',
20 +
text: email(user!.firstName),
21 +
});
22 +
23 +
if (error) {
24 +
console.error("Error sending export failure email: ", error);
25 +
throw new Error("Não foi possível enviar o e-mail de falha da exportação");
26 +
}
27 +
}
/dev/null → api/src/services/mail/sendExportReady.ts
1 +
import { sendEmail } from '@app/lib/mail';
2 +
import { findUserById } from '@app/services/user.service';
3 +
4 +
const email = (firstName: string) => (`
5 +
Oi, ${firstName}!
6 +
7 +
Preparamos um arquivo com os seus dados do nexo e anexamos a este e-mail. Ele
8 +
é um .zip com os seus dados em CSV, além de um "leia-me" explicando o conteúdo.
9 +
10 +
Guarde com carinho — agora ele é seu.
11 +
12 +
Qualquer coisa prende o grito!
13 +
`);
14 +
15 +
export async function sendExportReadyEmail(
16 +
userId: number,
17 +
attachment: { filename: string; buffer: Buffer },
18 +
): Promise<void> {
19 +
const user = await findUserById(userId);
20 +
21 +
const { error } = await sendEmail({
22 +
to: user!.email,
23 +
subject: 'Seus dados do nexo',
24 +
text: email(user!.firstName),
25 +
attachments: [{ filename: attachment.filename, content: attachment.buffer }],
26 +
});
27 +
28 +
if (error) {
29 +
console.error("Error sending export email: ", error);
30 +
throw new Error("Não foi possível enviar o e-mail com seus dados");
31 +
}
32 +
}
/dev/null → api/tests/exports/create.test.ts
1 +
import { describe, it, expect, afterAll, beforeEach } from 'vitest'
2 +
import request from 'supertest'
3 +
import { createApp } from '@app/createApp'
4 +
import { ExportJobName } from '@app/lib/queue/types'
5 +
import { buildPrisma, cleanupTestDb, createTestUser, generateTestToken } from '../test-helper'
6 +
import { recordedJobs, resetQueueMocks } from '../mocks/queue'
7 +
8 +
const app = createApp()
9 +
const db = buildPrisma()
10 +
11 +
afterAll(() => db.$disconnect())
12 +
beforeEach(async () => {
13 +
await cleanupTestDb(db)
14 +
15 +
resetQueueMocks()
16 +
})
17 +
18 +
describe('POST /exports', () => {
19 +
it('returns 401 without a token and enqueues nothing', async () => {
20 +
const res = await request(app).post('/exports')
21 +
22 +
expect(res.status).toBe(401)
23 +
expect(recordedJobs).toHaveLength(0)
24 +
})
25 +
26 +
it('enqueues exactly one export job for the authenticated user', async () => {
27 +
const user = await createTestUser(db)
28 +
const token = generateTestToken(user.id, user.email)
29 +
30 +
const res = await request(app)
31 +
.post('/exports')
32 +
.set('Authorization', `Bearer ${token}`)
33 +
34 +
expect(res.status).toBe(202)
35 +
expect(res.body.message).toBeTruthy()
36 +
37 +
expect(recordedJobs).toHaveLength(1)
38 +
expect(recordedJobs[0]).toEqual(
39 +
expect.objectContaining({
40 +
queue: 'exports',
41 +
name: ExportJobName.Data,
42 +
data: { userId: user.id },
43 +
})
44 +
)
45 +
})
46 +
})
/dev/null → api/tests/exports/format.test.ts
1 +
import { describe, it, expect } from 'vitest'
2 +
import { serializeCsv } from '@app/services/export/csv.serializer'
3 +
import { buildManifest } from '@app/services/export/manifest.builder'
4 +
import { profileTable } from '@app/services/export/table.descriptors'
5 +
6 +
describe('serializeCsv', () => {
7 +
it('keeps the header row and a BOM when the table has no rows', () => {
8 +
const file = serializeCsv(profileTable, [])
9 +
const text = file.content.toString('utf8')
10 +
11 +
expect(text.startsWith('\ufeff')).toBe(true)
12 +
expect(file.rows).toBe(0)
13 +
14 +
const lines = text.slice(1).split('\r\n').filter(Boolean)
15 +
expect(lines).toHaveLength(1)
16 +
expect(lines[0]).toContain('Email')
17 +
})
18 +
})
19 +
20 +
describe('buildManifest', () => {
21 +
it('states the generation time and lists each file with its row count', () => {
22 +
const manifest = buildManifest(
23 +
[{ filename: 'profile.csv', content: Buffer.from(''), rows: 0 }],
24 +
new Date('2026-10-04T12:00:00.000Z'),
25 +
)
26 +
const text = manifest.content.toString('utf8')
27 +
28 +
expect(manifest.filename).toBe('leia-me.md')
29 +
expect(text).toContain('2026-10-04T12:00:00.000Z')
30 +
expect(text).toContain('`profile.csv` — 0 registros')
31 +
expect(text).toContain('_id')
32 +
})
33 +
})
/dev/null → api/tests/jobs/export-data.test.ts
1 +
import { describe, it, expect, afterAll, beforeEach, vi } from 'vitest'
2 +
import JSZip from 'jszip'
3 +
import { exportProcessor } from '@app/lib/queue/processors/exports'
4 +
import { ExportJobName } from '@app/lib/queue/types'
5 +
import { buildPrisma, cleanupTestDb, createTestUser } from '../test-helper'
6 +
7 +
// Intercept all Resend calls
8 +
vi.mock('@app/lib/mail', () => ({
9 +
sendEmail: vi.fn().mockResolvedValue({
10 +
data: { id: 'fake-email-id' },
11 +
error: null,
12 +
}),
13 +
}))
14 +
15 +
import { sendEmail } from '@app/lib/mail'
16 +
const mockedSendEmail = vi.mocked(sendEmail)
17 +
18 +
const db = buildPrisma()
19 +
20 +
const buildJob = (data: unknown, overrides: Record<string, unknown> = {}) =>
21 +
({
22 +
name: ExportJobName.Data,
23 +
data,
24 +
attemptsMade: 0,
25 +
opts: { attempts: 3 },
26 +
...overrides,
27 +
}) as any
28 +
29 +
async function attachmentFromLastEmail() {
30 +
const calls = mockedSendEmail.mock.calls
31 +
const [payload] = calls[calls.length - 1]
32 +
const [attachment] = payload.attachments!
33 +
34 +
return { payload, attachment }
35 +
}
36 +
37 +
afterAll(() => db.$disconnect())
38 +
beforeEach(async () => {
39 +
await cleanupTestDb(db)
40 +
mockedSendEmail.mockClear()
41 +
mockedSendEmail.mockResolvedValue({ data: { id: 'fake-email-id' }, error: null } as any)
42 +
})
43 +
44 +
describe('exportProcessor', () => {
45 +
it('emails a zip with the profile CSV and a manifest', async () => {
46 +
const user = await createTestUser(db, {
47 +
email: 'export@example.com',
48 +
firstName: 'Ana',
49 +
lastName: 'Silva',
50 +
})
51 +
52 +
await exportProcessor(buildJob({ userId: user.id }))
53 +
54 +
expect(mockedSendEmail).toHaveBeenCalledOnce()
55 +
56 +
const { payload, attachment } = await attachmentFromLastEmail()
57 +
expect(payload.to).toBe('export@example.com')
58 +
expect(payload.subject).toBe('Seus dados do nexo')
59 +
expect(attachment.filename).toMatch(/^nexo-dados-\d{4}-\d{2}-\d{2}\.zip$/)
60 +
61 +
const zip = await JSZip.loadAsync(attachment.content)
62 +
expect(Object.keys(zip.files).sort()).toEqual(['leia-me.md', 'profile.csv'])
63 +
64 +
const csv = await zip.file('profile.csv')!.async('string')
65 +
expect(csv.startsWith('\ufeff')).toBe(true)
66 +
67 +
const lines = csv.slice(1).split('\r\n').filter(Boolean)
68 +
expect(lines).toHaveLength(2)
69 +
expect(lines[0]).toContain('Nome')
70 +
expect(lines[0]).toContain('Email')
71 +
72 +
const [row] = lines.slice(1)
73 +
expect(row).toContain('"Ana"')
74 +
expect(row).toContain('"Silva"')
75 +
expect(row).toContain('"export@example.com"')
76 +
expect(row).toContain('"Sim"') // notifications default to enabled
77 +
expect(row).toContain('"Não"') // freshly created users are inactive
78 +
79 +
// Secrets must never travel in the export
80 +
const lower = csv.toLowerCase()
81 +
expect(lower).not.toContain('senha')
82 +
expect(lower).not.toContain('token')
83 +
expect(lower).not.toContain('push')
84 +
expect(lower).not.toContain('avatar')
85 +
86 +
const manifest = await zip.file('leia-me.md')!.async('string')
87 +
expect(manifest).toContain('`profile.csv`')
88 +
expect(manifest).toContain('1 registro')
89 +
expect(manifest).toContain('_id')
90 +
})
91 +
92 +
it('preserves commas, quotes and newlines inside a field', async () => {
93 +
const user = await createTestUser(db, {
94 +
email: 'quoted@example.com',
95 +
lastName: 'Silva, "S"\nJunior',
96 +
})
97 +
98 +
await exportProcessor(buildJob({ userId: user.id }))
99 +
100 +
const { attachment } = await attachmentFromLastEmail()
101 +
const zip = await JSZip.loadAsync(attachment.content)
102 +
const csv = await zip.file('profile.csv')!.async('string')
103 +
104 +
expect(csv).toContain('"Silva, ""S""\nJunior"')
105 +
})
106 +
107 +
it('sends the failure email only on the final attempt', async () => {
108 +
const user = await createTestUser(db, { email: 'fail@example.com' })
109 +
mockedSendEmail
110 +
.mockRejectedValueOnce(new Error('boom'))
111 +
.mockResolvedValueOnce({ data: { id: 'fake-email-id' }, error: null })
112 +
113 +
await expect(
114 +
exportProcessor(buildJob({ userId: user.id }, { attemptsMade: 2, opts: { attempts: 3 } }))
115 +
).rejects.toThrow('boom')
116 +
117 +
expect(mockedSendEmail).toHaveBeenCalledTimes(2)
118 +
expect(mockedSendEmail.mock.calls[1][0].subject).toBe(
119 +
'Não conseguimos exportar seus dados'
120 +
)
121 +
})
122 +
123 +
it('does not send the failure email on a non-final attempt', async () => {
124 +
const user = await createTestUser(db, { email: 'retry@example.com' })
125 +
mockedSendEmail.mockRejectedValueOnce(new Error('boom'))
126 +
127 +
await expect(
128 +
exportProcessor(buildJob({ userId: user.id }, { attemptsMade: 0, opts: { attempts: 3 } }))
129 +
).rejects.toThrow('boom')
130 +
131 +
expect(mockedSendEmail).toHaveBeenCalledOnce()
132 +
})
133 +
134 +
it('throws for unknown job names', async () => {
135 +
await expect(
136 +
exportProcessor({ name: 'export:unknown', data: {} } as any)
137 +
).rejects.toThrow('Unknown export job')
138 +
})
139 +
})
frontend/app/(tabs)/settings.tsx
1 1
import {
2 2
TouchableOpacity,
3 3
StyleSheet,
4 4
SectionList,
5 5
View,
6 -
Alert,
7 6
Switch,
8 7
Pressable,
9 8
} from "react-native";
10 9
import { Text } from '@/components/ui/Text';
11 10
import { useState } from "react";
20 19
import { Button } from "@/components/ui/Button";
21 20
import { ConfirmModal } from "@/components/ui/ConfirmModal";
22 21
import { TimePicker } from "@/components/ui/TimePicker";
23 22
import { usePatchUserMe } from "@/hooks/useUserPreferences.queries";
24 23
import { useToast } from '@/context/ToastContext';
24 +
import { apiClient } from "@/lib/api";
25 25
26 26
export default function Settings() {
27 27
const { user, logout, updateAuthUser } = useAuth();
28 28
const [allowWeeklyInsights, setAllowWeeklyInsights] = useState(false);
29 29
const [logoutVisible, setLogoutVisible] = useState(false);
30 +
const [exportVisible, setExportVisible] = useState(false);
31 +
const [exporting, setExporting] = useState(false);
30 32
const { showToast } = useToast();
31 33
32 34
const notificationsEnabled = user?.notificationsEnabled ?? true;
33 35
const dailyReminderTime = user?.dailyReminderTime ?? "20:00";
34 36
56 58
};
57 59
58 60
const handleInvisibleMode = () => {};
59 61
60 62
const handleDataExport = () => {
61 -
Alert.alert("Exportar dados", "Deseja exportar seus dados?", [
62 -
{ text: "Cancelar", style: "cancel" },
63 -
{
64 -
text: "Exportar",
65 -
style: "destructive",
66 -
onPress: () => {
67 -
showToast("Você receberá um e-mail com os dados exportados", "success")
68 -
},
69 -
},
70 -
]);
63 +
setExportVisible(true);
64 +
};
65 +
66 +
const handleExportConfirm = async () => {
67 +
setExporting(true);
68 +
69 +
try {
70 +
const { message } = await apiClient.exportData();
71 +
72 +
setExportVisible(false);
73 +
showToast(message, "success");
74 +
} catch (error) {
75 +
showToast(
76 +
error instanceof Error
77 +
? error.message
78 +
: "Não foi possível exportar seus dados",
79 +
"error",
80 +
);
81 +
} finally {
82 +
setExporting(false);
83 +
}
71 84
};
72 85
73 86
return (
74 87
<ScreenLayout
75 88
userName={user.firstName}
328 341
onConfirm={() => {
329 342
setLogoutVisible(false);
330 343
logout();
331 344
}}
332 345
testID="logout-confirm"
346 +
/>
347 +
348 +
<ConfirmModal
349 +
visible={exportVisible}
350 +
title="Exportar meus dados"
351 +
message={
352 +
"Vamos preparar um arquivo com todos os seus dados e enviá-lo " +
353 +
"para o e-mail abaixo. Pode levar alguns instantes."
354 +
}
355 +
detail={user?.email}
356 +
confirmLabel="Exportar"
357 +
cancelLabel="Cancelar"
358 +
loading={exporting}
359 +
onCancel={() => setExportVisible(false)}
360 +
onConfirm={handleExportConfirm}
361 +
testID="export-confirm"
333 362
/>
334 363
</ScreenLayout>
335 364
);
336 365
}
337 366
370 399
height: StyleSheet.hairlineWidth,
371 400
backgroundColor: "#D1D5DB",
372 401
marginVertical: 16,
373 402
},
374 403
});
frontend/lib/api/client.ts
1 1
import * as SecureStore from "expo-secure-store";
2 2
3 3
export class ApiError extends Error {
4 4
status: number;
5 5
fields?: Record<string, string>;
49 49
PatchCareActionPayload,
50 50
TriggerMoodLink,
51 51
LinkMoodPayload,
52 52
AvatarResponse,
53 53
RemoveAvatarResponse,
54 +
ExportDataResponse,
54 55
} from "@/lib/api/types";
55 56
56 57
const API_BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL;
57 58
const TOKEN_KEY = process.env.EXPO_PUBLIC_TOKEN_KEY;
58 59
419 420
async patchUserMe(payload: UserPreferencesPayload): Promise<{ user: User }> {
420 421
return this.request('/users/me', {
421 422
method: 'PATCH',
422 423
body: JSON.stringify({ user: payload }),
423 424
});
425 +
}
426 +
427 +
async exportData(): Promise<ExportDataResponse> {
428 +
return this.request('/exports', { method: 'POST' });
424 429
}
425 430
426 431
// Care Actions
427 432
async getCareActions(
428 433
params?: {
513 518
return response.arrayBuffer()
514 519
}
515 520
}
516 521
517 522
export const apiClient = new ApiClient();
frontend/lib/api/index.ts
1 1
export { apiClient, ApiError } from "@/lib/api/client";
2 2
3 3
export type {
4 4
User,
5 5
AuthResponse,
37 37
MedicineRegimenResponse,
38 38
CreateMedicineRegimenPayload,
39 39
UpdateMedicineRegimenPayload,
40 40
TodayMedicineEntry,
41 41
UserPreferencesPayload,
42 +
ExportDataResponse,
42 43
UpdateTriggerPayload,
43 44
// Trigger-Mood linking
44 45
TriggerMoodLink,
45 46
TriggerMoodLinkWithMood,
46 47
TriggerMoodLinkWithTrigger,
47 48
LinkMoodPayload,
48 49
} from "@/lib/api/types";
frontend/lib/api/types.ts
1 1
export interface User {
2 2
id: number;
3 3
email: string;
4 4
firstName: string;
5 5
lastName?: string;
70 70
export interface UserPreferencesPayload {
71 71
notificationsEnabled?: boolean;
72 72
dailyReminderTime?: string | null;
73 73
}
74 74
75 +
export interface ExportDataResponse {
76 +
message: string;
77 +
}
78 +
75 79
// Mood entries
76 80
export interface MoodComponentPayload {
77 81
component: string;
78 82
intensity: string;
79 83
}
384 388
moodId?: number;
385 389
}
386 390
export interface MedicineRegimenResponse { regimen: MedicineRegimen; }
387 391
388 392
export interface LinkMoodPayload { moodId: number; perceivedImpact: number; }