Skip to content

Commit dde757c

Browse files
committed
feat(compliance): merchant data export endpoint (#313)
- Add DataExportJob model + DataExportStatus enum to Prisma schema - Add migration 20260329140000_add_data_export_job - dataExport.service: enqueue + async process export (profile, payments summary, webhook logs); base64 payload stored on job record - dataExport.controller: merchant self-service + admin-triggered endpoints - dataExport.route: POST /merchants/export, GET /export/:jobId, GET /export/:jobId/download, admin variants under /admin/merchants/:id - Wire route into app.ts at /api/v1/merchants/export - 7 unit tests covering job creation, status polling, download guards (409 not-ready, 410 expired, 404 not-found)
1 parent c7c9cd5 commit dde757c

File tree

7 files changed

+619
-0
lines changed

7 files changed

+619
-0
lines changed
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
-- CreateEnum
2+
CREATE TYPE "DataExportStatus" AS ENUM ('pending', 'processing', 'completed', 'failed');
3+
4+
-- CreateTable
5+
CREATE TABLE "DataExportJob" (
6+
"id" TEXT NOT NULL,
7+
"merchantId" TEXT NOT NULL,
8+
"status" "DataExportStatus" NOT NULL DEFAULT 'pending',
9+
"payload" TEXT,
10+
"error" TEXT,
11+
"requested_by" TEXT NOT NULL,
12+
"expires_at" TIMESTAMP(3) NOT NULL,
13+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
14+
"updated_at" TIMESTAMP(3) NOT NULL,
15+
16+
CONSTRAINT "DataExportJob_pkey" PRIMARY KEY ("id")
17+
);
18+
19+
-- CreateIndex
20+
CREATE INDEX "DataExportJob_merchantId_idx" ON "DataExportJob"("merchantId");
21+
22+
-- AddForeignKey
23+
ALTER TABLE "DataExportJob" ADD CONSTRAINT "DataExportJob_merchantId_fkey"
24+
FOREIGN KEY ("merchantId") REFERENCES "Merchant"("id") ON DELETE CASCADE ON UPDATE CASCADE;

fluxapay_backend/prisma/schema.prisma

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ model Merchant {
5353
discrepancyThresholds DiscrepancyThreshold[]
5454
discrepancyAlerts DiscrepancyAlert[]
5555
customers Customer[]
56+
dataExportJobs DataExportJob[]
5657
}
5758

5859
model Customer {
@@ -398,6 +399,23 @@ model IdempotencyRecord {
398399
updated_at DateTime @updatedAt
399400
}
400401

402+
/// Async GDPR-style data export job for a merchant.
403+
model DataExportJob {
404+
id String @id @default(cuid())
405+
merchant Merchant @relation(fields: [merchantId], references: [id], onDelete: Cascade)
406+
merchantId String
407+
status DataExportStatus @default(pending)
408+
/// Base64-encoded JSON payload — populated when status = completed.
409+
payload String?
410+
error String?
411+
requested_by String // "merchant" | "admin:<adminId>"
412+
expires_at DateTime
413+
created_at DateTime @default(now())
414+
updated_at DateTime @updatedAt
415+
416+
@@index([merchantId])
417+
}
418+
401419
// ...existing code...
402420

403421
enum MerchantStatus {
@@ -504,3 +522,10 @@ enum AuditEntityType {
504522
sweep_operation
505523
settlement_batch
506524
}
525+
526+
enum DataExportStatus {
527+
pending
528+
processing
529+
completed
530+
failed
531+
}

fluxapay_backend/src/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import keysRoutes from "./routes/keys.route";
2424
import settlementBatchRoutes from "./routes/settlementBatch.route";
2525
import dashboardRoutes from "./routes/dashboard.route";
2626
import auditRoutes from "./routes/audit.route";
27+
import dataExportRoutes from "./routes/dataExport.route";
2728

2829
const app = express();
2930
const prisma = new PrismaClient();
@@ -86,6 +87,7 @@ app.use("/api/v1/admin/reconciliation", reconciliationRoutes);
8687
app.use("/api/v1/admin/settlement", settlementBatchRoutes);
8788
app.use("/api/v1/admin/sweep", sweepRoutes);
8889
app.use("/api/v1/admin", auditRoutes);
90+
app.use("/api/v1/merchants/export", dataExportRoutes);
8991

9092
// Basic health check
9193
app.get("/health", (req, res) => {
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { Response } from "express";
2+
import { AuthRequest } from "../types/express";
3+
import { validateUserId } from "../helpers/request.helper";
4+
import {
5+
requestDataExport,
6+
getExportJob,
7+
downloadExport,
8+
} from "../services/dataExport.service";
9+
10+
/**
11+
* POST /api/v1/merchants/export
12+
* Merchant self-service: enqueue a data export for the authenticated merchant.
13+
*/
14+
export async function requestExport(req: AuthRequest, res: Response) {
15+
try {
16+
const merchantId = await validateUserId(req);
17+
const result = await requestDataExport(merchantId, "merchant");
18+
res.status(202).json({
19+
message: "Export job queued. Poll /export/:jobId for status.",
20+
...result,
21+
});
22+
} catch (err: any) {
23+
res.status(err.status || 500).json({ message: err.message || "Server error" });
24+
}
25+
}
26+
27+
/**
28+
* GET /api/v1/merchants/export/:jobId
29+
* Poll job status.
30+
*/
31+
export async function getExportStatus(req: AuthRequest, res: Response) {
32+
try {
33+
const merchantId = await validateUserId(req);
34+
const job = await getExportJob(req.params.jobId, merchantId);
35+
res.json({
36+
jobId: job.id,
37+
status: job.status,
38+
expires_at: job.expires_at,
39+
error: job.error ?? undefined,
40+
});
41+
} catch (err: any) {
42+
res.status(err.status || 500).json({ message: err.message || "Server error" });
43+
}
44+
}
45+
46+
/**
47+
* GET /api/v1/merchants/export/:jobId/download
48+
* Download the completed export as JSON.
49+
*/
50+
export async function downloadExportHandler(req: AuthRequest, res: Response) {
51+
try {
52+
const merchantId = await validateUserId(req);
53+
const data = await downloadExport(req.params.jobId, merchantId);
54+
res.setHeader("Content-Disposition", `attachment; filename="export-${req.params.jobId}.json"`);
55+
res.json(data);
56+
} catch (err: any) {
57+
res.status(err.status || 500).json({ message: err.message || "Server error" });
58+
}
59+
}
60+
61+
/**
62+
* POST /api/v1/admin/merchants/:merchantId/export
63+
* Admin-triggered export on behalf of a merchant.
64+
*/
65+
export async function adminRequestExport(req: AuthRequest, res: Response) {
66+
try {
67+
const { merchantId } = req.params;
68+
const adminId = req.adminUser?.id ?? req.user?.id ?? "admin";
69+
const result = await requestDataExport(merchantId, `admin:${adminId}`);
70+
res.status(202).json({
71+
message: "Export job queued.",
72+
...result,
73+
});
74+
} catch (err: any) {
75+
res.status(err.status || 500).json({ message: err.message || "Server error" });
76+
}
77+
}
78+
79+
/**
80+
* GET /api/v1/admin/merchants/:merchantId/export/:jobId/download
81+
* Admin download of a completed export.
82+
*/
83+
export async function adminDownloadExport(req: AuthRequest, res: Response) {
84+
try {
85+
const { merchantId, jobId } = req.params;
86+
const data = await downloadExport(jobId, merchantId);
87+
res.setHeader("Content-Disposition", `attachment; filename="export-${jobId}.json"`);
88+
res.json(data);
89+
} catch (err: any) {
90+
res.status(err.status || 500).json({ message: err.message || "Server error" });
91+
}
92+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { Router } from "express";
2+
import { authenticateToken } from "../middleware/auth.middleware";
3+
import { authenticateApiKey } from "../middleware/apiKeyAuth.middleware";
4+
import { adminAuth } from "../middleware/adminAuth.middleware";
5+
import {
6+
requestExport,
7+
getExportStatus,
8+
downloadExportHandler,
9+
adminRequestExport,
10+
adminDownloadExport,
11+
} from "../controllers/dataExport.controller";
12+
13+
const router = Router();
14+
15+
/**
16+
* @swagger
17+
* /api/v1/merchants/export:
18+
* post:
19+
* summary: Request a GDPR data export (merchant self-service)
20+
* tags: [Data Export]
21+
* security:
22+
* - bearerAuth: []
23+
* responses:
24+
* 202:
25+
* description: Export job queued
26+
* 401:
27+
* description: Unauthorized
28+
*/
29+
router.post("/", authenticateApiKey, requestExport);
30+
31+
/**
32+
* @swagger
33+
* /api/v1/merchants/export/{jobId}:
34+
* get:
35+
* summary: Poll export job status
36+
* tags: [Data Export]
37+
* security:
38+
* - bearerAuth: []
39+
* parameters:
40+
* - in: path
41+
* name: jobId
42+
* required: true
43+
* schema:
44+
* type: string
45+
* responses:
46+
* 200:
47+
* description: Job status
48+
* 404:
49+
* description: Job not found
50+
*/
51+
router.get("/:jobId", authenticateApiKey, getExportStatus);
52+
53+
/**
54+
* @swagger
55+
* /api/v1/merchants/export/{jobId}/download:
56+
* get:
57+
* summary: Download completed export
58+
* tags: [Data Export]
59+
* security:
60+
* - bearerAuth: []
61+
* parameters:
62+
* - in: path
63+
* name: jobId
64+
* required: true
65+
* schema:
66+
* type: string
67+
* responses:
68+
* 200:
69+
* description: Export JSON file
70+
* 409:
71+
* description: Export not ready
72+
* 410:
73+
* description: Export link expired
74+
*/
75+
router.get("/:jobId/download", authenticateApiKey, downloadExportHandler);
76+
77+
// ── Admin routes ──────────────────────────────────────────────────────────────
78+
79+
/**
80+
* @swagger
81+
* /api/v1/admin/merchants/{merchantId}/export:
82+
* post:
83+
* summary: Admin-triggered data export for a merchant
84+
* tags: [Admin - Data Export]
85+
* security:
86+
* - bearerAuth: []
87+
* - adminSecret: []
88+
* parameters:
89+
* - in: path
90+
* name: merchantId
91+
* required: true
92+
* schema:
93+
* type: string
94+
* responses:
95+
* 202:
96+
* description: Export job queued
97+
*/
98+
router.post(
99+
"/admin/:merchantId",
100+
authenticateToken,
101+
adminAuth,
102+
adminRequestExport,
103+
);
104+
105+
/**
106+
* @swagger
107+
* /api/v1/admin/merchants/{merchantId}/export/{jobId}/download:
108+
* get:
109+
* summary: Admin download of a completed merchant export
110+
* tags: [Admin - Data Export]
111+
* security:
112+
* - bearerAuth: []
113+
* - adminSecret: []
114+
* parameters:
115+
* - in: path
116+
* name: merchantId
117+
* required: true
118+
* schema:
119+
* type: string
120+
* - in: path
121+
* name: jobId
122+
* required: true
123+
* schema:
124+
* type: string
125+
* responses:
126+
* 200:
127+
* description: Export JSON file
128+
*/
129+
router.get(
130+
"/admin/:merchantId/:jobId/download",
131+
authenticateToken,
132+
adminAuth,
133+
adminDownloadExport,
134+
);
135+
136+
export default router;

0 commit comments

Comments
 (0)