Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions backend/docs/PROJECT_ARCHIVAL.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions backend/src/config/scheduled-tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -246,6 +247,19 @@ const RAW_TASKS: (Omit<ScheduledTaskMeta, 'schedule'> & { 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',
Expand Down
5 changes: 5 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Expand Down
193 changes: 193 additions & 0 deletions backend/src/routes/project-archival.ts
Original file line number Diff line number Diff line change
@@ -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); }
},
);
Loading