diff --git a/docs/multi-tenancy.md b/docs/multi-tenancy.md index 12d9b78..a5a94fb 100644 --- a/docs/multi-tenancy.md +++ b/docs/multi-tenancy.md @@ -22,6 +22,49 @@ org-scoped endpoint must uphold. `req.params`. Client-supplied `orgId` values in query strings or request bodies are never trusted for data access decisions. +### Org-scoped retention and hard purge + +Retention purge jobs are also scoped to the organization whose ID appears in the job +payload. A per-org retention pass only hard-deletes soft-deleted vaults and related +milestones for that organization. + +Retention configuration supports the following environment variables: + +- `RETENTION_PURGE_AGE_MS` — minimum age before a soft-deleted vault is eligible for hard purge. + If unset, the per-org retention window is consulted (see below). +- `RETENTION_PURGE_INTERVAL_MS` — frequency at which the retention scheduler enqueues per-org jobs. +- `RETENTION_PURGE_BATCH_SIZE` — maximum number of vaults deleted per org on each retention run. + +Every retention purge also writes an audit log entry scoped to the target organization with a +summary of deleted vault and milestone counts. + +### Per-org retention window + +Each organization can override the global retention window by setting +`retention_purge_age_ms` in its `metadata` JSONB column: + +```sql +UPDATE organizations +SET metadata = jsonb_set( + COALESCE(metadata, '{}'::jsonb), + '{retention_purge_age_ms}', + '604800000' -- 7 days in milliseconds +) +WHERE id = ''; +``` + +Resolution order (first match wins): + +1. Explicit `retentionAgeMs` argument passed to `purgeSoftDeletedVaults` (testing). +2. `RETENTION_PURGE_AGE_MS` environment variable (global override). +3. `organizations.metadata.retention_purge_age_ms` (per-org override). +4. Built-in default of 30 days (`2_592_000_000` ms). + +If the per-org value is missing, unset, or not a valid non-negative number, the system +falls through to the next level. This allows a cluster-wide default via `RETENTION_PURGE_AGE_MS` +while letting individual organizations opt into a shorter (or longer) window through their +metadata. + 5. **No cross-org leakage** — pagination, sorting, and filtering are applied *after* the org-scope filter. A filter that matches zero records in the target org returns an empty result set, not records from another org. diff --git a/src/jobs/handlers.ts b/src/jobs/handlers.ts index fa108d5..796f36a 100644 --- a/src/jobs/handlers.ts +++ b/src/jobs/handlers.ts @@ -13,6 +13,8 @@ import { processDeferredReminders, } from '../services/vaultExpiry.service.js' import { cleanupExpiredSessions } from '../services/session.js' +import { purgeSoftDeletedVaults } from '../services/retention.js' +import { createAuditLog } from '../lib/audit-logs.js' import { relayOutboxBatch } from '../services/outboxRelay.js' import { runReindexBatches } from '../services/evidenceReindex.js' import { renderOrgAnalyticsSnapshot } from '../services/analytics.service.js' @@ -185,6 +187,25 @@ export const createDefaultJobHandlers = ( `deleted=${deleted} batchSize=${batchSize} attempt=${context.attempt}`, ) }, + 'retention.purge': async (payload, context) => { + const batchSize = payload.batchSize ?? 1000 + const result = await purgeSoftDeletedVaults(payload.organizationId, batchSize) + const message = `organization=${payload.organizationId} deletedVaults=${result.deletedVaults} deletedMilestones=${result.deletedMilestones} batchSize=${batchSize}` + logJob('retention.purge', `${message} attempt=${context.attempt}`) + + await createAuditLog({ + actor_user_id: 'system', + organization_id: payload.organizationId, + action: 'retention.purge', + target_type: 'organization', + target_id: payload.organizationId, + metadata: { + deleted_vaults: result.deletedVaults, + deleted_milestones: result.deletedMilestones, + batch_size: batchSize, + }, + }) + }, 'outbox.relay': async (payload, context) => { const count = await relayOutboxBatch() logJob( diff --git a/src/jobs/system.ts b/src/jobs/system.ts index 0115de1..79adc03 100644 --- a/src/jobs/system.ts +++ b/src/jobs/system.ts @@ -8,6 +8,7 @@ import { } from './queue.js' import { type EnqueueOptions, type JobPayloadByType, type JobType } from './types.js' import { recoverPendingExportJobs } from '../services/exportQueue.js' +import { listOrganizations } from '../services/organization.js' import { createNotificationService, type NotificationService, @@ -197,6 +198,7 @@ export class BackgroundJobSystem { this.queue.registerHandler('analytics.report.generate', handlers['analytics.report.generate']) this.queue.registerHandler('export.generate', handlers['export.generate']) this.queue.registerHandler('sessions.cleanup', handlers['sessions.cleanup']) + this.queue.registerHandler('retention.purge', handlers['retention.purge']) this.queue.registerHandler('outbox.relay', handlers['outbox.relay']) this.queue.registerHandler('embeddings.reindex', handlers['embeddings.reindex']) this.queue.registerHandler('saved-search.evaluate', handlers['saved-search.evaluate']) @@ -339,6 +341,36 @@ export class BackgroundJobSystem { }, }) + const retentionPurgeIntervalMs = parsePositiveInteger( + process.env.RETENTION_PURGE_INTERVAL_MS, + 86_400_000, + ) + const retentionPurgeBatchSize = parsePositiveInteger( + process.env.RETENTION_PURGE_BATCH_SIZE, + 500, + ) + + this.schedulerRegistry.registerJob({ + name: 'retention.purge', + intervalMs: retentionPurgeIntervalMs, + immediate: true, + initialDelayMs: 20_000, + execute: async () => { + try { + const organizations = await listOrganizations() + for (const org of organizations) { + this.enqueue('retention.purge', { + organizationId: org.id, + batchSize: retentionPurgeBatchSize, + }) + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(`[jobs:retention.purge] failed to enqueue jobs: ${message}`) + } + }, + }) + this.schedulerRegistry.registerJob({ name: 'outbox.relay', intervalMs: outboxRelayIntervalMs, diff --git a/src/jobs/types.ts b/src/jobs/types.ts index 6e2fef4..c3decf6 100644 --- a/src/jobs/types.ts +++ b/src/jobs/types.ts @@ -10,6 +10,7 @@ export const JOB_TYPES = [ 'export.generate', 'vault.reconcile', 'sessions.cleanup', + 'retention.purge', 'outbox.relay', 'embeddings.reindex', 'saved-search.evaluate', @@ -72,6 +73,11 @@ export interface SessionsCleanupJobPayload { batchSize?: number } +export interface RetentionPurgeJobPayload { + organizationId: string + batchSize?: number +} + export interface OutboxRelayJobPayload { batchSize?: number } @@ -84,7 +90,6 @@ export interface EmbeddingsReindexJobPayload { export interface SavedSearchEvaluateJobPayload { searchId?: string } - export interface JobPayloadByType { 'notification.send': NotificationJobPayload 'deadline.check': DeadlineCheckJobPayload @@ -97,6 +102,7 @@ export interface JobPayloadByType { 'export.generate': ExportGenerateJobPayload 'vault.reconcile': VaultReconcileJobPayload 'sessions.cleanup': SessionsCleanupJobPayload + 'retention.purge': RetentionPurgeJobPayload 'outbox.relay': OutboxRelayJobPayload 'embeddings.reindex': EmbeddingsReindexJobPayload 'saved-search.evaluate': SavedSearchEvaluateJobPayload @@ -117,7 +123,7 @@ export interface EnqueueOptions { maxAttempts?: number } -const isRecord = (value: unknown): value is Record => { +export const isRecord = (value: unknown): value is Record => { return typeof value === 'object' && value !== null } @@ -193,6 +199,11 @@ export const isPayloadForJobType = ( ) case 'sessions.cleanup': return payload.batchSize === undefined || (typeof payload.batchSize === 'number' && payload.batchSize > 0) + case 'retention.purge': + return ( + isNonEmptyString(payload.organizationId) && + (payload.batchSize === undefined || (typeof payload.batchSize === 'number' && payload.batchSize > 0)) + ) case 'outbox.relay': return true case 'embeddings.reindex': diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 3aa804a..6c26803 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -84,6 +84,11 @@ export const analyticsRecomputePayloadSchema = z.object({ reason: z.string().optional(), }) +export const retentionPurgePayloadSchema = z.object({ + organizationId: nonEmptyString, + batchSize: z.number().int().min(1).optional(), +}) + export const enqueueJobSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('notification.send'), @@ -109,6 +114,12 @@ export const enqueueJobSchema = z.discriminatedUnion('type', [ maxAttempts: z.number().int().min(1).max(10).optional(), delayMs: z.number().int().min(0).max(60000).optional(), }), + z.object({ + type: z.literal('retention.purge'), + payload: retentionPurgePayloadSchema, + maxAttempts: z.number().int().min(1).max(10).optional(), + delayMs: z.number().int().min(0).max(60000).optional(), + }), ]) export interface ValidationErrorField { diff --git a/src/routes/jobs.ts b/src/routes/jobs.ts index bbca344..a954d04 100644 --- a/src/routes/jobs.ts +++ b/src/routes/jobs.ts @@ -1,11 +1,13 @@ import { Router, type RequestHandler } from 'express' -import { z } from 'zod' import { UserRole } from '../types/user.js' import type { BackgroundJobSystem } from '../jobs/system.js' import { type EnqueueOptions, type JobPayloadByType, type JobType, + isJobType, + isPayloadForJobType, + isRecord, } from '../jobs/types.js' import { parseEnqueueOptions } from '../jobs/enqueueOptions.js' import { authenticate, authorize } from '../middleware/auth.js' @@ -48,6 +50,8 @@ const enqueueTypedJob = ( return jobSystem.enqueue(type, payload, options) case 'analytics.recompute': return jobSystem.enqueue(type, payload, options) + case 'retention.purge': + return jobSystem.enqueue(type, payload, options) default: throw new Error('Unsupported job type') } @@ -211,7 +215,7 @@ export const createJobsRouter = (jobSystem: BackgroundJobSystem, options: JobsRo if (!isJobType(type)) { res.status(400).json({ error: - 'Invalid or missing job type. Supported types: notification.send, deadline.check, oracle.call, analytics.recompute', + 'Invalid or missing job type. Supported types: notification.send, deadline.check, oracle.call, analytics.recompute, retention.purge', }) return } diff --git a/src/services/retention.ts b/src/services/retention.ts new file mode 100644 index 0000000..d7434b6 --- /dev/null +++ b/src/services/retention.ts @@ -0,0 +1,99 @@ +import db from '../db/index.js' +import type { Knex } from 'knex' + +export type PurgeSoftDeletedVaultsResult = { + deletedVaults: number + deletedMilestones: number +} + +const DEFAULT_RETENTION_AGE_MS = 30 * 24 * 60 * 60 * 1000 // 30 days + +const validateOrganizationId = (organizationId: string): void => { + if (typeof organizationId !== 'string' || organizationId.trim() === '') { + throw new Error('organizationId is required') + } +} + +const getGlobalRetentionAgeMs = (): number | undefined => { + const rawValue = process.env.RETENTION_PURGE_AGE_MS + if (rawValue === undefined || rawValue.trim() === '') { + return undefined + } + + const parsed = Number(rawValue) + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error('RETENTION_PURGE_AGE_MS must be a non-negative integer') + } + + return Math.floor(parsed) +} + +const resolvePerOrgRetentionAgeMs = async ( + organizationId: string, + knexInstance: typeof db | Knex, +): Promise => { + const org = await knexInstance('organizations') + .select('metadata') + .where('id', organizationId) + .first() + + const orgRetention = org?.metadata?.retention_purge_age_ms + if (typeof orgRetention === 'number' && Number.isFinite(orgRetention) && orgRetention >= 0) { + return Math.floor(orgRetention) + } + + return DEFAULT_RETENTION_AGE_MS +} + +export const purgeSoftDeletedVaults = async ( + organizationId: string, + batchSize = 500, + knexInstance: typeof db | Knex = db, + retentionAgeMs?: number, +): Promise => { + validateOrganizationId(organizationId) + + if (!Number.isFinite(batchSize) || batchSize <= 0) { + throw new Error('batchSize must be a positive integer') + } + + const effectiveRetentionAgeMs = + retentionAgeMs !== undefined + ? retentionAgeMs + : getGlobalRetentionAgeMs() ?? (await resolvePerOrgRetentionAgeMs(organizationId, knexInstance)) + + return await knexInstance.transaction(async (trx) => { + const cutoffDate = + effectiveRetentionAgeMs > 0 + ? new Date(Date.now() - effectiveRetentionAgeMs) + : undefined + + let query = trx('vaults') + .where({ organization_id: organizationId }) + .whereNotNull('deleted_at') + + if (cutoffDate) { + query = query.where('deleted_at', '<=', cutoffDate) + } + + const vaultIds = await query.limit(batchSize).pluck('id') + + if (vaultIds.length === 0) { + return { deletedVaults: 0, deletedMilestones: 0 } + } + + const milestonesCountRow = await trx('milestones') + .whereIn('vault_id', vaultIds) + .count<{ total: string }>('id as total') + .first() + + const deletedMilestones = Number(milestonesCountRow?.total ?? 0) + + const deletedVaults = await trx('vaults').whereIn('id', vaultIds).delete() + + return { + deletedVaults, + deletedMilestones, + } + }) +} diff --git a/src/tests/jobs.retentionPurge.handler.test.ts b/src/tests/jobs.retentionPurge.handler.test.ts new file mode 100644 index 0000000..ede0e4a --- /dev/null +++ b/src/tests/jobs.retentionPurge.handler.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, jest, beforeEach } from '@jest/globals' + +const mockPurgeSoftDeletedVaults = jest.fn() +const mockCreateAuditLog = jest.fn() + +jest.unstable_mockModule('../services/retention.js', () => ({ + purgeSoftDeletedVaults: mockPurgeSoftDeletedVaults, +})) + +jest.unstable_mockModule('../lib/audit-logs.js', () => ({ + createAuditLog: mockCreateAuditLog, +})) + +const { createDefaultJobHandlers } = await import('../jobs/handlers.js') + +describe('retention.purge job handler', () => { + beforeEach(() => { + jest.clearAllMocks() + mockPurgeSoftDeletedVaults.mockResolvedValue({ deletedVaults: 2, deletedMilestones: 3 }) + mockCreateAuditLog.mockResolvedValue({ id: 'audit-retention-1' }) + }) + + it('invokes retention purge and writes an audit log summary', async () => { + const handlers = createDefaultJobHandlers({} as any) + + await handlers['retention.purge']( + { organizationId: 'org-1', batchSize: 42 }, + { jobId: 'job-123', attempt: 1 }, + ) + + expect(mockPurgeSoftDeletedVaults).toHaveBeenCalledWith('org-1', 42) + expect(mockCreateAuditLog).toHaveBeenCalledWith( + expect.objectContaining({ + actor_user_id: 'system', + organization_id: 'org-1', + action: 'retention.purge', + target_type: 'organization', + target_id: 'org-1', + metadata: { + deleted_vaults: 2, + deleted_milestones: 3, + batch_size: 42, + }, + }), + ) + }) +}) diff --git a/src/tests/vault.retentionPurge.test.ts b/src/tests/vault.retentionPurge.test.ts new file mode 100644 index 0000000..7f028cc --- /dev/null +++ b/src/tests/vault.retentionPurge.test.ts @@ -0,0 +1,651 @@ +import knex, { type Knex } from 'knex' +import { beforeAll, beforeEach, afterAll, describe, expect, it } from '@jest/globals' +import { purgeSoftDeletedVaults } from '../services/retention.js' + +let temporaryTestDbName: string | null = null + +const createRetentionTestDatabase = async (): Promise => { + const baseDatabaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5433/postgres' + const adminUrl = new URL(baseDatabaseUrl) + adminUrl.pathname = '/postgres' + + temporaryTestDbName = `disciplr_test_retention_${Date.now()}_${Math.floor(Math.random() * 10000)}` + const adminDb = knex({ client: 'pg', connection: adminUrl.toString(), pool: { min: 1, max: 1 } }) + try { + await adminDb.raw(`CREATE DATABASE "${temporaryTestDbName}"`) + } finally { + await adminDb.destroy() + } + + const testUrl = new URL(baseDatabaseUrl) + testUrl.pathname = `/${temporaryTestDbName}` + const db = knex({ client: 'pg', connection: testUrl.toString(), pool: { min: 1, max: 2 } }) + + await db.raw('select 1') + + await db.schema.createTable('organizations', (table) => { + table.string('id', 255).primary() + table.string('name', 255).notNullable() + table.string('slug', 255).notNullable() + table.jsonb('metadata').nullable() + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()) + }) + + await db.schema.createTable('vaults', (table) => { + table.string('id', 255).primary() + table.string('creator', 255).notNullable() + table.decimal('amount', 32, 7).notNullable() + table.timestamp('start_timestamp', { useTz: true }).notNullable() + table.timestamp('end_timestamp', { useTz: true }).notNullable() + table.string('success_destination', 255).notNullable() + table.string('failure_destination', 255).notNullable() + table.string('status', 64).notNullable() + table.timestamp('deleted_at', { useTz: true }).nullable() + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()) + table.string('organization_id', 255).nullable() + table + .foreign('organization_id') + .references('id') + .inTable('organizations') + .onDelete('SET NULL') + }) + + await db.schema.createTable('milestones', (table) => { + table.string('id', 255).primary() + table.string('vault_id', 255).notNullable() + table.string('title', 255).notNullable() + table.string('description', 1024).notNullable() + table.decimal('target_amount', 32, 7).notNullable() + table.decimal('current_amount', 32, 7).notNullable().defaultTo('0') + table.timestamp('deadline', { useTz: true }).notNullable() + table.string('status', 64).notNullable() + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()) + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()) + table + .foreign('vault_id') + .references('id') + .inTable('vaults') + .onDelete('CASCADE') + }) + + return db +} + +describe('Retention Service - purgeSoftDeletedVaults', () => { + let db: Knex + + beforeAll(async () => { + db = await createRetentionTestDatabase() + }) + + afterAll(async () => { + await db.destroy() + + if (temporaryTestDbName) { + const adminUrl = new URL(process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5433/postgres') + adminUrl.pathname = '/postgres' + const adminDb = knex({ client: 'pg', connection: adminUrl.toString(), pool: { min: 1, max: 1 } }) + try { + await adminDb.raw(`DROP DATABASE IF EXISTS "${temporaryTestDbName}"`) + } finally { + await adminDb.destroy() + } + } + }) + + beforeEach(async () => { + delete process.env.RETENTION_PURGE_AGE_MS + await db('milestones').delete() + await db('vaults').delete() + await db('organizations').delete() + }) + + it('deletes only soft-deleted vaults for the specified organization and counts cascade milestones', async () => { + const [organization] = await db('organizations') + .insert({ id: 'org-one', name: 'Org One', slug: 'org-one' }) + .returning('*') + + await db('vaults').insert([ + { + id: 'vault-org1-1', + creator: 'GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + amount: '1000.0000000', + start_timestamp: new Date('2024-01-01T00:00:00Z'), + end_timestamp: new Date('2024-06-01T00:00:00Z'), + success_destination: 'GSUCCESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + failure_destination: 'GFAILUREXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + status: 'cancelled', + deleted_at: new Date('2025-01-01T00:00:00Z'), + created_at: new Date(), + organization_id: organization.id, + }, + { + id: 'vault-org1-2', + creator: 'GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXYYYYYYYY', + amount: '2000.0000000', + start_timestamp: new Date('2024-02-01T00:00:00Z'), + end_timestamp: new Date('2024-07-01T00:00:00Z'), + success_destination: 'GSUCCESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXYYYYYYYY', + failure_destination: 'GFAILUREXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXYYYYYYYY', + status: 'active', + deleted_at: null, + created_at: new Date(), + organization_id: organization.id, + }, + ]) + + await db('milestones').insert([ + { + id: 'milestone-org1-1', + vault_id: 'vault-org1-1', + title: 'Milestone One', + description: 'Soft deleted vault milestone', + target_amount: '500.0000000', + current_amount: '0', + deadline: new Date('2024-05-01T00:00:00Z'), + status: 'pending', + created_at: new Date(), + updated_at: new Date(), + }, + { + id: 'milestone-org1-2', + vault_id: 'vault-org1-1', + title: 'Milestone Two', + description: 'Soft deleted vault milestone', + target_amount: '500.0000000', + current_amount: '0', + deadline: new Date('2024-06-01T00:00:00Z'), + status: 'pending', + created_at: new Date(), + updated_at: new Date(), + }, + { + id: 'milestone-org1-3', + vault_id: 'vault-org1-2', + title: 'Active Vault Milestone', + description: 'Active vault milestone should remain', + target_amount: '2000.0000000', + current_amount: '0', + deadline: new Date('2024-07-01T00:00:00Z'), + status: 'pending', + created_at: new Date(), + updated_at: new Date(), + }, + ]) + + const result = await purgeSoftDeletedVaults(organization.id, 10, db) + + expect(result).toEqual({ deletedVaults: 1, deletedMilestones: 2 }) + + const remainingVaults = await db('vaults').select('id', 'deleted_at', 'organization_id') + expect(remainingVaults).toHaveLength(1) + expect(remainingVaults[0].id).toBe('vault-org1-2') + + const remainingMilestones = await db('milestones').select('id', 'vault_id') + expect(remainingMilestones).toHaveLength(1) + expect(remainingMilestones[0].vault_id).toBe('vault-org1-2') + }) + + it('honors batchSize and deletes only one soft-deleted vault per batch', async () => { + const [organization] = await db('organizations') + .insert({ id: 'org-batch', name: 'Org Batch', slug: 'org-batch' }) + .returning('*') + + await db('vaults').insert([ + { + id: 'vault-batch-1', + creator: 'GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXAAAAAAA', + amount: '1000.0000000', + start_timestamp: new Date('2024-01-01T00:00:00Z'), + end_timestamp: new Date('2024-06-01T00:00:00Z'), + success_destination: 'GSUCCESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXAAAAAAA', + failure_destination: 'GFAILUREXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXAAAAAAA', + status: 'cancelled', + deleted_at: new Date('2025-01-01T00:00:00Z'), + created_at: new Date(), + organization_id: organization.id, + }, + { + id: 'vault-batch-2', + creator: 'GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXBBBBBBB', + amount: '2000.0000000', + start_timestamp: new Date('2024-02-01T00:00:00Z'), + end_timestamp: new Date('2024-07-01T00:00:00Z'), + success_destination: 'GSUCCESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXBBBBBBB', + failure_destination: 'GFAILUREXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXBBBBBBB', + status: 'failed', + deleted_at: new Date('2025-01-02T00:00:00Z'), + created_at: new Date(), + organization_id: organization.id, + }, + ]) + + await db('milestones').insert([ + { + id: 'milestone-batch-1', + vault_id: 'vault-batch-1', + title: 'Batch Milestone One', + description: 'First soft deleted vault milestone', + target_amount: '1000.0000000', + current_amount: '0', + deadline: new Date('2024-03-01T00:00:00Z'), + status: 'pending', + created_at: new Date(), + updated_at: new Date(), + }, + { + id: 'milestone-batch-2', + vault_id: 'vault-batch-2', + title: 'Batch Milestone Two', + description: 'Second soft deleted vault milestone', + target_amount: '2000.0000000', + current_amount: '0', + deadline: new Date('2024-04-01T00:00:00Z'), + status: 'pending', + created_at: new Date(), + updated_at: new Date(), + }, + ]) + + const result = await purgeSoftDeletedVaults(organization.id, 1, db) + + expect(result.deletedVaults).toBe(1) + expect(result.deletedMilestones).toBe(1) + + const remainingDeletedVaults = await db('vaults') + .where({ organization_id: organization.id }) + .whereNotNull('deleted_at') + .select('id') + + expect(remainingDeletedVaults).toHaveLength(1) + }) + + it('does not purge soft-deleted vaults from other organizations', async () => { + await db('organizations').insert([ + { id: 'org-one', name: 'Org One', slug: 'org-one' }, + { id: 'org-two', name: 'Org Two', slug: 'org-two' }, + ]) + + await db('vaults').insert([ + { + id: 'vault-org-one', + creator: 'GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXAAAAAAAA', + amount: '1000.0000000', + start_timestamp: new Date('2024-01-01T00:00:00Z'), + end_timestamp: new Date('2024-06-01T00:00:00Z'), + success_destination: 'GSUCCESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXAAAAAAAA', + failure_destination: 'GFAILUREXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXAAAAAAAA', + status: 'cancelled', + deleted_at: new Date('2025-01-01T00:00:00Z'), + created_at: new Date(), + organization_id: 'org-one', + }, + { + id: 'vault-org-two', + creator: 'GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXBBBBBBBB', + amount: '2000.0000000', + start_timestamp: new Date('2024-02-01T00:00:00Z'), + end_timestamp: new Date('2024-07-01T00:00:00Z'), + success_destination: 'GSUCCESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXBBBBBBBB', + failure_destination: 'GFAILUREXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXBBBBBBBB', + status: 'cancelled', + deleted_at: new Date('2025-01-01T00:00:00Z'), + created_at: new Date(), + organization_id: 'org-two', + }, + ]) + + await db('milestones').insert([ + { + id: 'milestone-org-one', + vault_id: 'vault-org-one', + title: 'Org One Milestone', + description: 'Belongs to org one', + target_amount: '1000.0000000', + current_amount: '0', + deadline: new Date('2024-05-01T00:00:00Z'), + status: 'pending', + created_at: new Date(), + updated_at: new Date(), + }, + { + id: 'milestone-org-two', + vault_id: 'vault-org-two', + title: 'Org Two Milestone', + description: 'Belongs to org two', + target_amount: '2000.0000000', + current_amount: '0', + deadline: new Date('2024-06-01T00:00:00Z'), + status: 'pending', + created_at: new Date(), + updated_at: new Date(), + }, + ]) + + const result = await purgeSoftDeletedVaults('org-one', 10, db) + + expect(result).toEqual({ deletedVaults: 1, deletedMilestones: 1 }) + + const remainingOrgTwoVaults = await db('vaults') + .where({ organization_id: 'org-two' }) + .select('id') + + expect(remainingOrgTwoVaults).toHaveLength(1) + expect(remainingOrgTwoVaults[0].id).toBe('vault-org-two') + }) + + it('respects RETENTION_PURGE_AGE_MS and excludes recently deleted vaults', async () => { + process.env.RETENTION_PURGE_AGE_MS = String(24 * 60 * 60 * 1000) + + await db('organizations').insert({ id: 'org-window', name: 'Org Window', slug: 'org-window' }) + + await db('vaults').insert([ + { + id: 'vault-window-old', + creator: 'GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXCCCCCCCC', + amount: '1000.0000000', + start_timestamp: new Date('2024-01-01T00:00:00Z'), + end_timestamp: new Date('2024-06-01T00:00:00Z'), + success_destination: 'GSUCCESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXCCCCCCCC', + failure_destination: 'GFAILUREXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXCCCCCCCC', + status: 'cancelled', + deleted_at: new Date(Date.now() - 36 * 60 * 60 * 1000), + created_at: new Date(), + organization_id: 'org-window', + }, + { + id: 'vault-window-recent', + creator: 'GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXDDDDDDDD', + amount: '2000.0000000', + start_timestamp: new Date('2024-02-01T00:00:00Z'), + end_timestamp: new Date('2024-07-01T00:00:00Z'), + success_destination: 'GSUCCESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXDDDDDDDD', + failure_destination: 'GFAILUREXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXDDDDDDDD', + status: 'cancelled', + deleted_at: new Date(Date.now() - 2 * 60 * 60 * 1000), + created_at: new Date(), + organization_id: 'org-window', + }, + ]) + + await db('milestones').insert([ + { + id: 'milestone-window-old', + vault_id: 'vault-window-old', + title: 'Old Deleted Vault Milestone', + description: 'Should be removed', + target_amount: '1000.0000000', + current_amount: '0', + deadline: new Date('2024-05-01T00:00:00Z'), + status: 'pending', + created_at: new Date(), + updated_at: new Date(), + }, + { + id: 'milestone-window-recent', + vault_id: 'vault-window-recent', + title: 'Recent Deleted Vault Milestone', + description: 'Should remain', + target_amount: '2000.0000000', + current_amount: '0', + deadline: new Date('2024-06-01T00:00:00Z'), + status: 'pending', + created_at: new Date(), + updated_at: new Date(), + }, + ]) + + const result = await purgeSoftDeletedVaults('org-window', 10, db) + + expect(result).toEqual({ deletedVaults: 1, deletedMilestones: 1 }) + + const remainingVaults = await db('vaults') + .where({ organization_id: 'org-window' }) + .whereNotNull('deleted_at') + .select('id') + + expect(remainingVaults).toHaveLength(1) + expect(remainingVaults[0].id).toBe('vault-window-recent') + }) + + it('uses per-org retention_purge_age_ms from metadata when env var is unset', async () => { + delete process.env.RETENTION_PURGE_AGE_MS + + const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000) + const sixHoursAgo = new Date(Date.now() - 6 * 60 * 60 * 1000) + + await db('organizations').insert({ + id: 'org-per-org-window', + name: 'Org Per-Org', + slug: 'org-per-org', + metadata: { retention_purge_age_ms: 24 * 60 * 60 * 1000 }, + }) + + await db('vaults').insert([ + { + id: 'vault-per-org-old', + creator: 'GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXEEEEEEEE', + amount: '1000.0000000', + start_timestamp: new Date('2024-01-01T00:00:00Z'), + end_timestamp: new Date('2024-06-01T00:00:00Z'), + success_destination: 'GSUCCESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXEEEEEEEE', + failure_destination: 'GFAILUREXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXEEEEEEEE', + status: 'cancelled', + deleted_at: twoDaysAgo, + created_at: new Date(), + organization_id: 'org-per-org-window', + }, + { + id: 'vault-per-org-recent', + creator: 'GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXFFFFFFFF', + amount: '2000.0000000', + start_timestamp: new Date('2024-02-01T00:00:00Z'), + end_timestamp: new Date('2024-07-01T00:00:00Z'), + success_destination: 'GSUCCESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXFFFFFFFF', + failure_destination: 'GFAILUREXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXFFFFFFFF', + status: 'cancelled', + deleted_at: sixHoursAgo, + created_at: new Date(), + organization_id: 'org-per-org-window', + }, + ]) + + await db('milestones').insert([ + { + id: 'milestone-per-org-old', + vault_id: 'vault-per-org-old', + title: 'Old Milestone', + description: 'Should be removed', + target_amount: '1000.0000000', + current_amount: '0', + deadline: new Date('2024-05-01T00:00:00Z'), + status: 'pending', + created_at: new Date(), + updated_at: new Date(), + }, + { + id: 'milestone-per-org-recent', + vault_id: 'vault-per-org-recent', + title: 'Recent Milestone', + description: 'Should remain', + target_amount: '2000.0000000', + current_amount: '0', + deadline: new Date('2024-06-01T00:00:00Z'), + status: 'pending', + created_at: new Date(), + updated_at: new Date(), + }, + ]) + + const result = await purgeSoftDeletedVaults('org-per-org-window', 10, db) + + expect(result).toEqual({ deletedVaults: 1, deletedMilestones: 1 }) + + const remaining = await db('vaults') + .where({ organization_id: 'org-per-org-window' }) + .whereNotNull('deleted_at') + .select('id') + + expect(remaining).toHaveLength(1) + expect(remaining[0].id).toBe('vault-per-org-recent') + }) + + it('env var RETENTION_PURGE_AGE_MS takes precedence over per-org metadata', async () => { + process.env.RETENTION_PURGE_AGE_MS = String(2 * 60 * 60 * 1000) // 2 hours + + const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000) + const oneHourAgo = new Date(Date.now() - 1 * 60 * 60 * 1000) + + await db('organizations').insert({ + id: 'org-precedence', + name: 'Org Precedence', + slug: 'org-precedence', + metadata: { retention_purge_age_ms: 7 * 24 * 60 * 60 * 1000 }, + }) + + await db('vaults').insert([ + { + id: 'vault-precedence-old', + creator: 'GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXGGGGGGGG', + amount: '1000.0000000', + start_timestamp: new Date('2024-01-01T00:00:00Z'), + end_timestamp: new Date('2024-06-01T00:00:00Z'), + success_destination: 'GSUCCESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXGGGGGGGG', + failure_destination: 'GFAILUREXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXGGGGGGGG', + status: 'cancelled', + deleted_at: twoDaysAgo, + created_at: new Date(), + organization_id: 'org-precedence', + }, + { + id: 'vault-precedence-recent', + creator: 'GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXHHHHHHHH', + amount: '2000.0000000', + start_timestamp: new Date('2024-02-01T00:00:00Z'), + end_timestamp: new Date('2024-07-01T00:00:00Z'), + success_destination: 'GSUCCESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXHHHHHHHH', + failure_destination: 'GFAILUREXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXHHHHHHHH', + status: 'cancelled', + deleted_at: oneHourAgo, + created_at: new Date(), + organization_id: 'org-precedence', + }, + ]) + + await db('milestones').insert([ + { + id: 'milestone-precedence-old', + vault_id: 'vault-precedence-old', + title: 'Old Milestone', + description: 'Should be removed', + target_amount: '1000.0000000', + current_amount: '0', + deadline: new Date('2024-05-01T00:00:00Z'), + status: 'pending', + created_at: new Date(), + updated_at: new Date(), + }, + { + id: 'milestone-precedence-recent', + vault_id: 'vault-precedence-recent', + title: 'Recent Milestone', + description: 'Should remain', + target_amount: '2000.0000000', + current_amount: '0', + deadline: new Date('2024-06-01T00:00:00Z'), + status: 'pending', + created_at: new Date(), + updated_at: new Date(), + }, + ]) + + const result = await purgeSoftDeletedVaults('org-precedence', 10, db) + + expect(result).toEqual({ deletedVaults: 1, deletedMilestones: 1 }) + + const remaining = await db('vaults') + .where({ organization_id: 'org-precedence' }) + .whereNotNull('deleted_at') + .select('id') + + expect(remaining).toHaveLength(1) + expect(remaining[0].id).toBe('vault-precedence-recent') + }) + + it('applies default 30-day retention window when no config is provided', async () => { + delete process.env.RETENTION_PURGE_AGE_MS + + await db('organizations').insert({ + id: 'org-default-window', + name: 'Org Default', + slug: 'org-default', + metadata: null, + }) + + await db('vaults').insert([ + { + id: 'vault-default-old', + creator: 'GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXIIIIIIII', + amount: '1000.0000000', + start_timestamp: new Date('2024-01-01T00:00:00Z'), + end_timestamp: new Date('2024-06-01T00:00:00Z'), + success_destination: 'GSUCCESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXIIIIIIII', + failure_destination: 'GFAILUREXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXIIIIIIII', + status: 'cancelled', + deleted_at: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000), + created_at: new Date(), + organization_id: 'org-default-window', + }, + { + id: 'vault-default-recent', + creator: 'GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXJJJJJJJJ', + amount: '2000.0000000', + start_timestamp: new Date('2024-02-01T00:00:00Z'), + end_timestamp: new Date('2024-07-01T00:00:00Z'), + success_destination: 'GSUCCESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXJJJJJJJJ', + failure_destination: 'GFAILUREXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXJJJJJJJJ', + status: 'cancelled', + deleted_at: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), + created_at: new Date(), + organization_id: 'org-default-window', + }, + ]) + + await db('milestones').insert([ + { + id: 'milestone-default-old', + vault_id: 'vault-default-old', + title: 'Old Milestone', + description: 'Should be removed', + target_amount: '1000.0000000', + current_amount: '0', + deadline: new Date('2024-05-01T00:00:00Z'), + status: 'pending', + created_at: new Date(), + updated_at: new Date(), + }, + ]) + + const result = await purgeSoftDeletedVaults('org-default-window', 10, db) + + expect(result).toEqual({ deletedVaults: 1, deletedMilestones: 1 }) + + const remaining = await db('vaults') + .where({ organization_id: 'org-default-window' }) + .whereNotNull('deleted_at') + .select('id') + + expect(remaining).toHaveLength(1) + expect(remaining[0].id).toBe('vault-default-recent') + }) + + it('throws when RETENTION_PURGE_AGE_MS is not a valid non-negative integer', async () => { + process.env.RETENTION_PURGE_AGE_MS = 'invalid' + + await db('organizations').insert({ id: 'org-invalid', name: 'Org Invalid', slug: 'org-invalid' }) + + await expect(purgeSoftDeletedVaults('org-invalid', 10, db)).rejects.toThrow( + 'RETENTION_PURGE_AGE_MS must be a non-negative integer', + ) + }) +})