From cafd52a4ff8033d27c7337f44d2e2b0a54d3ef08 Mon Sep 17 00:00:00 2001 From: Glittersup Date: Thu, 30 Jul 2026 12:05:41 +0100 Subject: [PATCH] feat: automated project archival with data retention --- backend/docs/PROJECT_ARCHIVAL.md | 86 +++ backend/src/config/scheduled-tasks.ts | 14 + backend/src/index.ts | 5 + backend/src/routes/project-archival.ts | 193 ++++++ .../src/services/project-archival/index.ts | 580 ++++++++++++++++++ backend/src/services/projects.ts | 20 + 6 files changed, 898 insertions(+) create mode 100644 backend/docs/PROJECT_ARCHIVAL.md create mode 100644 backend/src/routes/project-archival.ts create mode 100644 backend/src/services/project-archival/index.ts diff --git a/backend/docs/PROJECT_ARCHIVAL.md b/backend/docs/PROJECT_ARCHIVAL.md new file mode 100644 index 00000000..597eb6f9 --- /dev/null +++ b/backend/docs/PROJECT_ARCHIVAL.md @@ -0,0 +1,86 @@ +# Automated Project Archival & Data Retention + +Projects that go quiet are archived automatically, retained for a configurable +window, and then purged — with restoration available at any point before the +purge runs. + +Service: [`backend/src/services/project-archival/index.ts`](../src/services/project-archival/index.ts) +Routes: [`backend/src/routes/project-archival.ts`](../src/routes/project-archival.ts) — mounted at `/api/v1/project-archival` +Schedule: `project-archival-sweep` in [`backend/src/config/scheduled-tasks.ts`](../src/config/scheduled-tasks.ts) + +## Lifecycle + +``` +active/completed ──(inactive ≥ archiveAfterDays)──▶ archived ──(retention elapsed)──▶ purged + │ + └──(restore)──▶ previous status +``` + +1. **Sweep** — the daily job scans every project and resolves the retention + policy that applies to it. +2. **Archive** — projects whose status is listed in `eligibleStatuses` and whose + last activity (`endDate`, else `updatedAt`) is older than `archiveAfterDays` + are archived. An `ArchiveRecord` captures the previous status, milestone + count, budget, and the `purgeEligibleAt` timestamp. +3. **Warn** — an archive within 7 days of its purge date emits a `purge_due` + notification to the project owner and client. +4. **Purge** — once `purgeAfterDays` has elapsed the project, its milestones, + and its payment releases are deleted permanently. +5. **Restore** — restoring before the purge returns the project to its + pre-archive status and closes the archive record. + +## Retention policies + +Policies resolve most-specific-first: `owner` → `client` → `global`. The seeded +`default` policy is global and cannot be deleted. + +| Field | Meaning | +| --- | --- | +| `scope` / `scopeId` | `global`, or `client`/`owner` bound to an id | +| `archiveAfterDays` | Inactivity required before archival | +| `purgeAfterDays` | Retention window after archival (must be ≥ `archiveAfterDays`) | +| `eligibleStatuses` | Statuses eligible for archival (default `completed`, `abandoned`) | +| `enabled` | Disabled policies are skipped during resolution | +| `notify` | Whether the policy emits archival notifications | + +Defaults come from `PROJECT_ARCHIVE_AFTER_DAYS` (90) and +`PROJECT_PURGE_AFTER_DAYS` (365). + +## Scheduling + +The sweep runs daily at `04:00 UTC`. Override it without a code change: + +```bash +SCHEDULE_OVERRIDE_PROJECT_ARCHIVAL_SWEEP="0 */6 * * *" +``` + +## API + +| Method | Path | Purpose | +| --- | --- | --- | +| `GET` | `/policies` | List retention policies | +| `POST` | `/policies` | Create or update a policy (pass `id` to update) | +| `DELETE` | `/policies/:policyId` | Delete a non-default policy | +| `GET` | `/candidates` | Preview what the next sweep would archive | +| `POST` | `/run` | Run the sweep now; `{ "dryRun": true }` for a no-op preview | +| `POST` | `/archive` | Archive one project immediately, bypassing the window | +| `GET` | `/archives` | List archives (`clientId`, `ownerId`, `policyId`, `includeRestored`, `includePurged`) | +| `POST` | `/restore/:projectId` | Restore the project's latest active archive | +| `GET` | `/analytics` | Archival analytics | +| `GET` | `/notifications` | Notification feed (`limit`, `projectId`) | + +Reads require the `projects:read` permission, writes `projects:write`, and +policy deletion `projects:delete`. + +## Analytics + +`GET /analytics` returns archived/retained/restored/purged totals, the budget +value currently held in archives, pending candidate count, restoration rate, +average inactivity at archival, average time spent in retention, breakdowns by +reason, policy, and month, the next 20 upcoming purges, and `lastRunAt`. + +## Notifications + +Four notification types are emitted to the owner and client: `archived`, +`purge_due`, `purged`, and `restored`. The feed is capped at the 500 most recent +entries and each is mirrored to the application log. diff --git a/backend/src/config/scheduled-tasks.ts b/backend/src/config/scheduled-tasks.ts index cae5d9c3..4d6ba102 100644 --- a/backend/src/config/scheduled-tasks.ts +++ b/backend/src/config/scheduled-tasks.ts @@ -24,6 +24,7 @@ import { getArchivalService } from '../services/archival/index.js'; import { getBridgeMonitorService } from '../services/bridge-monitor/bridge-monitor.js'; import { runScheduledReconciliation } from '../services/payment-reconciliation/index.js'; import { runEscalationEvaluation } from '../jobs/escalation.job.js'; +import { runProjectArchivalSweep } from '../services/project-archival/index.js'; import { ethers } from 'ethers'; // --------------------------------------------------------------------------- @@ -246,6 +247,19 @@ const RAW_TASKS: (Omit & { defaultSchedule: strin } }, }, + { + id: 'project-archival-sweep', + name: 'Automated Project Archival', + description: + 'Archives projects that have exceeded their retention policy window, warns about upcoming purges, and purges archives past retention.', + defaultSchedule: '0 4 * * *', + timezone: 'UTC', + timeoutMs: 10 * 60 * 1000, + priority: 'normal', + handler: () => { + runProjectArchivalSweep(); + }, + }, { id: 'escalation-evaluation', name: 'Escalation SLA Evaluation', diff --git a/backend/src/index.ts b/backend/src/index.ts index de7975d4..462a59ae 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -82,6 +82,7 @@ import { paymentLinksRouter } from './routes/payment-links.js'; import { paymentStrategiesRouter } from './routes/payment-strategies.js'; import { taxRouter } from './routes/tax.js'; import { projectsRouter } from './routes/projects.js'; +import { projectArchivalRouter } from './routes/project-archival.js'; import { graphQLRouter, graphQLWsRouter } from './graphql/gateway.js'; import { fraudDetectionRouter } from './routes/fraud-detection.js'; import { bridgeRouter } from './routes/bridge.js'; @@ -332,6 +333,7 @@ apiV1Router.use('/emails', emailRouter); apiV1Router.use('/portfolio', portfolioRouter); apiV1Router.use('/backup', backupRouter); apiV1Router.use('/archival', archivalRouter); +apiV1Router.use('/project-archival', projectArchivalRouter); apiV1Router.use('/admin/contracts/upgrade', upgradeValidatorRouter); apiV1Router.use('/bridge/monitor', bridgeMonitorRouter); apiV1Router.use('/ip-allowlist', ipAllowlistRouter); @@ -432,6 +434,9 @@ app.use('/api/v1/exports', streamingExportRouter); // Project + milestone delivery approval workflow app.use('/api/v1/projects', projectsRouter); +// Automated project archival + data retention +app.use('/api/v1/project-archival', projectArchivalRouter); + // Payment categories — Issue #251 app.use('/api/v1/categories', categoriesRouter); diff --git a/backend/src/routes/project-archival.ts b/backend/src/routes/project-archival.ts new file mode 100644 index 00000000..43f311a1 --- /dev/null +++ b/backend/src/routes/project-archival.ts @@ -0,0 +1,193 @@ +/** + * project-archival.ts — automated project archival with data retention. + * + * Mounted at /api/v1/project-archival. Kept separate from the projects + * router so the collection paths do not collide with its "/:id" routes. + */ + +import { Router } from 'express'; +import { z } from 'zod'; +import { validate } from '../middleware/validate.js'; +import { requireEnhancedPermission } from '../middleware/permissions.js'; +import { projectArchivalService } from '../services/project-archival/index.js'; + +export const projectArchivalRouter = Router(); + +const projectStatus = z.enum(['active', 'completed', 'archived', 'disputed', 'abandoned']); + +const policySchema = z.object({ + id: z.string().min(1).optional(), + name: z.string().min(1), + scope: z.enum(['global', 'client', 'owner']).optional(), + scopeId: z.string().min(1).nullable().optional(), + archiveAfterDays: z.number().int().min(0), + purgeAfterDays: z.number().int().min(0), + eligibleStatuses: z.array(projectStatus).optional(), + enabled: z.boolean().optional(), + notify: z.boolean().optional(), +}); + +const runSchema = z.object({ + dryRun: z.boolean().optional(), +}); + +const restoreSchema = z.object({ + restoredBy: z.string().min(1).optional(), +}); + +const archiveSchema = z.object({ + projectId: z.string().min(1), + actor: z.string().min(1).optional(), +}); + +function actorOf(req: unknown): string | undefined { + return (req as { user?: { id?: string } }).user?.id; +} + +// ── Retention policies ────────────────────────────────────────────────────── + +projectArchivalRouter.get( + '/policies', + requireEnhancedPermission('projects', 'read'), + (_req, res, next) => { + try { + const policies = projectArchivalService.listPolicies(); + res.json({ policies, count: policies.length }); + } catch (err) { next(err); } + }, +); + +projectArchivalRouter.post( + '/policies', + requireEnhancedPermission('projects', 'write'), + validate(policySchema), + (req, res) => { + try { + res.status(201).json(projectArchivalService.configurePolicy(req.body)); + } catch (err) { + res.status(400).json({ error: (err as Error).message }); + } + }, +); + +projectArchivalRouter.delete( + '/policies/:policyId', + requireEnhancedPermission('projects', 'delete'), + (req, res, next) => { + try { + const deleted = projectArchivalService.deletePolicy(String(req.params.policyId)); + if (!deleted) { + res.status(400).json({ error: 'Policy not found or cannot be deleted' }); + return; + } + res.json({ deleted: true, policyId: req.params.policyId }); + } catch (err) { next(err); } + }, +); + +// ── Archival sweep ────────────────────────────────────────────────────────── + +projectArchivalRouter.get( + '/candidates', + requireEnhancedPermission('projects', 'read'), + (_req, res, next) => { + try { + const candidates = projectArchivalService.previewArchival(); + res.json({ candidates, count: candidates.length }); + } catch (err) { next(err); } + }, +); + +projectArchivalRouter.post( + '/run', + requireEnhancedPermission('projects', 'write'), + validate(runSchema), + (req, res, next) => { + try { + res.json(projectArchivalService.runArchival({ dryRun: Boolean(req.body?.dryRun) })); + } catch (err) { next(err); } + }, +); + +projectArchivalRouter.post( + '/archive', + requireEnhancedPermission('projects', 'write'), + validate(archiveSchema), + (req, res, next) => { + try { + const record = projectArchivalService.archiveNow( + req.body.projectId, + req.body.actor ?? actorOf(req), + ); + if (!record) { + res.status(404).json({ error: 'Project not found or already archived' }); + return; + } + res.status(201).json(record); + } catch (err) { next(err); } + }, +); + +// ── Archives, restoration, analytics, notifications ───────────────────────── + +projectArchivalRouter.get( + '/archives', + requireEnhancedPermission('projects', 'read'), + (req, res, next) => { + try { + const archives = projectArchivalService.listArchives({ + clientId: typeof req.query.clientId === 'string' ? req.query.clientId : undefined, + ownerId: typeof req.query.ownerId === 'string' ? req.query.ownerId : undefined, + policyId: typeof req.query.policyId === 'string' ? req.query.policyId : undefined, + includeRestored: req.query.includeRestored === 'true', + includePurged: req.query.includePurged === 'true', + }); + res.json({ archives, count: archives.length }); + } catch (err) { next(err); } + }, +); + +projectArchivalRouter.post( + '/restore/:projectId', + requireEnhancedPermission('projects', 'write'), + validate(restoreSchema), + (req, res, next) => { + try { + const restored = projectArchivalService.restoreProject( + String(req.params.projectId), + req.body?.restoredBy ?? actorOf(req), + ); + if (!restored) { + res.status(404).json({ error: 'No restorable archive found for this project' }); + return; + } + res.json(restored); + } catch (err) { next(err); } + }, +); + +projectArchivalRouter.get( + '/analytics', + requireEnhancedPermission('projects', 'read'), + (_req, res, next) => { + try { + res.json(projectArchivalService.getAnalytics()); + } catch (err) { next(err); } + }, +); + +projectArchivalRouter.get( + '/notifications', + requireEnhancedPermission('projects', 'read'), + (req, res, next) => { + try { + const limit = typeof req.query.limit === 'string' ? parseInt(req.query.limit, 10) : 50; + const projectId = typeof req.query.projectId === 'string' ? req.query.projectId : undefined; + const notifications = projectArchivalService.listNotifications( + Number.isFinite(limit) ? limit : 50, + projectId, + ); + res.json({ notifications, count: notifications.length }); + } catch (err) { next(err); } + }, +); diff --git a/backend/src/services/project-archival/index.ts b/backend/src/services/project-archival/index.ts new file mode 100644 index 00000000..2cb73e95 --- /dev/null +++ b/backend/src/services/project-archival/index.ts @@ -0,0 +1,580 @@ +/** + * project-archival — automated project archival with data retention. + * + * Provides retention-policy configuration, an automated archival sweep + * (archive → retain → purge), restoration of archived projects, archival + * analytics, and a notification feed consumed by the scheduler and routes. + */ + +import { randomUUID } from 'node:crypto'; +import { projectsService, type ProjectRecord, type ProjectStatus } from '../projects.js'; + +export type PolicyScope = 'global' | 'client' | 'owner'; + +export type ArchiveReason = 'inactivity' | 'completed' | 'abandoned' | 'manual'; + +export type RetentionPolicy = { + id: string; + name: string; + scope: PolicyScope; + /** clientId / ownerId the policy applies to; ignored for the global scope. */ + scopeId: string | null; + /** Days of inactivity after which an eligible project is archived. */ + archiveAfterDays: number; + /** Days an archived project is retained before it becomes purge-eligible. */ + purgeAfterDays: number; + /** Project statuses considered archivable by this policy. */ + eligibleStatuses: ProjectStatus[]; + enabled: boolean; + notify: boolean; + createdAt: string; + updatedAt: string; +}; + +export type ArchiveRecord = { + id: string; + projectId: string; + projectName: string; + clientId: string; + ownerId: string; + policyId: string; + reason: ArchiveReason; + previousStatus: ProjectStatus; + inactiveDays: number; + milestoneCount: number; + budget: number; + currency: string; + archivedAt: string; + /** Timestamp after which the archive may be purged. */ + purgeEligibleAt: string; + restoredAt: string | null; + restoredBy: string | null; + purgedAt: string | null; +}; + +export type ArchivalNotificationType = 'archived' | 'purge_due' | 'purged' | 'restored'; + +export type ArchivalNotification = { + id: string; + type: ArchivalNotificationType; + projectId: string; + projectName: string; + recipients: string[]; + message: string; + createdAt: string; +}; + +export type ArchivalRunResult = { + runAt: string; + dryRun: boolean; + scanned: number; + archived: ArchiveRecord[]; + purged: ArchiveRecord[]; + skipped: number; + notifications: ArchivalNotification[]; + durationMs: number; +}; + +export type ArchivalCandidate = { + projectId: string; + projectName: string; + policyId: string; + reason: ArchiveReason; + inactiveDays: number; + status: ProjectStatus; +}; + +export type ConfigurePolicyInput = { + id?: string; + name: string; + scope?: PolicyScope; + scopeId?: string | null; + archiveAfterDays: number; + purgeAfterDays: number; + eligibleStatuses?: ProjectStatus[]; + enabled?: boolean; + notify?: boolean; +}; + +const DAY_MS = 24 * 60 * 60 * 1000; +const DEFAULT_ELIGIBLE_STATUSES: ProjectStatus[] = ['completed', 'abandoned']; +const NOTIFICATION_LIMIT = 500; + +/** Notifications are emitted ahead of a purge once the window is this close. */ +const PURGE_WARNING_DAYS = 7; + +export class ProjectArchivalService { + private policies = new Map(); + private records = new Map(); + private notifications: ArchivalNotification[] = []; + private lastRun: ArchivalRunResult | null = null; + + constructor() { + this.seedDefaultPolicy(); + } + + private nowIso(now = new Date()): string { + return now.toISOString(); + } + + private seedDefaultPolicy(): void { + const now = this.nowIso(); + const policy: RetentionPolicy = { + id: 'default', + name: 'Default retention policy', + scope: 'global', + scopeId: null, + archiveAfterDays: Number(process.env.PROJECT_ARCHIVE_AFTER_DAYS ?? 90), + purgeAfterDays: Number(process.env.PROJECT_PURGE_AFTER_DAYS ?? 365), + eligibleStatuses: [...DEFAULT_ELIGIBLE_STATUSES], + enabled: true, + notify: true, + createdAt: now, + updatedAt: now, + }; + this.policies.set(policy.id, policy); + } + + // ── Retention policy configuration ──────────────────────────────────────── + + configurePolicy(input: ConfigurePolicyInput): RetentionPolicy { + if (input.archiveAfterDays < 0 || input.purgeAfterDays < 0) { + throw new Error('Retention windows must be non-negative'); + } + if (input.purgeAfterDays < input.archiveAfterDays) { + throw new Error('purgeAfterDays must be greater than or equal to archiveAfterDays'); + } + + const scope = input.scope ?? 'global'; + if (scope !== 'global' && !input.scopeId) { + throw new Error(`A scopeId is required for the "${scope}" scope`); + } + + const id = input.id ?? randomUUID(); + const existing = this.policies.get(id); + const now = this.nowIso(); + + const policy: RetentionPolicy = { + id, + name: input.name, + scope, + scopeId: scope === 'global' ? null : String(input.scopeId), + archiveAfterDays: input.archiveAfterDays, + purgeAfterDays: input.purgeAfterDays, + eligibleStatuses: input.eligibleStatuses?.length + ? [...input.eligibleStatuses] + : existing?.eligibleStatuses ?? [...DEFAULT_ELIGIBLE_STATUSES], + enabled: input.enabled ?? existing?.enabled ?? true, + notify: input.notify ?? existing?.notify ?? true, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }; + + this.policies.set(policy.id, policy); + return policy; + } + + listPolicies(): RetentionPolicy[] { + return [...this.policies.values()].sort((a, b) => a.name.localeCompare(b.name)); + } + + getPolicy(policyId: string): RetentionPolicy | undefined { + return this.policies.get(policyId); + } + + deletePolicy(policyId: string): boolean { + if (policyId === 'default') return false; + return this.policies.delete(policyId); + } + + /** Most specific enabled policy wins: owner → client → global. */ + resolvePolicy(project: ProjectRecord): RetentionPolicy | undefined { + const enabled = [...this.policies.values()].filter((policy) => policy.enabled); + return ( + enabled.find((p) => p.scope === 'owner' && p.scopeId === project.ownerId) ?? + enabled.find((p) => p.scope === 'client' && p.scopeId === project.clientId) ?? + enabled.find((p) => p.scope === 'global') + ); + } + + // ── Eligibility ─────────────────────────────────────────────────────────── + + private inactiveDays(project: ProjectRecord, now: Date): number { + const last = new Date(project.endDate ?? project.updatedAt).getTime(); + return Math.floor((now.getTime() - last) / DAY_MS); + } + + private reasonFor(status: ProjectStatus): ArchiveReason { + if (status === 'completed') return 'completed'; + if (status === 'abandoned') return 'abandoned'; + return 'inactivity'; + } + + /** Projects that the current policy set would archive on the next sweep. */ + previewArchival(now = new Date()): ArchivalCandidate[] { + const candidates: ArchivalCandidate[] = []; + + for (const project of projectsService.listProjects({ includeArchived: true })) { + if (project.status === 'archived') continue; + + const policy = this.resolvePolicy(project); + if (!policy || !policy.eligibleStatuses.includes(project.status)) continue; + + const inactiveDays = this.inactiveDays(project, now); + if (inactiveDays < policy.archiveAfterDays) continue; + + candidates.push({ + projectId: project.id, + projectName: project.name, + policyId: policy.id, + reason: this.reasonFor(project.status), + inactiveDays, + status: project.status, + }); + } + + return candidates; + } + + // ── Archival ────────────────────────────────────────────────────────────── + + /** + * Run the archival sweep: archive eligible projects, warn about upcoming + * purges, and purge archives whose retention window has elapsed. + */ + runArchival(options?: { dryRun?: boolean; now?: Date }): ArchivalRunResult { + const startedAt = Date.now(); + const now = options?.now ?? new Date(); + const dryRun = options?.dryRun ?? false; + + const projects = projectsService.listProjects({ includeArchived: true }); + const candidates = this.previewArchival(now); + const notifications: ArchivalNotification[] = []; + const archived: ArchiveRecord[] = []; + + for (const candidate of candidates) { + const project = projectsService.getProject(candidate.projectId); + if (!project) continue; + + const policy = this.policies.get(candidate.policyId); + if (!policy) continue; + + const record = this.buildRecord(project, policy, candidate.reason, candidate.inactiveDays, now); + archived.push(record); + + if (dryRun) continue; + + this.records.set(record.id, record); + projectsService.archiveProject(project.id); + + if (policy.notify) { + notifications.push( + this.emit('archived', project, [project.ownerId, project.clientId], + `Project "${project.name}" was archived after ${candidate.inactiveDays} day(s) of inactivity ` + + `under policy "${policy.name}". It is retained until ${record.purgeEligibleAt}.`), + ); + } + } + + const purged = dryRun ? [] : this.enforceRetention(now, notifications); + + const result: ArchivalRunResult = { + runAt: this.nowIso(now), + dryRun, + scanned: projects.length, + archived, + purged, + skipped: projects.length - archived.length, + notifications, + durationMs: Date.now() - startedAt, + }; + + if (!dryRun) this.lastRun = result; + return result; + } + + private buildRecord( + project: ProjectRecord, + policy: RetentionPolicy, + reason: ArchiveReason, + inactiveDays: number, + now: Date, + ): ArchiveRecord { + return { + id: randomUUID(), + projectId: project.id, + projectName: project.name, + clientId: project.clientId, + ownerId: project.ownerId, + policyId: policy.id, + reason, + previousStatus: project.status, + inactiveDays, + milestoneCount: projectsService.listMilestones(project.id).length, + budget: project.budget, + currency: project.currency, + archivedAt: this.nowIso(now), + purgeEligibleAt: new Date(now.getTime() + policy.purgeAfterDays * DAY_MS).toISOString(), + restoredAt: null, + restoredBy: null, + purgedAt: null, + }; + } + + /** Archive a single project on demand, bypassing the inactivity window. */ + archiveNow(projectId: string, actor?: string): ArchiveRecord | undefined { + const project = projectsService.getProject(projectId); + if (!project || project.status === 'archived') return undefined; + + const policy = this.resolvePolicy(project) ?? this.policies.get('default'); + if (!policy) return undefined; + + const now = new Date(); + const record = this.buildRecord(project, policy, 'manual', this.inactiveDays(project, now), now); + this.records.set(record.id, record); + projectsService.archiveProject(project.id); + + if (policy.notify) { + this.emit('archived', project, [project.ownerId, project.clientId], + `Project "${project.name}" was archived manually${actor ? ` by ${actor}` : ''}. ` + + `It is retained until ${record.purgeEligibleAt}.`); + } + + return record; + } + + /** Warn about imminent purges and purge archives past their retention window. */ + private enforceRetention(now: Date, notifications: ArchivalNotification[]): ArchiveRecord[] { + const purged: ArchiveRecord[] = []; + + for (const record of this.records.values()) { + if (record.purgedAt || record.restoredAt) continue; + + const dueIn = Math.ceil((new Date(record.purgeEligibleAt).getTime() - now.getTime()) / DAY_MS); + const policy = this.policies.get(record.policyId); + + if (dueIn > 0) { + if (dueIn <= PURGE_WARNING_DAYS && policy?.notify) { + notifications.push( + this.emitRaw('purge_due', record.projectId, record.projectName, [record.ownerId, record.clientId], + `Archived project "${record.projectName}" will be purged in ${dueIn} day(s). Restore it to retain the data.`), + ); + } + continue; + } + + record.purgedAt = this.nowIso(now); + projectsService.purgeProject(record.projectId); + purged.push(record); + + if (policy?.notify) { + notifications.push( + this.emitRaw('purged', record.projectId, record.projectName, [record.ownerId, record.clientId], + `Archived project "${record.projectName}" was purged after its ${policy.purgeAfterDays}-day retention window.`), + ); + } + } + + return purged; + } + + // ── Restoration ─────────────────────────────────────────────────────────── + + restoreProject(projectId: string, restoredBy?: string): { record: ArchiveRecord; project: ProjectRecord } | undefined { + const record = this.latestActiveRecord(projectId); + if (!record || record.purgedAt) return undefined; + + const project = projectsService.restoreProject(projectId, record.previousStatus); + if (!project) return undefined; + + record.restoredAt = this.nowIso(); + record.restoredBy = restoredBy ?? null; + + const policy = this.policies.get(record.policyId); + if (policy?.notify) { + this.emit('restored', project, [project.ownerId, project.clientId], + `Project "${project.name}" was restored to "${record.previousStatus}"${restoredBy ? ` by ${restoredBy}` : ''}.`); + } + + return { record, project }; + } + + private latestActiveRecord(projectId: string): ArchiveRecord | undefined { + return [...this.records.values()] + .filter((record) => record.projectId === projectId && !record.restoredAt && !record.purgedAt) + .sort((a, b) => b.archivedAt.localeCompare(a.archivedAt))[0]; + } + + listArchives(filters?: { + clientId?: string; + ownerId?: string; + policyId?: string; + includeRestored?: boolean; + includePurged?: boolean; + }): ArchiveRecord[] { + return [...this.records.values()] + .filter((record) => { + if (filters?.clientId && record.clientId !== filters.clientId) return false; + if (filters?.ownerId && record.ownerId !== filters.ownerId) return false; + if (filters?.policyId && record.policyId !== filters.policyId) return false; + if (!filters?.includeRestored && record.restoredAt) return false; + if (!filters?.includePurged && record.purgedAt) return false; + return true; + }) + .sort((a, b) => b.archivedAt.localeCompare(a.archivedAt)); + } + + getArchive(recordId: string): ArchiveRecord | undefined { + return this.records.get(recordId); + } + + // ── Analytics ───────────────────────────────────────────────────────────── + + getAnalytics(now = new Date()): { + totals: { + archived: number; + retained: number; + restored: number; + purged: number; + pendingCandidates: number; + archivedBudget: number; + }; + restorationRate: number; + averageInactiveDays: number; + averageRetentionDays: number; + byReason: Record; + byPolicy: Array<{ policyId: string; policyName: string; archived: number; purged: number }>; + byMonth: Array<{ month: string; archived: number }>; + upcomingPurges: Array<{ recordId: string; projectId: string; projectName: string; purgeEligibleAt: string; daysRemaining: number }>; + lastRunAt: string | null; + } { + const all = [...this.records.values()]; + const restored = all.filter((record) => record.restoredAt); + const purged = all.filter((record) => record.purgedAt); + const retained = all.filter((record) => !record.restoredAt && !record.purgedAt); + + const byReason: Record = {}; + for (const record of all) { + byReason[record.reason] = (byReason[record.reason] ?? 0) + 1; + } + + const byMonthMap = new Map(); + for (const record of all) { + const month = record.archivedAt.slice(0, 7); + byMonthMap.set(month, (byMonthMap.get(month) ?? 0) + 1); + } + + const byPolicy = this.listPolicies().map((policy) => ({ + policyId: policy.id, + policyName: policy.name, + archived: all.filter((record) => record.policyId === policy.id).length, + purged: purged.filter((record) => record.policyId === policy.id).length, + })); + + const retentionDays = retained.map((record) => + Math.max(0, Math.floor((now.getTime() - new Date(record.archivedAt).getTime()) / DAY_MS)), + ); + + const average = (values: number[]): number => + values.length === 0 ? 0 : Number((values.reduce((sum, value) => sum + value, 0) / values.length).toFixed(2)); + + const upcomingPurges = retained + .map((record) => ({ + recordId: record.id, + projectId: record.projectId, + projectName: record.projectName, + purgeEligibleAt: record.purgeEligibleAt, + daysRemaining: Math.ceil((new Date(record.purgeEligibleAt).getTime() - now.getTime()) / DAY_MS), + })) + .sort((a, b) => a.daysRemaining - b.daysRemaining) + .slice(0, 20); + + return { + totals: { + archived: all.length, + retained: retained.length, + restored: restored.length, + purged: purged.length, + pendingCandidates: this.previewArchival(now).length, + archivedBudget: Number(retained.reduce((sum, record) => sum + record.budget, 0).toFixed(2)), + }, + restorationRate: all.length === 0 ? 0 : Number((restored.length / all.length).toFixed(4)), + averageInactiveDays: average(all.map((record) => record.inactiveDays)), + averageRetentionDays: average(retentionDays), + byReason, + byPolicy, + byMonth: [...byMonthMap.entries()] + .map(([month, archived]) => ({ month, archived })) + .sort((a, b) => a.month.localeCompare(b.month)), + upcomingPurges, + lastRunAt: this.lastRun?.runAt ?? null, + }; + } + + getLastRun(): ArchivalRunResult | null { + return this.lastRun; + } + + // ── Notifications ───────────────────────────────────────────────────────── + + private emit( + type: ArchivalNotificationType, + project: ProjectRecord, + recipients: string[], + message: string, + ): ArchivalNotification { + return this.emitRaw(type, project.id, project.name, recipients, message); + } + + private emitRaw( + type: ArchivalNotificationType, + projectId: string, + projectName: string, + recipients: string[], + message: string, + ): ArchivalNotification { + const notification: ArchivalNotification = { + id: randomUUID(), + type, + projectId, + projectName, + recipients: [...new Set(recipients.filter(Boolean))], + message, + createdAt: this.nowIso(), + }; + + this.notifications.unshift(notification); + if (this.notifications.length > NOTIFICATION_LIMIT) { + this.notifications.length = NOTIFICATION_LIMIT; + } + + console.log(`[project-archival] ${type}: ${message}`); + return notification; + } + + listNotifications(limit = 50, projectId?: string): ArchivalNotification[] { + return this.notifications + .filter((notification) => !projectId || notification.projectId === projectId) + .slice(0, Math.max(1, limit)); + } + + resetForTests(): void { + this.policies.clear(); + this.records.clear(); + this.notifications = []; + this.lastRun = null; + this.seedDefaultPolicy(); + } +} + +export const projectArchivalService = new ProjectArchivalService(); + +/** Scheduler entrypoint — see backend/src/config/scheduled-tasks.ts. */ +export function runProjectArchivalSweep(): ArchivalRunResult { + const result = projectArchivalService.runArchival(); + console.log( + `[project-archival] Sweep complete: ${result.archived.length} archived, ` + + `${result.purged.length} purged, ${result.scanned} scanned in ${result.durationMs}ms`, + ); + return result; +} diff --git a/backend/src/services/projects.ts b/backend/src/services/projects.ts index ce625544..df9ba851 100644 --- a/backend/src/services/projects.ts +++ b/backend/src/services/projects.ts @@ -185,6 +185,26 @@ export class ProjectsService { return project; } + /** Reverse an archival, returning the project to its pre-archive status. */ + restoreProject(projectId: string, previousStatus: ProjectStatus = 'active'): ProjectRecord | undefined { + const project = this.projects.get(projectId); + if (!project) return undefined; + + project.status = previousStatus === 'archived' ? 'active' : previousStatus; + project.archivedAt = null; + project.updatedAt = this.nowIso(); + this.projects.set(projectId, project); + return project; + } + + /** Permanently drop a project and its milestones once retention has elapsed. */ + purgeProject(projectId: string): boolean { + const existed = this.projects.delete(projectId); + this.milestones.delete(projectId); + this.releases = this.releases.filter((release) => release.projectId !== projectId); + return existed; + } + markAbandoned(projectId: string): ProjectRecord | undefined { const project = this.projects.get(projectId); if (!project) return undefined;