From 2bc170d000d89495f2f43708f11156311f2bd7de Mon Sep 17 00:00:00 2001 From: teslims2 <38410456+teslims2@users.noreply.github.com> Date: Sat, 27 Jun 2026 09:41:52 +0000 Subject: [PATCH] feat(sep12): implement StorageProvider interface, S3 implementation, upload-url endpoint, and CI migration check - Add StorageProvider interface (closes #543) - Install @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner; implement S3StorageProvider (closes #544) - Add POST /sep12/customer/upload-url route and controller; add UploadRecord Prisma model with migration (closes #548) - Add Prisma migrate status dry-run check step in CI workflow (closes #540) --- .github/workflows/backend.yml | 7 + backend/package.json | 11 +- .../migration.sql | 96 +++++++++++ backend/prisma/schema.prisma | 151 +++++++++--------- .../src/api/controllers/sep12.controller.ts | 25 +++ backend/src/api/routes/sep12.route.ts | 11 +- .../services/storage/s3-storage.provider.ts | 39 +++++ .../storage/storage-provider.interface.ts | 4 + 8 files changed, 265 insertions(+), 79 deletions(-) create mode 100644 backend/prisma/migrations/20260627094058_add_upload_record/migration.sql create mode 100644 backend/src/services/storage/s3-storage.provider.ts create mode 100644 backend/src/services/storage/storage-provider.interface.ts diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 4e39958b..61913923 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -32,6 +32,13 @@ jobs: - name: Install dependencies run: npm install + - name: Check Prisma Migration Status + env: + DATABASE_URL: "file:./migrate-check.db" + run: | + npx prisma migrate deploy + npx prisma migrate status + - name: Verify Migrations Against Temporary Database env: DATABASE_URL: "file:./ci-verify.db" diff --git a/backend/package.json b/backend/package.json index aef33e20..406a428d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -29,20 +29,21 @@ }, "dependencies": { "@aws-sdk/client-kms": "^3.500.0", + "@aws-sdk/client-s3": "^3.1075.0", + "@aws-sdk/s3-request-presigner": "^3.1075.0", "@prisma/client": "^6.19.2", "@stellar/stellar-sdk": "^14.6.1", - "cron-parser": "^4.9.0", "@types/node-cron": "^3.0.11", "@types/pdfkit": "^0.17.6", "cors": "^2.8.5", + "cron-parser": "^4.9.0", "dotenv": "^16.4.5", "express": "^4.19.2", "express-rate-limit": "^7.1.0", "ioredis": "^5.3.0", "jsonwebtoken": "^9.0.3", - "node-cron": "^3.0.3", - "nodemailer": "^6.10.1", "node-cron": "^4.2.1", + "nodemailer": "^6.10.1", "pdfkit": "^0.18.0", "prom-client": "^15.1.0", "rate-limit-redis": "^4.0.0", @@ -56,11 +57,11 @@ "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^4.17.25", - "@types/node-cron": "^3.0.11", "@types/jest": "^29.5.12", "@types/jsonwebtoken": "^9.0.10", - "@types/nodemailer": "^6.4.17", "@types/node": "^20.11.30", + "@types/node-cron": "^3.0.11", + "@types/nodemailer": "^6.4.17", "@types/supertest": "^6.0.2", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", diff --git a/backend/prisma/migrations/20260627094058_add_upload_record/migration.sql b/backend/prisma/migrations/20260627094058_add_upload_record/migration.sql new file mode 100644 index 00000000..54115c8e --- /dev/null +++ b/backend/prisma/migrations/20260627094058_add_upload_record/migration.sql @@ -0,0 +1,96 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "phone" TEXT; + +-- CreateTable +CREATE TABLE "NotificationPreference" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "emailEnabled" BOOLEAN NOT NULL DEFAULT true, + "smsEnabled" BOOLEAN NOT NULL DEFAULT false, + "pushEnabled" BOOLEAN NOT NULL DEFAULT false, + CONSTRAINT "NotificationPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Notification" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "transactionId" TEXT, + "type" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "message" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "Notification_transactionId_fkey" FOREIGN KEY ("transactionId") REFERENCES "Transaction" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "ContractJob" ( + "id" TEXT NOT NULL PRIMARY KEY, + "jobId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "priority" TEXT NOT NULL DEFAULT 'NORMAL', + "status" TEXT NOT NULL DEFAULT 'PENDING', + "contractId" TEXT, + "functionName" TEXT, + "parameters" JSONB, + "result" JSONB, + "error" TEXT, + "errorCategory" TEXT, + "errorSeverity" TEXT, + "errorCode" TEXT, + "userMessage" TEXT, + "suggestedAction" TEXT, + "retryable" BOOLEAN NOT NULL DEFAULT false, + "attempts" INTEGER NOT NULL DEFAULT 0, + "maxAttempts" INTEGER NOT NULL DEFAULT 3, + "createdBy" TEXT, + "metadata" JSONB, + "startedAt" DATETIME, + "completedAt" DATETIME, + "failedAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "UploadRecord" ( + "id" TEXT NOT NULL PRIMARY KEY, + "key" TEXT NOT NULL, + "contentType" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "NotificationPreference_userId_key" ON "NotificationPreference"("userId"); + +-- CreateIndex +CREATE INDEX "NotificationPreference_userId_idx" ON "NotificationPreference"("userId"); + +-- CreateIndex +CREATE INDEX "Notification_userId_idx" ON "Notification"("userId"); + +-- CreateIndex +CREATE INDEX "Notification_transactionId_idx" ON "Notification"("transactionId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ContractJob_jobId_key" ON "ContractJob"("jobId"); + +-- CreateIndex +CREATE INDEX "ContractJob_jobId_idx" ON "ContractJob"("jobId"); + +-- CreateIndex +CREATE INDEX "ContractJob_status_idx" ON "ContractJob"("status"); + +-- CreateIndex +CREATE INDEX "ContractJob_createdBy_idx" ON "ContractJob"("createdBy"); + +-- CreateIndex +CREATE INDEX "ContractJob_type_idx" ON "ContractJob"("type"); + +-- CreateIndex +CREATE UNIQUE INDEX "UploadRecord_key_key" ON "UploadRecord"("key"); + +-- CreateIndex +CREATE INDEX "UploadRecord_status_idx" ON "UploadRecord"("status"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 495f4ce6..fc1eff94 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -17,25 +17,17 @@ enum Tier { } model User { - id String @id @default(uuid()) - publicKey String @unique - email String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - transactions Transaction[] - kycCustomer KycCustomer? - apiKeys ApiKey[] - recurringPaymentSchedules RecurringPaymentSchedule[] - id String @id @default(uuid()) - publicKey String @unique - email String? - phone String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - transactions Transaction[] - kycCustomer KycCustomer? - apiKeys ApiKey[] - notificationPreference NotificationPreference? + id String @id @default(uuid()) + publicKey String @unique + email String? + phone String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + transactions Transaction[] + kycCustomer KycCustomer? + apiKeys ApiKey[] + recurringPaymentSchedules RecurringPaymentSchedule[] + notificationPreference NotificationPreference? @@index([publicKey]) } @@ -91,9 +83,9 @@ enum RecurringPaymentRunStatus { } model RecurringPaymentSchedule { - id String @id @default(uuid()) + id String @id @default(uuid()) userId String - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id]) destination String assetCode String amount String @@ -101,8 +93,8 @@ model RecurringPaymentSchedule { status RecurringPaymentScheduleStatus @default(ACTIVE) nextRunAt DateTime lastRunAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt runs RecurringPaymentRun[] @@index([userId]) @@ -110,17 +102,17 @@ model RecurringPaymentSchedule { } model RecurringPaymentRun { - id String @id @default(uuid()) + id String @id @default(uuid()) scheduleId String - schedule RecurringPaymentSchedule @relation(fields: [scheduleId], references: [id]) + schedule RecurringPaymentSchedule @relation(fields: [scheduleId], references: [id]) status RecurringPaymentRunStatus @default(PENDING) - attempt Int @default(0) - stellarTxId String? @unique + attempt Int @default(0) + stellarTxId String? @unique error String? startedAt DateTime? finishedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([scheduleId]) @@index([status]) @@ -132,13 +124,7 @@ model Transaction { user User @relation(fields: [userId], references: [id]) assetCode String amount String - type String // DEPOSIT | WITHDRAW | SEP31 | SWAP - status String // PENDING, COMPLETED, FAILED - externalId String? @unique - stellarTxId String? @unique - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - type String // DEPOSIT | WITHDRAW | SEP31 + type String // DEPOSIT | WITHDRAW | SEP31 | SWAP status String // PENDING, COMPLETED, FAILED externalId String? @unique stellarTxId String? @unique @@ -147,11 +133,11 @@ model Transaction { notifications Notification[] // Fee tracking - feeAmount String? @default("0") // Fee amount in asset units - feeAssetCode String? // Asset code for fee (e.g., XLM, USDC) - feeType String? // FLAT, PERCENTAGE, TIERED + feeAmount String? @default("0") + feeAssetCode String? + feeType String? - // SEP-31 specific fields (nullable for backward compatibility) + // SEP-31 specific fields senderInfo Json? receiverInfo Json? callbackUrl String? @@ -167,17 +153,17 @@ model Transaction { } model FeeReport { - id String @id @default(uuid()) - reportType String // DAILY, MONTHLY + id String @id @default(uuid()) + reportType String // DAILY, MONTHLY startDate DateTime endDate DateTime - totalFees String // Total fees collected in base asset - totalFeesXLM String // Total fees converted to XLM - operationCounts Json // { DEPOSIT: 10, WITHDRAW: 5, SWAP: 3, SEP31: 2 } - feeBreakdown Json // Detailed breakdown by operation type - generatedAt DateTime @default(now()) - filePath String? // Path to generated report file - fileType String? // JSON, PDF + totalFees String + totalFeesXLM String + operationCounts Json + feeBreakdown Json + generatedAt DateTime @default(now()) + filePath String? + fileType String? @@index([reportType]) @@index([startDate]) @@ -254,6 +240,8 @@ model SystemConfig { @@index([isActive]) @@index([createdAt]) +} + enum JobStatus { PENDING ACTIVE @@ -270,34 +258,51 @@ enum JobPriority { } model ContractJob { - id String @id @default(uuid()) - jobId String @unique - type String - priority JobPriority @default(NORMAL) - status JobStatus @default(PENDING) - contractId String? - functionName String? - parameters Json? - result Json? - error String? - errorCategory String? - errorSeverity String? - errorCode String? - userMessage String? + id String @id @default(uuid()) + jobId String @unique + type String + priority JobPriority @default(NORMAL) + status JobStatus @default(PENDING) + contractId String? + functionName String? + parameters Json? + result Json? + error String? + errorCategory String? + errorSeverity String? + errorCode String? + userMessage String? suggestedAction String? - retryable Boolean @default(false) - attempts Int @default(0) - maxAttempts Int @default(3) - createdBy String? - metadata Json? - startedAt DateTime? - completedAt DateTime? - failedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + retryable Boolean @default(false) + attempts Int @default(0) + maxAttempts Int @default(3) + createdBy String? + metadata Json? + startedAt DateTime? + completedAt DateTime? + failedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([jobId]) @@index([status]) @@index([createdBy]) @@index([type]) } + +enum UploadStatus { + PENDING + COMPLETED + FAILED +} + +model UploadRecord { + id String @id @default(uuid()) + key String @unique + contentType String + status UploadStatus @default(PENDING) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([status]) +} diff --git a/backend/src/api/controllers/sep12.controller.ts b/backend/src/api/controllers/sep12.controller.ts index 9ef26c8f..6c10362c 100644 --- a/backend/src/api/controllers/sep12.controller.ts +++ b/backend/src/api/controllers/sep12.controller.ts @@ -1,8 +1,10 @@ import { Request, Response } from 'express'; +import { v4 as uuidv4 } from 'uuid'; import prisma from '../../lib/prisma'; import { cryptoService } from '../../services/crypto.service'; import { kycProvider, KycStatus } from '../../services/kyc-provider.service'; import { KYCStatus } from '@prisma/client'; +import { s3StorageProvider } from '../../services/storage/s3-storage.provider'; export class Sep12Controller { @@ -212,3 +214,26 @@ export class Sep12Controller { } export const sep12Controller = new Sep12Controller(); + +// Standalone handler for upload-url (avoids binding issues) +export async function uploadUrl(req: Request, res: Response) { + try { + const { contentType } = req.body; + if (!contentType) { + return res.status(400).json({ error: 'contentType is required' }); + } + + const key = `kyc/${uuidv4()}`; + const expiresIn = 900; // 15 minutes + const url = await s3StorageProvider.generatePresignedPutUrl(key, contentType, expiresIn); + + const record = await prisma.uploadRecord.create({ + data: { key, contentType }, + }); + + return res.status(200).json({ url, id: record.id }); + } catch (error) { + console.error(error); + return res.status(500).json({ error: 'Internal Server Error' }); + } +} diff --git a/backend/src/api/routes/sep12.route.ts b/backend/src/api/routes/sep12.route.ts index ac2c88d3..dd21aea5 100644 --- a/backend/src/api/routes/sep12.route.ts +++ b/backend/src/api/routes/sep12.route.ts @@ -2,7 +2,7 @@ import { Router } from 'express'; import multer from 'multer'; import fs from 'fs'; import path from 'path'; -import { sep12Controller } from '../controllers/sep12.controller'; +import { sep12Controller, uploadUrl } from '../controllers/sep12.controller'; const router = Router(); @@ -52,6 +52,15 @@ router.get('/customer', sep12Controller.getCustomer); */ router.delete('/customer/:account', sep12Controller.deleteCustomer); +/** + * @swagger + * /sep12/customer/upload-url: + * post: + * summary: Generate a pre-signed S3 URL for document upload + * tags: [SEP-12] + */ +router.post('/customer/upload-url', uploadUrl); + /** * @swagger * /sep12/webhook: diff --git a/backend/src/services/storage/s3-storage.provider.ts b/backend/src/services/storage/s3-storage.provider.ts new file mode 100644 index 00000000..ddd2d39f --- /dev/null +++ b/backend/src/services/storage/s3-storage.provider.ts @@ -0,0 +1,39 @@ +import { S3Client, HeadObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { StorageProvider } from './storage-provider.interface'; + +export class S3StorageProvider implements StorageProvider { + private client: S3Client; + private bucket: string; + + constructor() { + this.client = new S3Client({ + region: process.env.AWS_REGION ?? 'us-east-1', + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? '', + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? '', + }, + }); + this.bucket = process.env.AWS_S3_BUCKET ?? ''; + } + + async generatePresignedPutUrl(key: string, contentType: string, expiresIn: number): Promise { + const command = new PutObjectCommand({ + Bucket: this.bucket, + Key: key, + ContentType: contentType, + }); + return getSignedUrl(this.client, command, { expiresIn }); + } + + async objectExists(key: string): Promise { + try { + await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key })); + return true; + } catch { + return false; + } + } +} + +export const s3StorageProvider = new S3StorageProvider(); diff --git a/backend/src/services/storage/storage-provider.interface.ts b/backend/src/services/storage/storage-provider.interface.ts new file mode 100644 index 00000000..fdbcb107 --- /dev/null +++ b/backend/src/services/storage/storage-provider.interface.ts @@ -0,0 +1,4 @@ +export interface StorageProvider { + generatePresignedPutUrl(key: string, contentType: string, expiresIn: number): Promise; + objectExists(key: string): Promise; +}