eletrotupi / tcc/ commit / 19821da

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 files
Parents 0401453
Changed 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 modified+2
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; }