From cbe5930ec91319b57e695b342ca53f71b83602b3 Mon Sep 17 00:00:00 2001 From: numdinkushi Date: Fri, 28 Aug 2026 14:43:03 +0100 Subject: [PATCH] feat(exports): implement ExportsService and persist job lifecycle --- app/backend/docs/openapi.yaml | 67 ++++ .../__tests__/export-status.util.unit.spec.ts | 74 +++++ .../__tests__/export.repository.unit.spec.ts | 245 +++++++++++++++ .../__tests__/exports.controller.unit.spec.ts | 92 ++++++ .../__tests__/exports.service.unit.spec.ts | 285 ++++++++++++++++++ .../src/exports/constants/export.constants.ts | 36 +++ .../src/exports/dto/export-status.dto.ts | 61 ++++ .../src/exports/dto/request-export.dto.ts | 56 ++-- app/backend/src/exports/enums/export.enums.ts | 23 ++ .../src/exports/export-storage.module.ts | 5 +- app/backend/src/exports/export.repository.ts | 133 ++++++++ app/backend/src/exports/exports.controller.ts | 113 +++---- app/backend/src/exports/exports.module.ts | 10 +- app/backend/src/exports/exports.service.ts | 137 +++++++++ app/backend/src/exports/types/export.types.ts | 92 ++++++ .../src/exports/utils/export-delivery.util.ts | 29 ++ .../src/exports/utils/export-record.mapper.ts | 28 ++ .../src/exports/utils/export-status.util.ts | 42 +++ .../exports/utils/export-validation.util.ts | 99 ++++++ .../export-generation.handler.unit.spec.ts | 50 ++- .../handlers/export-generation.handler.ts | 150 ++++----- .../src/job-queue/types/job-payloads.types.ts | 12 +- .../src/job-queue/types/job.types.test.ts | 13 +- ...0260828120000_create_export_jobs_table.sql | 31 ++ 24 files changed, 1683 insertions(+), 200 deletions(-) create mode 100644 app/backend/src/exports/__tests__/export-status.util.unit.spec.ts create mode 100644 app/backend/src/exports/__tests__/export.repository.unit.spec.ts create mode 100644 app/backend/src/exports/__tests__/exports.controller.unit.spec.ts create mode 100644 app/backend/src/exports/__tests__/exports.service.unit.spec.ts create mode 100644 app/backend/src/exports/constants/export.constants.ts create mode 100644 app/backend/src/exports/dto/export-status.dto.ts create mode 100644 app/backend/src/exports/enums/export.enums.ts create mode 100644 app/backend/src/exports/export.repository.ts create mode 100644 app/backend/src/exports/exports.service.ts create mode 100644 app/backend/src/exports/types/export.types.ts create mode 100644 app/backend/src/exports/utils/export-delivery.util.ts create mode 100644 app/backend/src/exports/utils/export-record.mapper.ts create mode 100644 app/backend/src/exports/utils/export-status.util.ts create mode 100644 app/backend/src/exports/utils/export-validation.util.ts create mode 100644 app/backend/supabase/migrations/20260828120000_create_export_jobs_table.sql diff --git a/app/backend/docs/openapi.yaml b/app/backend/docs/openapi.yaml index 1ad790f0d..7908f48b1 100644 --- a/app/backend/docs/openapi.yaml +++ b/app/backend/docs/openapi.yaml @@ -1785,11 +1785,38 @@ paths: properties: jobId: type: string + status: + type: string + enum: [queued] message: type: string "400": $ref: "#/components/responses/BadRequest" + /exports/{jobId}: + get: + tags: [exports] + operationId: getExportStatus + summary: Get export job status + description: Returns the current export status and, when complete, the delivery reference. + security: + - ApiKeyAuth: [] + parameters: + - name: jobId + in: path + required: true + schema: + type: string + responses: + "200": + description: Current export status + content: + application/json: + schema: + $ref: "#/components/schemas/ExportStatus" + "404": + $ref: "#/components/responses/NotFound" + # --------------------------------------------------------------------------- # Telegram # --------------------------------------------------------------------------- @@ -3598,6 +3625,46 @@ components: type: string enum: [webhook, email, download] + ExportStatus: + type: object + required: [jobId, status, exportType, format, deliveryMethod, queuedAt] + properties: + jobId: + type: string + status: + type: string + enum: [queued, running, completed, failed] + exportType: + type: string + enum: [transactions, links, payments] + format: + type: string + enum: [csv, json] + deliveryMethod: + type: string + enum: [webhook, email, download] + queuedAt: + type: string + format: date-time + startedAt: + type: string + format: date-time + nullable: true + completedAt: + type: string + format: date-time + nullable: true + failedAt: + type: string + format: date-time + nullable: true + deliveryReference: + type: string + description: Present when status is completed + failureReason: + type: string + description: Present when status is failed + # --- Feature Flags --- UpdateFeatureFlagRequest: type: object diff --git a/app/backend/src/exports/__tests__/export-status.util.unit.spec.ts b/app/backend/src/exports/__tests__/export-status.util.unit.spec.ts new file mode 100644 index 000000000..d83a6084a --- /dev/null +++ b/app/backend/src/exports/__tests__/export-status.util.unit.spec.ts @@ -0,0 +1,74 @@ +import { canTransition, toExportStatusView } from '../utils/export-status.util'; +import { + ExportDeliveryMethod, + ExportFormat, + ExportStatus, + ExportType, +} from '../enums/export.enums'; +import type { ExportRecord } from '../types/export.types'; + +describe('export-status.util', () => { + describe('canTransition', () => { + it('allows queued -> running and queued -> failed', () => { + expect(canTransition(ExportStatus.QUEUED, ExportStatus.RUNNING)).toBe(true); + expect(canTransition(ExportStatus.QUEUED, ExportStatus.FAILED)).toBe(true); + }); + + it('allows running -> completed and running -> failed', () => { + expect(canTransition(ExportStatus.RUNNING, ExportStatus.COMPLETED)).toBe(true); + expect(canTransition(ExportStatus.RUNNING, ExportStatus.FAILED)).toBe(true); + }); + + it('treats same-status transitions as idempotent', () => { + expect(canTransition(ExportStatus.QUEUED, ExportStatus.QUEUED)).toBe(true); + expect(canTransition(ExportStatus.RUNNING, ExportStatus.RUNNING)).toBe(true); + }); + + it('rejects transitions out of terminal states', () => { + expect(canTransition(ExportStatus.COMPLETED, ExportStatus.RUNNING)).toBe(false); + expect(canTransition(ExportStatus.FAILED, ExportStatus.COMPLETED)).toBe(false); + expect(canTransition(ExportStatus.QUEUED, ExportStatus.COMPLETED)).toBe(false); + }); + }); + + describe('toExportStatusView', () => { + const base: ExportRecord = { + id: 'rec-1', + jobId: 'job-1', + userId: 'GUSER123', + exportType: ExportType.TRANSACTIONS, + format: ExportFormat.CSV, + deliveryMethod: ExportDeliveryMethod.DOWNLOAD, + filters: {}, + status: ExportStatus.QUEUED, + deliveryReference: null, + failureReason: null, + queuedAt: new Date('2026-08-28T12:00:00.000Z'), + startedAt: null, + completedAt: null, + failedAt: null, + createdAt: new Date('2026-08-28T12:00:00.000Z'), + updatedAt: new Date('2026-08-28T12:00:00.000Z'), + }; + + it('omits deliveryReference until the export is completed', () => { + const view = toExportStatusView({ + ...base, + deliveryReference: 'exports/GUSER123/job-1.csv', + }); + + expect(view.deliveryReference).toBeUndefined(); + }); + + it('includes deliveryReference when completed', () => { + const view = toExportStatusView({ + ...base, + status: ExportStatus.COMPLETED, + deliveryReference: 'exports/GUSER123/job-1.csv', + completedAt: new Date('2026-08-28T12:02:00.000Z'), + }); + + expect(view.deliveryReference).toBe('exports/GUSER123/job-1.csv'); + }); + }); +}); diff --git a/app/backend/src/exports/__tests__/export.repository.unit.spec.ts b/app/backend/src/exports/__tests__/export.repository.unit.spec.ts new file mode 100644 index 000000000..d1f0b001f --- /dev/null +++ b/app/backend/src/exports/__tests__/export.repository.unit.spec.ts @@ -0,0 +1,245 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ExportRepository } from '../export.repository'; +import { SupabaseService } from '../../supabase/supabase.service'; +import { + ExportDeliveryMethod, + ExportFormat, + ExportStatus, + ExportType, +} from '../enums/export.enums'; +import { + ExportInvalidTransitionError, + ExportJobRow, + ExportRecordNotFoundError, +} from '../types/export.types'; +import { EXPORT_JOBS_TABLE } from '../constants/export.constants'; + +function makeRow(overrides: Partial = {}): ExportJobRow { + return { + id: 'rec-1', + job_id: 'job-1', + user_id: 'GUSER123', + export_type: ExportType.TRANSACTIONS, + format: ExportFormat.CSV, + delivery_method: ExportDeliveryMethod.EMAIL, + filters: {}, + status: ExportStatus.QUEUED, + delivery_reference: null, + failure_reason: null, + queued_at: '2026-08-28T12:00:00.000Z', + started_at: null, + completed_at: null, + failed_at: null, + created_at: '2026-08-28T12:00:00.000Z', + updated_at: '2026-08-28T12:00:00.000Z', + ...overrides, + }; +} + +function createChain(result: { data: unknown; error: unknown }) { + const chain: Record = {}; + chain.insert = jest.fn().mockReturnValue(chain); + chain.update = jest.fn().mockReturnValue(chain); + chain.select = jest.fn().mockReturnValue(chain); + chain.eq = jest.fn().mockReturnValue(chain); + chain.maybeSingle = jest.fn().mockResolvedValue(result); + chain.single = jest.fn().mockResolvedValue(result); + return chain; +} + +describe('ExportRepository', () => { + let repository: ExportRepository; + let from: jest.Mock; + + beforeEach(async () => { + from = jest.fn(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ExportRepository, + { + provide: SupabaseService, + useValue: { + getClient: jest.fn(() => ({ from })), + }, + }, + ], + }).compile(); + + repository = module.get(ExportRepository); + }); + + describe('create', () => { + it('persists a queued export record', async () => { + const row = makeRow(); + const chain = createChain({ data: row, error: null }); + from.mockReturnValue(chain); + + const record = await repository.create({ + jobId: 'job-1', + userId: 'GUSER123', + exportType: ExportType.TRANSACTIONS, + format: ExportFormat.CSV, + deliveryMethod: ExportDeliveryMethod.EMAIL, + filters: {}, + }); + + expect(from).toHaveBeenCalledWith(EXPORT_JOBS_TABLE); + expect(chain.insert).toHaveBeenCalledWith( + expect.objectContaining({ + job_id: 'job-1', + status: ExportStatus.QUEUED, + user_id: 'GUSER123', + }), + ); + expect(record.status).toBe(ExportStatus.QUEUED); + expect(record.jobId).toBe('job-1'); + }); + }); + + describe('status transitions', () => { + it('transitions queued -> running and stamps startedAt', async () => { + const queued = makeRow(); + const running = makeRow({ + status: ExportStatus.RUNNING, + started_at: '2026-08-28T12:01:00.000Z', + }); + + const findChain = createChain({ data: queued, error: null }); + const updateChain = createChain({ data: running, error: null }); + from.mockReturnValueOnce(findChain).mockReturnValueOnce(updateChain); + + const record = await repository.transition('job-1', ExportStatus.RUNNING); + + expect(updateChain.update).toHaveBeenCalledWith( + expect.objectContaining({ + status: ExportStatus.RUNNING, + started_at: expect.any(String), + }), + ); + expect(record.status).toBe(ExportStatus.RUNNING); + expect(record.startedAt).toBeInstanceOf(Date); + }); + + it('transitions running -> completed with delivery reference and timestamp', async () => { + const running = makeRow({ + status: ExportStatus.RUNNING, + started_at: '2026-08-28T12:01:00.000Z', + }); + const completed = makeRow({ + status: ExportStatus.COMPLETED, + started_at: '2026-08-28T12:01:00.000Z', + completed_at: '2026-08-28T12:02:00.000Z', + delivery_reference: 'email:export:job-1', + }); + + const findChain = createChain({ data: running, error: null }); + const updateChain = createChain({ data: completed, error: null }); + from.mockReturnValueOnce(findChain).mockReturnValueOnce(updateChain); + + const record = await repository.transition('job-1', ExportStatus.COMPLETED, { + deliveryReference: 'email:export:job-1', + }); + + expect(updateChain.update).toHaveBeenCalledWith( + expect.objectContaining({ + status: ExportStatus.COMPLETED, + delivery_reference: 'email:export:job-1', + completed_at: expect.any(String), + }), + ); + expect(record.status).toBe(ExportStatus.COMPLETED); + expect(record.deliveryReference).toBe('email:export:job-1'); + }); + + it('transitions running -> failed with failure reason and timestamp', async () => { + const running = makeRow({ + status: ExportStatus.RUNNING, + started_at: '2026-08-28T12:01:00.000Z', + }); + const failed = makeRow({ + status: ExportStatus.FAILED, + started_at: '2026-08-28T12:01:00.000Z', + failed_at: '2026-08-28T12:02:00.000Z', + failure_reason: 'query failed', + }); + + const findChain = createChain({ data: running, error: null }); + const updateChain = createChain({ data: failed, error: null }); + from.mockReturnValueOnce(findChain).mockReturnValueOnce(updateChain); + + const record = await repository.transition('job-1', ExportStatus.FAILED, { + failureReason: 'query failed', + }); + + expect(updateChain.update).toHaveBeenCalledWith( + expect.objectContaining({ + status: ExportStatus.FAILED, + failure_reason: 'query failed', + failed_at: expect.any(String), + }), + ); + expect(record.status).toBe(ExportStatus.FAILED); + expect(record.failureReason).toBe('query failed'); + }); + + it('allows queued -> failed without going through running', async () => { + const queued = makeRow(); + const failed = makeRow({ + status: ExportStatus.FAILED, + failed_at: '2026-08-28T12:01:00.000Z', + failure_reason: 'persist failed', + }); + + const findChain = createChain({ data: queued, error: null }); + const updateChain = createChain({ data: failed, error: null }); + from.mockReturnValueOnce(findChain).mockReturnValueOnce(updateChain); + + const record = await repository.transition('job-1', ExportStatus.FAILED, { + failureReason: 'persist failed', + }); + + expect(record.status).toBe(ExportStatus.FAILED); + }); + + it('is idempotent when transitioning to the current status', async () => { + const running = makeRow({ status: ExportStatus.RUNNING }); + const findChain = createChain({ data: running, error: null }); + from.mockReturnValueOnce(findChain); + + const record = await repository.transition('job-1', ExportStatus.RUNNING); + + expect(record.status).toBe(ExportStatus.RUNNING); + expect(from).toHaveBeenCalledTimes(1); + }); + + it('rejects an invalid transition from completed to running', async () => { + const completed = makeRow({ status: ExportStatus.COMPLETED }); + const findChain = createChain({ data: completed, error: null }); + from.mockReturnValueOnce(findChain); + + await expect( + repository.transition('job-1', ExportStatus.RUNNING), + ).rejects.toThrow(ExportInvalidTransitionError); + }); + + it('rejects an invalid transition from failed to completed', async () => { + const failed = makeRow({ status: ExportStatus.FAILED }); + const findChain = createChain({ data: failed, error: null }); + from.mockReturnValueOnce(findChain); + + await expect( + repository.transition('job-1', ExportStatus.COMPLETED), + ).rejects.toThrow(ExportInvalidTransitionError); + }); + + it('throws when the export record does not exist', async () => { + const findChain = createChain({ data: null, error: null }); + from.mockReturnValueOnce(findChain); + + await expect( + repository.transition('missing', ExportStatus.RUNNING), + ).rejects.toThrow(ExportRecordNotFoundError); + }); + }); +}); diff --git a/app/backend/src/exports/__tests__/exports.controller.unit.spec.ts b/app/backend/src/exports/__tests__/exports.controller.unit.spec.ts new file mode 100644 index 000000000..e5493ba1b --- /dev/null +++ b/app/backend/src/exports/__tests__/exports.controller.unit.spec.ts @@ -0,0 +1,92 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { ExportsController } from '../exports.controller'; +import { ExportsService } from '../exports.service'; +import { ExportStorageService } from '../export-storage.service'; +import { RequestExportDto } from '../dto/request-export.dto'; +import { ApiKeyGuard } from '../../auth/guards/api-key.guard'; +import { + ExportDeliveryMethod, + ExportFormat, + ExportStatus, + ExportType, +} from '../enums/export.enums'; + +describe('ExportsController', () => { + let controller: ExportsController; + let exportsService: jest.Mocked>; + + beforeEach(async () => { + exportsService = { + requestExport: jest.fn(), + getStatus: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [ExportsController], + providers: [ + { provide: ExportsService, useValue: exportsService }, + { + provide: ExportStorageService, + useValue: { + verifyDownloadToken: jest.fn(), + findArtifactRecord: jest.fn(), + createPresignedDownloadUrl: jest.fn(), + }, + }, + ], + }) + .overrideGuard(ApiKeyGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(ExportsController); + }); + + it('delegates export requests to ExportsService', async () => { + const dto: RequestExportDto = { + userId: 'GUSER123', + exportType: ExportType.TRANSACTIONS, + format: ExportFormat.CSV, + deliveryMethod: ExportDeliveryMethod.EMAIL, + }; + const enqueued = { + jobId: 'job-1', + status: ExportStatus.QUEUED, + message: 'Export job enqueued successfully. Job ID: job-1', + }; + exportsService.requestExport.mockResolvedValue(enqueued); + + await expect(controller.requestExport(dto)).resolves.toEqual(enqueued); + expect(exportsService.requestExport).toHaveBeenCalledWith(dto); + }); + + it('delegates status lookup to ExportsService', async () => { + const status = { + jobId: 'job-1', + status: ExportStatus.COMPLETED, + exportType: ExportType.TRANSACTIONS, + format: ExportFormat.CSV, + deliveryMethod: ExportDeliveryMethod.EMAIL, + queuedAt: '2026-08-28T12:00:00.000Z', + startedAt: '2026-08-28T12:01:00.000Z', + completedAt: '2026-08-28T12:02:00.000Z', + failedAt: null, + deliveryReference: 'email:export:job-1', + }; + exportsService.getStatus.mockResolvedValue(status); + + await expect(controller.getExportStatus('job-1')).resolves.toEqual(status); + expect(exportsService.getStatus).toHaveBeenCalledWith('job-1'); + }); + + it('surfaces NotFoundException from status lookup', async () => { + exportsService.getStatus.mockRejectedValue( + new NotFoundException('Export job not found: missing'), + ); + + await expect(controller.getExportStatus('missing')).rejects.toThrow( + NotFoundException, + ); + }); +}); diff --git a/app/backend/src/exports/__tests__/exports.service.unit.spec.ts b/app/backend/src/exports/__tests__/exports.service.unit.spec.ts new file mode 100644 index 000000000..f70b41d3d --- /dev/null +++ b/app/backend/src/exports/__tests__/exports.service.unit.spec.ts @@ -0,0 +1,285 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { ExportsService } from '../exports.service'; +import { ExportRepository } from '../export.repository'; +import { JobQueueService, PayloadValidationError } from '../../job-queue/job-queue.service'; +import { JobType } from '../../job-queue/types'; +import { RequestExportDto } from '../dto/request-export.dto'; +import { + ExportDeliveryMethod, + ExportFormat, + ExportStatus, + ExportType, +} from '../enums/export.enums'; +import { ExportRecord } from '../types/export.types'; +import { EXPORT_ENQUEUED_MESSAGE } from '../constants/export.constants'; + +function makeDto( + overrides: Partial = {}, +): RequestExportDto { + return { + userId: 'GUSER123', + exportType: ExportType.TRANSACTIONS, + format: ExportFormat.CSV, + deliveryMethod: ExportDeliveryMethod.EMAIL, + filters: { status: 'completed' }, + ...overrides, + }; +} + +function makeRecord(overrides: Partial = {}): ExportRecord { + const now = new Date('2026-08-28T12:00:00.000Z'); + return { + id: 'rec-1', + jobId: 'job-1', + userId: 'GUSER123', + exportType: ExportType.TRANSACTIONS, + format: ExportFormat.CSV, + deliveryMethod: ExportDeliveryMethod.EMAIL, + filters: {}, + status: ExportStatus.QUEUED, + deliveryReference: null, + failureReason: null, + queuedAt: now, + startedAt: null, + completedAt: null, + failedAt: null, + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +describe('ExportsService', () => { + let service: ExportsService; + let jobQueueService: jest.Mocked>; + let exportRepository: jest.Mocked< + Pick + >; + + beforeEach(async () => { + jobQueueService = { + enqueue: jest.fn().mockResolvedValue('job-1'), + cancel: jest.fn().mockResolvedValue(undefined), + }; + + exportRepository = { + create: jest.fn().mockResolvedValue(makeRecord()), + findByJobId: jest.fn(), + transition: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ExportsService, + { provide: JobQueueService, useValue: jobQueueService }, + { provide: ExportRepository, useValue: exportRepository }, + ], + }).compile(); + + service = module.get(ExportsService); + }); + + describe('requestExport – enqueue', () => { + it('validates, enqueues an export_generation job, and persists a queued record', async () => { + const dto = makeDto(); + + const result = await service.requestExport(dto); + + expect(jobQueueService.enqueue).toHaveBeenCalledWith( + JobType.EXPORT_GENERATION, + { + userId: dto.userId, + exportType: dto.exportType, + filters: dto.filters, + format: dto.format, + deliveryMethod: dto.deliveryMethod, + }, + ); + expect(exportRepository.create).toHaveBeenCalledWith({ + jobId: 'job-1', + userId: dto.userId, + exportType: dto.exportType, + format: dto.format, + deliveryMethod: dto.deliveryMethod, + filters: dto.filters, + }); + expect(result).toEqual({ + jobId: 'job-1', + status: ExportStatus.QUEUED, + message: `${EXPORT_ENQUEUED_MESSAGE}. Job ID: job-1`, + }); + }); + + it('defaults missing filters to an empty object', async () => { + await service.requestExport(makeDto({ filters: undefined })); + + expect(jobQueueService.enqueue).toHaveBeenCalledWith( + JobType.EXPORT_GENERATION, + expect.objectContaining({ filters: {} }), + ); + expect(exportRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ filters: {} }), + ); + }); + + it('cancels the job when persist fails after enqueue', async () => { + exportRepository.create.mockRejectedValue(new Error('db down')); + + await expect(service.requestExport(makeDto())).rejects.toThrow('db down'); + expect(jobQueueService.cancel).toHaveBeenCalledWith('job-1'); + }); + + it('maps payload validation failures to BadRequestException', async () => { + jobQueueService.enqueue.mockRejectedValue( + new PayloadValidationError('exportType is required'), + ); + + await expect(service.requestExport(makeDto())).rejects.toThrow( + BadRequestException, + ); + expect(exportRepository.create).not.toHaveBeenCalled(); + }); + }); + + describe('requestExport – rejection of invalid requests', () => { + it('rejects a missing userId', async () => { + await expect( + service.requestExport(makeDto({ userId: '' })), + ).rejects.toThrow(BadRequestException); + + expect(jobQueueService.enqueue).not.toHaveBeenCalled(); + }); + + it('rejects an invalid export type', async () => { + await expect( + service.requestExport( + makeDto({ exportType: 'invoices' as ExportType }), + ), + ).rejects.toThrow(/exportType/); + + expect(jobQueueService.enqueue).not.toHaveBeenCalled(); + }); + + it('rejects an invalid format', async () => { + await expect( + service.requestExport(makeDto({ format: 'xml' as ExportFormat })), + ).rejects.toThrow(/format/); + + expect(jobQueueService.enqueue).not.toHaveBeenCalled(); + }); + + it('rejects an invalid delivery method', async () => { + await expect( + service.requestExport( + makeDto({ deliveryMethod: 'fax' as ExportDeliveryMethod }), + ), + ).rejects.toThrow(/deliveryMethod/); + + expect(jobQueueService.enqueue).not.toHaveBeenCalled(); + }); + + it('rejects filters that are not a plain object', async () => { + await expect( + service.requestExport( + makeDto({ filters: ['not-an-object'] as unknown as Record }), + ), + ).rejects.toThrow(/filters/); + + expect(jobQueueService.enqueue).not.toHaveBeenCalled(); + }); + }); + + describe('getStatus', () => { + it('returns the current state for a queued export without a delivery reference', async () => { + exportRepository.findByJobId.mockResolvedValue(makeRecord()); + + const view = await service.getStatus('job-1'); + + expect(view.status).toBe(ExportStatus.QUEUED); + expect(view.jobId).toBe('job-1'); + expect(view.deliveryReference).toBeUndefined(); + expect(view.queuedAt).toBe('2026-08-28T12:00:00.000Z'); + }); + + it('includes the delivery reference when the export is completed', async () => { + exportRepository.findByJobId.mockResolvedValue( + makeRecord({ + status: ExportStatus.COMPLETED, + deliveryReference: 'email:export:job-1', + completedAt: new Date('2026-08-28T12:05:00.000Z'), + }), + ); + + const view = await service.getStatus('job-1'); + + expect(view.status).toBe(ExportStatus.COMPLETED); + expect(view.deliveryReference).toBe('email:export:job-1'); + expect(view.completedAt).toBe('2026-08-28T12:05:00.000Z'); + }); + + it('throws NotFoundException when the export record does not exist', async () => { + exportRepository.findByJobId.mockResolvedValue(null); + + await expect(service.getStatus('missing')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('status transitions', () => { + it('marks an export as running', async () => { + const running = makeRecord({ status: ExportStatus.RUNNING }); + exportRepository.transition.mockResolvedValue(running); + + const result = await service.markRunning('job-1'); + + expect(exportRepository.transition).toHaveBeenCalledWith( + 'job-1', + ExportStatus.RUNNING, + ); + expect(result.status).toBe(ExportStatus.RUNNING); + }); + + it('marks an export as completed with a delivery reference', async () => { + const completed = makeRecord({ + status: ExportStatus.COMPLETED, + deliveryReference: 'exports/GUSER123/job-1.csv', + }); + exportRepository.transition.mockResolvedValue(completed); + + const result = await service.markCompleted( + 'job-1', + 'exports/GUSER123/job-1.csv', + ); + + expect(exportRepository.transition).toHaveBeenCalledWith( + 'job-1', + ExportStatus.COMPLETED, + { deliveryReference: 'exports/GUSER123/job-1.csv' }, + ); + expect(result.status).toBe(ExportStatus.COMPLETED); + expect(result.deliveryReference).toBe('exports/GUSER123/job-1.csv'); + }); + + it('marks an export as failed with a reason', async () => { + const failed = makeRecord({ + status: ExportStatus.FAILED, + failureReason: 'database connection refused', + }); + exportRepository.transition.mockResolvedValue(failed); + + const result = await service.markFailed( + 'job-1', + 'database connection refused', + ); + + expect(exportRepository.transition).toHaveBeenCalledWith( + 'job-1', + ExportStatus.FAILED, + { failureReason: 'database connection refused' }, + ); + expect(result.status).toBe(ExportStatus.FAILED); + }); + }); +}); diff --git a/app/backend/src/exports/constants/export.constants.ts b/app/backend/src/exports/constants/export.constants.ts new file mode 100644 index 000000000..008ad9ed5 --- /dev/null +++ b/app/backend/src/exports/constants/export.constants.ts @@ -0,0 +1,36 @@ +import { + ExportDeliveryMethod, + ExportFormat, + ExportStatus, + ExportType, +} from '../enums/export.enums'; + +export const EXPORT_JOBS_TABLE = 'export_jobs'; + +export const EXPORT_TYPE_VALUES = Object.values(ExportType); + +export const EXPORT_FORMAT_VALUES = Object.values(ExportFormat); + +export const EXPORT_DELIVERY_METHOD_VALUES = Object.values( + ExportDeliveryMethod, +); + +export const EXPORT_ENQUEUED_MESSAGE = 'Export job enqueued successfully'; + +export const EXPORT_ERROR_CODES = { + JOB_NOT_FOUND: 'EXPORT_JOB_NOT_FOUND', + INVALID_TRANSITION: 'EXPORT_INVALID_TRANSITION', +} as const; + +export const EXPORT_STATUS_TRANSITIONS: Record = { + [ExportStatus.QUEUED]: [ExportStatus.RUNNING, ExportStatus.FAILED], + [ExportStatus.RUNNING]: [ExportStatus.COMPLETED, ExportStatus.FAILED], + [ExportStatus.COMPLETED]: [], + [ExportStatus.FAILED]: [], +}; + +export const DELIVERY_REFERENCE_PREFIX = { + [ExportDeliveryMethod.EMAIL]: 'email', + [ExportDeliveryMethod.WEBHOOK]: 'webhook', + [ExportDeliveryMethod.DOWNLOAD]: 'download', +} as const; diff --git a/app/backend/src/exports/dto/export-status.dto.ts b/app/backend/src/exports/dto/export-status.dto.ts new file mode 100644 index 000000000..d59d0c031 --- /dev/null +++ b/app/backend/src/exports/dto/export-status.dto.ts @@ -0,0 +1,61 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ExportDeliveryMethod, + ExportFormat, + ExportStatus, + ExportType, +} from '../enums/export.enums'; + +export class ExportStatusResponseDto { + @ApiProperty({ description: 'Job ID for the export' }) + jobId!: string; + + @ApiProperty({ enum: ExportStatus, description: 'Current export status' }) + status!: ExportStatus; + + @ApiProperty({ enum: ExportType }) + exportType!: ExportType; + + @ApiProperty({ enum: ExportFormat }) + format!: ExportFormat; + + @ApiProperty({ enum: ExportDeliveryMethod }) + deliveryMethod!: ExportDeliveryMethod; + + @ApiProperty({ + description: 'When the export was queued', + format: 'date-time', + }) + queuedAt!: string; + + @ApiProperty({ + description: 'When processing started', + format: 'date-time', + nullable: true, + }) + startedAt!: string | null; + + @ApiProperty({ + description: 'When processing completed', + format: 'date-time', + nullable: true, + }) + completedAt!: string | null; + + @ApiProperty({ + description: 'When processing failed', + format: 'date-time', + nullable: true, + }) + failedAt!: string | null; + + @ApiPropertyOptional({ + description: 'Delivery reference, present when status is completed', + }) + deliveryReference?: string; + + @ApiPropertyOptional({ + description: 'Failure reason, present when status is failed', + }) + failureReason?: string; +} diff --git a/app/backend/src/exports/dto/request-export.dto.ts b/app/backend/src/exports/dto/request-export.dto.ts index 54f81865b..54e86a4b1 100644 --- a/app/backend/src/exports/dto/request-export.dto.ts +++ b/app/backend/src/exports/dto/request-export.dto.ts @@ -1,37 +1,37 @@ -/** - * Request Export DTO - * - * Data transfer object for export request endpoint. - */ +import { + IsString, + IsEnum, + IsObject, + IsOptional, + IsNotEmpty, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ExportDeliveryMethod, + ExportFormat, + ExportType, +} from '../enums/export.enums'; -import { IsString, IsEnum, IsObject, IsOptional } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; - -/** - * Request Export DTO - * - * Defines the structure for export requests. - */ export class RequestExportDto { @ApiProperty({ description: 'User ID requesting the export', example: '123e4567-e89b-12d3-a456-426614174000', }) @IsString() - userId: string; + @IsNotEmpty() + userId!: string; @ApiProperty({ description: 'Type of data to export', - enum: ['transactions', 'links', 'payments'], - example: 'transactions', + enum: ExportType, + example: ExportType.TRANSACTIONS, }) - @IsEnum(['transactions', 'links', 'payments']) - exportType: 'transactions' | 'links' | 'payments'; + @IsEnum(ExportType) + exportType!: ExportType; - @ApiProperty({ + @ApiPropertyOptional({ description: 'Filters to apply to the export query', example: { status: 'completed', startDate: '2024-01-01' }, - required: false, }) @IsObject() @IsOptional() @@ -39,17 +39,17 @@ export class RequestExportDto { @ApiProperty({ description: 'Output format for the export', - enum: ['csv', 'json'], - example: 'csv', + enum: ExportFormat, + example: ExportFormat.CSV, }) - @IsEnum(['csv', 'json']) - format: 'csv' | 'json'; + @IsEnum(ExportFormat) + format!: ExportFormat; @ApiProperty({ description: 'How to deliver the export', - enum: ['webhook', 'email', 'download'], - example: 'download', + enum: ExportDeliveryMethod, + example: ExportDeliveryMethod.DOWNLOAD, }) - @IsEnum(['webhook', 'email', 'download']) - deliveryMethod: 'webhook' | 'email' | 'download'; + @IsEnum(ExportDeliveryMethod) + deliveryMethod!: ExportDeliveryMethod; } diff --git a/app/backend/src/exports/enums/export.enums.ts b/app/backend/src/exports/enums/export.enums.ts new file mode 100644 index 000000000..0bee22e35 --- /dev/null +++ b/app/backend/src/exports/enums/export.enums.ts @@ -0,0 +1,23 @@ +export enum ExportStatus { + QUEUED = 'queued', + RUNNING = 'running', + COMPLETED = 'completed', + FAILED = 'failed', +} + +export enum ExportType { + TRANSACTIONS = 'transactions', + LINKS = 'links', + PAYMENTS = 'payments', +} + +export enum ExportFormat { + CSV = 'csv', + JSON = 'json', +} + +export enum ExportDeliveryMethod { + WEBHOOK = 'webhook', + EMAIL = 'email', + DOWNLOAD = 'download', +} diff --git a/app/backend/src/exports/export-storage.module.ts b/app/backend/src/exports/export-storage.module.ts index cbd964f9f..71bb81637 100644 --- a/app/backend/src/exports/export-storage.module.ts +++ b/app/backend/src/exports/export-storage.module.ts @@ -7,11 +7,12 @@ import { Module } from '@nestjs/common'; import { ExportStorageService } from './export-storage.service'; import { ExportRetentionScheduler } from './export-retention.scheduler'; +import { ExportRepository } from './export.repository'; import { SupabaseModule } from '../supabase/supabase.module'; @Module({ imports: [SupabaseModule], - providers: [ExportStorageService, ExportRetentionScheduler], - exports: [ExportStorageService], + providers: [ExportStorageService, ExportRetentionScheduler, ExportRepository], + exports: [ExportStorageService, ExportRepository], }) export class ExportStorageModule {} diff --git a/app/backend/src/exports/export.repository.ts b/app/backend/src/exports/export.repository.ts new file mode 100644 index 000000000..21e1ae06e --- /dev/null +++ b/app/backend/src/exports/export.repository.ts @@ -0,0 +1,133 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { SupabaseService } from '../supabase/supabase.service'; +import { EXPORT_JOBS_TABLE } from './constants/export.constants'; +import { ExportStatus } from './enums/export.enums'; +import { + ExportInvalidTransitionError, + ExportRecordNotFoundError, + type CreateExportRecordInput, + type ExportJobRow, + type ExportRecord, +} from './types/export.types'; +import { canTransition } from './utils/export-status.util'; +import { mapRowToExportRecord } from './utils/export-record.mapper'; + +export interface ExportTransitionExtras { + deliveryReference?: string; + failureReason?: string; +} + +@Injectable() +export class ExportRepository { + private readonly logger = new Logger(ExportRepository.name); + + constructor(private readonly supabase: SupabaseService) {} + + private get client() { + return this.supabase.getClient(); + } + + async create(input: CreateExportRecordInput): Promise { + const now = new Date().toISOString(); + const { data, error } = await this.client + .from(EXPORT_JOBS_TABLE) + .insert({ + job_id: input.jobId, + user_id: input.userId, + export_type: input.exportType, + format: input.format, + delivery_method: input.deliveryMethod, + filters: input.filters, + status: ExportStatus.QUEUED, + queued_at: now, + }) + .select() + .single(); + + if (error || !data) { + const message = error?.message ?? 'unknown error'; + this.logger.error(`Failed to create export record: ${message}`); + throw new Error(`Failed to persist export record: ${message}`); + } + + return mapRowToExportRecord(data as ExportJobRow); + } + + async findByJobId(jobId: string): Promise { + const { data, error } = await this.client + .from(EXPORT_JOBS_TABLE) + .select('*') + .eq('job_id', jobId) + .maybeSingle(); + + if (error) { + this.logger.error( + `Failed to look up export record for job ${jobId}: ${error.message}`, + ); + throw new Error(`Failed to look up export record: ${error.message}`); + } + + if (!data) { + return null; + } + + return mapRowToExportRecord(data as ExportJobRow); + } + + async transition( + jobId: string, + status: ExportStatus, + extras: ExportTransitionExtras = {}, + ): Promise { + const current = await this.findByJobId(jobId); + + if (!current) { + throw new ExportRecordNotFoundError(jobId); + } + + if (!canTransition(current.status, status)) { + throw new ExportInvalidTransitionError(current.status, status); + } + + if (current.status === status) { + return current; + } + + const now = new Date(); + const updateData: Record = { + status, + updated_at: now.toISOString(), + }; + + if (status === ExportStatus.RUNNING) { + updateData.started_at = now.toISOString(); + } + + if (status === ExportStatus.COMPLETED) { + updateData.completed_at = now.toISOString(); + updateData.delivery_reference = extras.deliveryReference ?? null; + } + + if (status === ExportStatus.FAILED) { + updateData.failed_at = now.toISOString(); + updateData.failure_reason = extras.failureReason ?? null; + } + + const { data, error } = await this.client + .from(EXPORT_JOBS_TABLE) + .update(updateData) + .eq('job_id', jobId) + .select() + .single(); + + if (error || !data) { + const message = error?.message ?? 'unknown error'; + this.logger.error( + `Failed to transition export ${jobId} to ${status}: ${message}`, + ); + throw new Error(`Failed to update export status: ${message}`); + } + + return mapRowToExportRecord(data as ExportJobRow); + } +} diff --git a/app/backend/src/exports/exports.controller.ts b/app/backend/src/exports/exports.controller.ts index 378d0c6b2..bf41cf49e 100644 --- a/app/backend/src/exports/exports.controller.ts +++ b/app/backend/src/exports/exports.controller.ts @@ -1,12 +1,3 @@ -/** - * Exports Controller - * - * Provides endpoints for requesting data exports and redeeming signed download - * links (BE-102). - * - * Requirements: 9.2, BE-102 - */ - import { Controller, Post, @@ -18,26 +9,22 @@ import { Logger, Res, HttpStatus, + HttpCode, } from '@nestjs/common'; import { Response } from 'express'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { ApiKeyGuard } from '../auth/guards/api-key.guard'; -import { JobQueueService } from '../job-queue/job-queue.service'; -import { JobType } from '../job-queue/types'; -import { ExportGenerationPayload } from '../job-queue/types/job-payloads.types'; import { RequestExportDto } from './dto/request-export.dto'; +import { ExportStatusResponseDto } from './dto/export-status.dto'; +import { ExportsService } from './exports.service'; import { ExportStorageService, EXPORT_LINK_INVALID, EXPORT_NOT_FOUND, + type ArtifactRecord, } from './export-storage.service'; +import type { EnqueueExportResult } from './types/export.types'; -/** - * Exports Controller - * - * POST /exports – enqueue an export job. - * GET /exports/:jobId/download – redeem a signed download token. - */ @ApiTags('exports') @UseGuards(ApiKeyGuard) @Controller('exports') @@ -45,17 +32,12 @@ export class ExportsController { private readonly logger = new Logger(ExportsController.name); constructor( - private readonly jobQueueService: JobQueueService, + private readonly exportsService: ExportsService, private readonly exportStorageService: ExportStorageService, ) {} - /** - * Request a data export - * - * Enqueues an export_generation job to process the export asynchronously. - * The export will be delivered via the specified deliveryMethod. - */ @Post() + @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: 'Request a data export' }) @ApiResponse({ status: 201, @@ -63,7 +45,11 @@ export class ExportsController { schema: { type: 'object', properties: { - jobId: { type: 'string', description: 'Job ID for tracking the export' }, + jobId: { + type: 'string', + description: 'Job ID for tracking the export', + }, + status: { type: 'string', enum: ['queued'] }, message: { type: 'string', description: 'Success message' }, }, }, @@ -71,46 +57,10 @@ export class ExportsController { @ApiResponse({ status: 400, description: 'Invalid request parameters' }) async requestExport( @Body() dto: RequestExportDto, - ): Promise<{ jobId: string; message: string }> { - this.logger.log( - `Export requested: userId=${dto.userId}, type=${dto.exportType}, format=${dto.format}, delivery=${dto.deliveryMethod}`, - ); - - const payload: ExportGenerationPayload = { - userId: dto.userId, - exportType: dto.exportType, - filters: dto.filters || {}, - format: dto.format, - deliveryMethod: dto.deliveryMethod, - }; - - const jobId = await this.jobQueueService.enqueue( - JobType.EXPORT_GENERATION, - payload, - ); - - this.logger.log(`Export job enqueued: ${jobId}`); - - return { - jobId, - message: `Export job enqueued successfully. Job ID: ${jobId}`, - }; + ): Promise { + return this.exportsService.requestExport(dto); } - /** - * Redeem a signed export download link. - * - * Query parameters: - * - userId : the principal who requested the export (scopes the token) - * - token : the HMAC-signed token issued by ExportStorageService - * - * On success: 302 redirect to a short-lived presigned Supabase Storage URL. - * On failure: 400 with stable error code EXPORT_LINK_INVALID or - * 404 with EXPORT_NOT_FOUND. - * - * Expiry and tampering are both rejected with EXPORT_LINK_INVALID so that - * callers do not receive signal about which condition triggered the rejection. - */ @Get(':jobId/download') @ApiOperation({ summary: 'Redeem a signed export download link' }) @ApiResponse({ status: 302, description: 'Redirect to presigned download URL' }) @@ -122,7 +72,6 @@ export class ExportsController { @Query('token') token: string, @Res() res: Response, ): Promise { - // 1. Verify the download token (handles expiry + tampering + principal mismatch) const verification = this.exportStorageService.verifyDownloadToken({ jobId, userId, @@ -133,13 +82,13 @@ export class ExportsController { res.status(HttpStatus.BAD_REQUEST).json({ statusCode: HttpStatus.BAD_REQUEST, errorCode: EXPORT_LINK_INVALID, - message: 'The download link is invalid, expired, or was not issued for this resource.', + message: + 'The download link is invalid, expired, or was not issued for this resource.', }); return; } - // 2. Look up the artifact record to get the storage key - let artifact; + let artifact: ArtifactRecord | null; try { artifact = await this.exportStorageService.findArtifactRecord(jobId); } catch (err) { @@ -156,23 +105,22 @@ export class ExportsController { res.status(HttpStatus.NOT_FOUND).json({ statusCode: HttpStatus.NOT_FOUND, errorCode: EXPORT_NOT_FOUND, - message: 'Export artifact not found. It may have expired or been cleaned up.', + message: + 'Export artifact not found. It may have expired or been cleaned up.', }); return; } - // 3. Double-check ownership at the record level (defence-in-depth) if (artifact.userId !== userId) { - // Return the same stable code as an invalid token to avoid oracle attacks res.status(HttpStatus.BAD_REQUEST).json({ statusCode: HttpStatus.BAD_REQUEST, errorCode: EXPORT_LINK_INVALID, - message: 'The download link is invalid, expired, or was not issued for this resource.', + message: + 'The download link is invalid, expired, or was not issued for this resource.', }); return; } - // 4. Generate a short-lived presigned URL and redirect the client let presignedUrl: string; try { presignedUrl = await this.exportStorageService.createPresignedDownloadUrl( @@ -180,7 +128,9 @@ export class ExportsController { ); } catch (err) { const msg = err instanceof Error ? err.message : String(err); - this.logger.error(`Failed to create presigned URL for job ${jobId}: ${msg}`); + this.logger.error( + `Failed to create presigned URL for job ${jobId}: ${msg}`, + ); res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ statusCode: HttpStatus.INTERNAL_SERVER_ERROR, message: 'Failed to generate download URL.', @@ -191,5 +141,18 @@ export class ExportsController { this.logger.log(`Download link redeemed for job ${jobId} by user ${userId}`); res.redirect(HttpStatus.FOUND, presignedUrl); } -} + @Get(':jobId') + @ApiOperation({ summary: 'Get export job status' }) + @ApiResponse({ + status: 200, + description: 'Current export status', + type: ExportStatusResponseDto, + }) + @ApiResponse({ status: 404, description: 'Export job not found' }) + async getExportStatus( + @Param('jobId') jobId: string, + ): Promise { + return this.exportsService.getStatus(jobId); + } +} diff --git a/app/backend/src/exports/exports.module.ts b/app/backend/src/exports/exports.module.ts index d7c34657a..86047194e 100644 --- a/app/backend/src/exports/exports.module.ts +++ b/app/backend/src/exports/exports.module.ts @@ -1,12 +1,6 @@ -/** - * Exports Module - * - * Provides endpoints for requesting data exports and redeeming - * signed download links (BE-102). - */ - import { Module } from '@nestjs/common'; import { ExportsController } from './exports.controller'; +import { ExportsService } from './exports.service'; import { ExportStorageModule } from './export-storage.module'; import { JobQueueModule } from '../job-queue/job-queue.module'; import { ApiKeysModule } from '../api-keys/api-keys.module'; @@ -14,5 +8,7 @@ import { ApiKeysModule } from '../api-keys/api-keys.module'; @Module({ imports: [JobQueueModule, ApiKeysModule, ExportStorageModule], controllers: [ExportsController], + providers: [ExportsService], + exports: [ExportsService], }) export class ExportsModule {} diff --git a/app/backend/src/exports/exports.service.ts b/app/backend/src/exports/exports.service.ts new file mode 100644 index 000000000..d4028c44e --- /dev/null +++ b/app/backend/src/exports/exports.service.ts @@ -0,0 +1,137 @@ +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { JobQueueService, PayloadValidationError } from '../job-queue/job-queue.service'; +import { JobType } from '../job-queue/types'; +import type { ExportGenerationPayload } from '../job-queue/types/job-payloads.types'; +import { EXPORT_ENQUEUED_MESSAGE } from './constants/export.constants'; +import { ExportStatus } from './enums/export.enums'; +import type { RequestExportDto } from './dto/request-export.dto'; +import { ExportRepository } from './export.repository'; +import type { + EnqueueExportResult, + ExportRecord, + ExportStatusView, +} from './types/export.types'; +import { toExportStatusView } from './utils/export-status.util'; +import { + collectExportValidationErrors, + normalizeExportFilters, +} from './utils/export-validation.util'; + +@Injectable() +export class ExportsService { + private readonly logger = new Logger(ExportsService.name); + + constructor( + private readonly jobQueueService: JobQueueService, + private readonly exportRepository: ExportRepository, + ) {} + + async requestExport(dto: RequestExportDto): Promise { + this.assertValidRequest(dto); + + const filters = normalizeExportFilters(dto.filters); + const payload: ExportGenerationPayload = { + userId: dto.userId, + exportType: dto.exportType, + filters, + format: dto.format, + deliveryMethod: dto.deliveryMethod, + }; + + let jobId: string; + + try { + jobId = await this.jobQueueService.enqueue( + JobType.EXPORT_GENERATION, + payload, + ); + } catch (error) { + if (error instanceof PayloadValidationError) { + throw new BadRequestException(error.message); + } + throw error; + } + + try { + await this.exportRepository.create({ + jobId, + userId: dto.userId, + exportType: dto.exportType, + format: dto.format, + deliveryMethod: dto.deliveryMethod, + filters, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error( + `Failed to persist export record for job ${jobId}: ${message}`, + ); + + try { + await this.jobQueueService.cancel(jobId); + } catch (cancelError) { + const cancelMessage = + cancelError instanceof Error ? cancelError.message : String(cancelError); + this.logger.error( + `Failed to cancel export job ${jobId} after persist failure: ${cancelMessage}`, + ); + } + + throw error; + } + + this.logger.log( + `Export enqueued: jobId=${jobId} userId=${dto.userId} type=${dto.exportType}`, + ); + + return { + jobId, + status: ExportStatus.QUEUED, + message: `${EXPORT_ENQUEUED_MESSAGE}. Job ID: ${jobId}`, + }; + } + + async getStatus(jobId: string): Promise { + const record = await this.exportRepository.findByJobId(jobId); + + if (!record) { + throw new NotFoundException(`Export job not found: ${jobId}`); + } + + return toExportStatusView(record); + } + + async markRunning(jobId: string): Promise { + return this.exportRepository.transition(jobId, ExportStatus.RUNNING); + } + + async markCompleted( + jobId: string, + deliveryReference: string, + ): Promise { + return this.exportRepository.transition(jobId, ExportStatus.COMPLETED, { + deliveryReference, + }); + } + + async markFailed(jobId: string, failureReason: string): Promise { + return this.exportRepository.transition(jobId, ExportStatus.FAILED, { + failureReason, + }); + } + + private assertValidRequest(dto: RequestExportDto): void { + const errors = collectExportValidationErrors(dto); + + if (errors.length > 0) { + throw new BadRequestException( + `Invalid export request: ${errors.join(', ')}`, + ); + } + } +} diff --git a/app/backend/src/exports/types/export.types.ts b/app/backend/src/exports/types/export.types.ts new file mode 100644 index 000000000..ea594a2be --- /dev/null +++ b/app/backend/src/exports/types/export.types.ts @@ -0,0 +1,92 @@ +import { + ExportDeliveryMethod, + ExportFormat, + ExportStatus, + ExportType, +} from '../enums/export.enums'; +import { EXPORT_ERROR_CODES } from '../constants/export.constants'; + +export interface ExportRecord { + id: string; + jobId: string; + userId: string; + exportType: ExportType; + format: ExportFormat; + deliveryMethod: ExportDeliveryMethod; + filters: Record; + status: ExportStatus; + deliveryReference: string | null; + failureReason: string | null; + queuedAt: Date; + startedAt: Date | null; + completedAt: Date | null; + failedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateExportRecordInput { + jobId: string; + userId: string; + exportType: ExportType; + format: ExportFormat; + deliveryMethod: ExportDeliveryMethod; + filters: Record; +} + +export interface ExportStatusView { + jobId: string; + status: ExportStatus; + exportType: ExportType; + format: ExportFormat; + deliveryMethod: ExportDeliveryMethod; + queuedAt: string; + startedAt: string | null; + completedAt: string | null; + failedAt: string | null; + deliveryReference?: string; + failureReason?: string; +} + +export interface EnqueueExportResult { + jobId: string; + status: ExportStatus; + message: string; +} + +export interface ExportJobRow { + id: string; + job_id: string; + user_id: string; + export_type: string; + format: string; + delivery_method: string; + filters: Record; + status: string; + delivery_reference: string | null; + failure_reason: string | null; + queued_at: string; + started_at: string | null; + completed_at: string | null; + failed_at: string | null; + created_at: string; + updated_at: string; +} + +export class ExportRecordNotFoundError extends Error { + readonly code = EXPORT_ERROR_CODES.JOB_NOT_FOUND; + + constructor(jobId: string) { + super(`Export job not found: ${jobId}`); + this.name = 'ExportRecordNotFoundError'; + } +} + +export class ExportInvalidTransitionError extends Error { + readonly code = EXPORT_ERROR_CODES.INVALID_TRANSITION; + + constructor(from: ExportStatus, to: ExportStatus) { + super(`Cannot transition export from '${from}' to '${to}'`); + this.name = 'ExportInvalidTransitionError'; + } +} diff --git a/app/backend/src/exports/utils/export-delivery.util.ts b/app/backend/src/exports/utils/export-delivery.util.ts new file mode 100644 index 000000000..c4d855c86 --- /dev/null +++ b/app/backend/src/exports/utils/export-delivery.util.ts @@ -0,0 +1,29 @@ +import { DELIVERY_REFERENCE_PREFIX } from '../constants/export.constants'; +import { ExportDeliveryMethod } from '../enums/export.enums'; + +export interface DeliveryReferenceInput { + jobId: string; + userId: string; + storageKey?: string; +} + +export function buildDeliveryReference( + method: ExportDeliveryMethod, + details: DeliveryReferenceInput, +): string { + switch (method) { + case ExportDeliveryMethod.DOWNLOAD: + return ( + details.storageKey ?? + `${DELIVERY_REFERENCE_PREFIX.download}:${details.jobId}` + ); + case ExportDeliveryMethod.EMAIL: + return `${DELIVERY_REFERENCE_PREFIX.email}:export:${details.jobId}`; + case ExportDeliveryMethod.WEBHOOK: + return `${DELIVERY_REFERENCE_PREFIX.webhook}:${details.userId}`; + default: { + const exhaustive: never = method; + return exhaustive; + } + } +} diff --git a/app/backend/src/exports/utils/export-record.mapper.ts b/app/backend/src/exports/utils/export-record.mapper.ts new file mode 100644 index 000000000..cc2956d3e --- /dev/null +++ b/app/backend/src/exports/utils/export-record.mapper.ts @@ -0,0 +1,28 @@ +import { + ExportDeliveryMethod, + ExportFormat, + ExportStatus, + ExportType, +} from '../enums/export.enums'; +import type { ExportJobRow, ExportRecord } from '../types/export.types'; + +export function mapRowToExportRecord(row: ExportJobRow): ExportRecord { + return { + id: row.id, + jobId: row.job_id, + userId: row.user_id, + exportType: row.export_type as ExportType, + format: row.format as ExportFormat, + deliveryMethod: row.delivery_method as ExportDeliveryMethod, + filters: row.filters ?? {}, + status: row.status as ExportStatus, + deliveryReference: row.delivery_reference, + failureReason: row.failure_reason, + queuedAt: new Date(row.queued_at), + startedAt: row.started_at ? new Date(row.started_at) : null, + completedAt: row.completed_at ? new Date(row.completed_at) : null, + failedAt: row.failed_at ? new Date(row.failed_at) : null, + createdAt: new Date(row.created_at), + updatedAt: new Date(row.updated_at), + }; +} diff --git a/app/backend/src/exports/utils/export-status.util.ts b/app/backend/src/exports/utils/export-status.util.ts new file mode 100644 index 000000000..1b3a23a75 --- /dev/null +++ b/app/backend/src/exports/utils/export-status.util.ts @@ -0,0 +1,42 @@ +import { EXPORT_STATUS_TRANSITIONS } from '../constants/export.constants'; +import { ExportStatus } from '../enums/export.enums'; +import type { ExportRecord, ExportStatusView } from '../types/export.types'; + +export function canTransition( + from: ExportStatus, + to: ExportStatus, +): boolean { + if (from === to) { + return true; + } + + return EXPORT_STATUS_TRANSITIONS[from].includes(to); +} + +export function toIsoString(value: Date | null): string | null { + return value ? value.toISOString() : null; +} + +export function toExportStatusView(record: ExportRecord): ExportStatusView { + const view: ExportStatusView = { + jobId: record.jobId, + status: record.status, + exportType: record.exportType, + format: record.format, + deliveryMethod: record.deliveryMethod, + queuedAt: record.queuedAt.toISOString(), + startedAt: toIsoString(record.startedAt), + completedAt: toIsoString(record.completedAt), + failedAt: toIsoString(record.failedAt), + }; + + if (record.status === ExportStatus.COMPLETED && record.deliveryReference) { + view.deliveryReference = record.deliveryReference; + } + + if (record.status === ExportStatus.FAILED && record.failureReason) { + view.failureReason = record.failureReason; + } + + return view; +} diff --git a/app/backend/src/exports/utils/export-validation.util.ts b/app/backend/src/exports/utils/export-validation.util.ts new file mode 100644 index 000000000..374d6558d --- /dev/null +++ b/app/backend/src/exports/utils/export-validation.util.ts @@ -0,0 +1,99 @@ +import { + EXPORT_DELIVERY_METHOD_VALUES, + EXPORT_FORMAT_VALUES, + EXPORT_TYPE_VALUES, +} from '../constants/export.constants'; +import { + ExportDeliveryMethod, + ExportFormat, + ExportType, +} from '../enums/export.enums'; + +export interface ExportRequestInput { + userId?: unknown; + exportType?: unknown; + format?: unknown; + deliveryMethod?: unknown; + filters?: unknown; +} + +export function isPlainObject( + value: unknown, +): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function isExportType(value: unknown): value is ExportType { + return ( + typeof value === 'string' && + (EXPORT_TYPE_VALUES as string[]).includes(value) + ); +} + +export function isExportFormat(value: unknown): value is ExportFormat { + return ( + typeof value === 'string' && + (EXPORT_FORMAT_VALUES as string[]).includes(value) + ); +} + +export function isExportDeliveryMethod( + value: unknown, +): value is ExportDeliveryMethod { + return ( + typeof value === 'string' && + (EXPORT_DELIVERY_METHOD_VALUES as string[]).includes(value) + ); +} + +export function collectExportValidationErrors( + input: ExportRequestInput, +): string[] { + const errors: string[] = []; + + if (!input.userId || typeof input.userId !== 'string' || !input.userId.trim()) { + errors.push('userId is required and must be a non-empty string'); + } + + if (!isExportType(input.exportType)) { + errors.push( + `exportType is required and must be one of: ${EXPORT_TYPE_VALUES.join(', ')}`, + ); + } + + if (!isExportFormat(input.format)) { + errors.push( + `format is required and must be one of: ${EXPORT_FORMAT_VALUES.join(', ')}`, + ); + } + + if (!isExportDeliveryMethod(input.deliveryMethod)) { + errors.push( + `deliveryMethod is required and must be one of: ${EXPORT_DELIVERY_METHOD_VALUES.join(', ')}`, + ); + } + + if ( + input.filters !== undefined && + input.filters !== null && + !isPlainObject(input.filters) + ) { + errors.push('filters must be a plain object'); + } + + return errors; +} + +export function normalizeExportFilters( + filters: unknown, +): Record { + if (filters === undefined || filters === null) { + return {}; + } + + if (!isPlainObject(filters)) { + return {}; + } + + return filters; +} diff --git a/app/backend/src/job-queue/handlers/__tests__/export-generation.handler.unit.spec.ts b/app/backend/src/job-queue/handlers/__tests__/export-generation.handler.unit.spec.ts index 932c31c76..a62f0287d 100644 --- a/app/backend/src/job-queue/handlers/__tests__/export-generation.handler.unit.spec.ts +++ b/app/backend/src/job-queue/handlers/__tests__/export-generation.handler.unit.spec.ts @@ -6,6 +6,13 @@ import { import { SupabaseService } from "../../../supabase/supabase.service"; import { NotificationService } from "../../../notifications/notification.service"; import { ExportStorageService } from "../../../exports/export-storage.service"; +import { ExportRepository } from "../../../exports/export.repository"; +import { + ExportDeliveryMethod, + ExportFormat, + ExportStatus, + ExportType, +} from "../../../exports/enums/export.enums"; import { Job, CancellationToken, JobStatus } from "../../types"; import { ExportGenerationPayload } from "../../types/job-payloads.types"; import type { ExportCompletedPayload } from "../../../notifications/types/notification.types"; @@ -47,10 +54,10 @@ function makeJob( type: "EXPORT_GENERATION" as unknown as Job["type"], payload: { userId: "GUSER123", - exportType: "transactions", + exportType: ExportType.TRANSACTIONS, filters: {}, - format: "csv", - deliveryMethod: "email", + format: ExportFormat.CSV, + deliveryMethod: ExportDeliveryMethod.EMAIL, ...overrides, }, status: JobStatus.PENDING, @@ -75,8 +82,13 @@ function makeCancellationToken(): CancellationToken { describe("ExportGenerationHandler – email delivery (BE-101)", () => { let handler: ExportGenerationHandler; let notificationService: jest.Mocked; + let exportRepository: { transition: jest.Mock }; beforeEach(async () => { + exportRepository = { + transition: jest.fn().mockResolvedValue({}), + }; + const module: TestingModule = await Test.createTestingModule({ providers: [ ExportGenerationHandler, @@ -98,6 +110,10 @@ describe("ExportGenerationHandler – email delivery (BE-101)", () => { issueDownloadToken: jest.fn().mockReturnValue({ token: 'test-token', expiresAt: Math.floor(Date.now() / 1000) + 3600 }), }, }, + { + provide: ExportRepository, + useValue: exportRepository, + }, ], }).compile(); @@ -127,6 +143,17 @@ describe("ExportGenerationHandler – email delivery (BE-101)", () => { expect(payload.recordCount).toBe(2); expect(payload.jobId).toBe("job-42"); expect(payload.eventId).toBe("export:job-42"); + expect(exportRepository.transition).toHaveBeenNthCalledWith( + 1, + "job-42", + ExportStatus.RUNNING, + ); + expect(exportRepository.transition).toHaveBeenNthCalledWith( + 2, + "job-42", + ExportStatus.COMPLETED, + { deliveryReference: "email:export:job-42" }, + ); }); it("completes the job when the templated email is delivered", async () => { @@ -135,7 +162,7 @@ describe("ExportGenerationHandler – email delivery (BE-101)", () => { templateVersionId: "tpl-version-7", }); - const job = makeJob({ deliveryMethod: "email", format: "json" }); + const job = makeJob({ deliveryMethod: ExportDeliveryMethod.EMAIL, format: ExportFormat.JSON }); await expect( handler.execute(job, makeCancellationToken()), @@ -187,7 +214,7 @@ describe("ExportGenerationHandler – email delivery (BE-101)", () => { templateVersionId: undefined, }); - const job = makeJob({ deliveryMethod: "download" }); + const job = makeJob({ deliveryMethod: ExportDeliveryMethod.DOWNLOAD }); await expect( handler.execute(job, makeCancellationToken()), @@ -195,7 +222,7 @@ describe("ExportGenerationHandler – email delivery (BE-101)", () => { }); it("does not send an email for webhook deliveries", async () => { - const job = makeJob({ deliveryMethod: "webhook" }); + const job = makeJob({ deliveryMethod: ExportDeliveryMethod.WEBHOOK }); await expect( handler.execute(job, makeCancellationToken()), @@ -208,10 +235,10 @@ describe("ExportGenerationHandler – email delivery (BE-101)", () => { it("passes for a valid email delivery payload", async () => { const payload: ExportGenerationPayload = { userId: "GUSER123", - exportType: "payments", + exportType: ExportType.PAYMENTS, filters: {}, - format: "csv", - deliveryMethod: "email", + format: ExportFormat.CSV, + deliveryMethod: ExportDeliveryMethod.EMAIL, }; await expect(handler.validate(payload)).resolves.toBeUndefined(); @@ -247,6 +274,11 @@ describe("ExportGenerationHandler – email delivery (BE-101)", () => { "csv", // format "database connection refused", // safe reason (single-line message) ); + expect(exportRepository.transition).toHaveBeenCalledWith( + "job-42", + ExportStatus.FAILED, + { failureReason: "database connection refused" }, + ); }); it("strips embedded newlines / stack frames from the failure reason before notifying the user", async () => { diff --git a/app/backend/src/job-queue/handlers/export-generation.handler.ts b/app/backend/src/job-queue/handlers/export-generation.handler.ts index 2d45af7dc..46a4fdfdb 100644 --- a/app/backend/src/job-queue/handlers/export-generation.handler.ts +++ b/app/backend/src/job-queue/handlers/export-generation.handler.ts @@ -14,6 +14,15 @@ import { SupabaseService } from '../../supabase/supabase.service'; import { NotificationService } from '../../notifications/notification.service'; import { ExportCompletedPayload } from '../../notifications/types/notification.types'; import { ExportStorageService } from '../../exports/export-storage.service'; +import { ExportRepository } from '../../exports/export.repository'; +import { + ExportDeliveryMethod, + ExportFormat, + ExportStatus, + ExportType, +} from '../../exports/enums/export.enums'; +import { collectExportValidationErrors } from '../../exports/utils/export-validation.util'; +import { buildDeliveryReference } from '../../exports/utils/export-delivery.util'; /** * Error thrown for permanent job failures (no retry) @@ -41,6 +50,7 @@ export class ExportGenerationHandler implements JobHandler, cancellationToken: CancellationToken, ): Promise[]> { @@ -132,34 +145,16 @@ export class ExportGenerationHandler implements JobHandler = { + [ExportType.TRANSACTIONS]: 'transactions', + [ExportType.LINKS]: 'links', + [ExportType.PAYMENTS]: 'payments', + }; - default: - throw new PermanentJobError(`Unsupported export type: ${exportType}`); - } + let query = client + .from(tableByExportType[exportType]) + .select('*') + .eq('user_id', userId); // Apply filters for (const [key, value] of Object.entries(filters)) { @@ -194,10 +189,10 @@ export class ExportGenerationHandler implements JobHandler[], - format: 'csv' | 'json', + format: ExportFormat, cancellationToken: CancellationToken, ): Promise { - if (format === 'json') { + if (format === ExportFormat.JSON) { // JSON export is simple - just stringify cancellationToken.throwIfCancelled(); return JSON.stringify(records, null, 2); @@ -260,28 +255,29 @@ export class ExportGenerationHandler implements JobHandler { + ): Promise { cancellationToken.throwIfCancelled(); switch (deliveryMethod) { - case 'webhook': - // TODO: Implement webhook delivery - // For now, just log + case ExportDeliveryMethod.WEBHOOK: this.logger.log(`Webhook delivery not yet implemented for user ${userId}`); - break; + return buildDeliveryReference(ExportDeliveryMethod.WEBHOOK, { jobId, userId }); - case 'email': { + case ExportDeliveryMethod.EMAIL: { const payload: ExportCompletedPayload = { eventType: 'export.completed', eventId: `export:${jobId}`, recipientPublicKey: userId, title: `Your ${exportType} export is ready`, - body: `Your ${format.toUpperCase()} export of ${recordCount} ${recordCount === 1 ? 'record' : 'records'} has been generated and is attached to this delivery.`, + body: + `Your ${format.toUpperCase()} export of ${recordCount} ` + + `${recordCount === 1 ? 'record' : 'records'} has been generated ` + + `and is attached to this delivery.`, occurredAt: new Date().toISOString(), exportType, format, @@ -299,24 +295,27 @@ export class ExportGenerationHandler implements JobHandler { - const errors: string[] = []; + const errors = collectExportValidationErrors(payload); - if (!payload.userId || typeof payload.userId !== 'string') { - errors.push('userId is required and must be a string'); - } - - if (!payload.exportType || !['transactions', 'links', 'payments'].includes(payload.exportType)) { - errors.push('exportType is required and must be one of: transactions, links, payments'); - } - - if (!payload.format || !['csv', 'json'].includes(payload.format)) { - errors.push('format is required and must be one of: csv, json'); - } - - if (!payload.deliveryMethod || !['webhook', 'email', 'download'].includes(payload.deliveryMethod)) { - errors.push('deliveryMethod is required and must be one of: webhook, email, download'); - } - - if (!payload.filters || typeof payload.filters !== 'object') { + if (payload.filters === undefined || payload.filters === null) { errors.push('filters is required and must be an object'); } - if (errors.length > 0) { - throw new PermanentJobError(`Validation failed: ${errors.join(', ')}`); + const uniqueErrors = [...new Set(errors)]; + + if (uniqueErrors.length > 0) { + throw new PermanentJobError(`Validation failed: ${uniqueErrors.join(', ')}`); } } @@ -418,7 +411,8 @@ export class ExportGenerationHandler implements JobHandler; /** Output format */ - format: 'csv' | 'json'; + format: ExportFormat; /** How to deliver the export */ - deliveryMethod: 'webhook' | 'email' | 'download'; + deliveryMethod: ExportDeliveryMethod; } /** diff --git a/app/backend/src/job-queue/types/job.types.test.ts b/app/backend/src/job-queue/types/job.types.test.ts index 03dfa0bfb..ebf6b27e9 100644 --- a/app/backend/src/job-queue/types/job.types.test.ts +++ b/app/backend/src/job-queue/types/job.types.test.ts @@ -16,6 +16,11 @@ import { ReconciliationPayload, StellarReconnectPayload, } from '../index'; +import { + ExportDeliveryMethod, + ExportFormat, + ExportType, +} from '../../exports/enums/export.enums'; describe('Job Queue Types', () => { describe('JobType enum', () => { @@ -123,13 +128,13 @@ describe('Job Queue Types', () => { it('should allow creating ExportGenerationPayload', () => { const payload: ExportGenerationPayload = { userId: 'user_123', - exportType: 'transactions', + exportType: ExportType.TRANSACTIONS, filters: { startDate: '2024-01-01' }, - format: 'csv', - deliveryMethod: 'download', + format: ExportFormat.CSV, + deliveryMethod: ExportDeliveryMethod.DOWNLOAD, }; - expect(payload.exportType).toBe('transactions'); + expect(payload.exportType).toBe(ExportType.TRANSACTIONS); }); it('should allow creating ReconciliationPayload', () => { diff --git a/app/backend/supabase/migrations/20260828120000_create_export_jobs_table.sql b/app/backend/supabase/migrations/20260828120000_create_export_jobs_table.sql new file mode 100644 index 000000000..a2f33fc50 --- /dev/null +++ b/app/backend/supabase/migrations/20260828120000_create_export_jobs_table.sql @@ -0,0 +1,31 @@ +CREATE TABLE IF NOT EXISTS export_jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + job_id UUID NOT NULL UNIQUE REFERENCES jobs (id) ON DELETE CASCADE, + user_id TEXT NOT NULL, + export_type TEXT NOT NULL + CHECK (export_type IN ('transactions', 'links', 'payments')), + format TEXT NOT NULL + CHECK (format IN ('csv', 'json')), + delivery_method TEXT NOT NULL + CHECK (delivery_method IN ('webhook', 'email', 'download')), + filters JSONB NOT NULL DEFAULT '{}'::jsonb, + status TEXT NOT NULL DEFAULT 'queued' + CHECK (status IN ('queued', 'running', 'completed', 'failed')), + delivery_reference TEXT, + failure_reason TEXT, + queued_at TIMESTAMPTZ NOT NULL DEFAULT now(), + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + failed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_export_jobs_user_id + ON export_jobs (user_id); + +CREATE INDEX IF NOT EXISTS idx_export_jobs_status + ON export_jobs (status); + +COMMENT ON TABLE export_jobs IS + 'Export job records with status lifecycle (queued, running, completed, failed).';