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 40a1de65..4aca87c4 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -137,7 +137,7 @@ model Transaction { feeAssetCode String? feeType String? - // SEP-31 specific fields (nullable for backward compatibility) + // SEP-31 specific fields senderInfo Json? receiverInfo Json? callbackUrl String? diff --git a/backend/src/api/controllers/sep12.controller.ts b/backend/src/api/controllers/sep12.controller.ts index 4e5d0005..0f3e2c04 100644 --- a/backend/src/api/controllers/sep12.controller.ts +++ b/backend/src/api/controllers/sep12.controller.ts @@ -365,3 +365,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 4ecf7fed..cfd53ebf 100644 --- a/backend/src/api/routes/sep12.route.ts +++ b/backend/src/api/routes/sep12.route.ts @@ -90,6 +90,15 @@ router.post('/customer/upload-url', authMiddleware, validateUploadFileSize, sep1 */ router.post('/customer/upload-confirm', authMiddleware, sep12Controller.confirmUpload.bind(sep12Controller)); +/** + * @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; +}