Skip to content

Commit 6e98452

Browse files
authored
Merge pull request #759 from MorsH14/feature/campaign-archive-restore
[FEATURE] Add soft delete (archive) for campaigns
2 parents eb232b5 + fffdd77 commit 6e98452

7 files changed

Lines changed: 390 additions & 7 deletions

File tree

backend/src/api.test.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@ import fs from 'fs';
33
import { Server } from 'http';
44
import path from 'path';
55
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
6+
7+
// The write rate limit (default 20/min) is a module-level constant in `./index`,
8+
// evaluated at import time. This suite exercises many archive/restore/pledge
9+
// mutations across its describe blocks, so raise the limit before `./index`
10+
// is imported to avoid tripping 429s on unrelated test assertions.
11+
vi.hoisted(() => {
12+
process.env.RATE_LIMIT_WRITE_LIMIT = '1000';
13+
});
14+
615
import { app } from './index';
716
import { initCampaignStore } from './services/campaignStore';
817
import { getDb } from './services/db';
@@ -430,6 +439,144 @@ describe('Campaign maxPerContributor Field', () => {
430439
});
431440
});
432441

442+
describe('Campaign archive (soft delete) and restore', () => {
443+
async function get(apiPath: string) {
444+
const response = await fetch(`${baseUrl}${apiPath}`);
445+
const data = await response.json().catch(() => null);
446+
return { status: response.status, data };
447+
}
448+
449+
async function post(apiPath: string, body?: unknown) {
450+
const response = await fetch(`${baseUrl}${apiPath}`, {
451+
method: 'POST',
452+
headers: { 'Content-Type': 'application/json' },
453+
body: body !== undefined ? JSON.stringify(body) : undefined,
454+
});
455+
const data = await response.json().catch(() => null);
456+
return { status: response.status, data };
457+
}
458+
459+
async function del(apiPath: string) {
460+
const response = await fetch(`${baseUrl}${apiPath}`, { method: 'DELETE' });
461+
const data = await response.json().catch(() => null);
462+
return { status: response.status, data };
463+
}
464+
465+
async function createTestCampaign(title: string) {
466+
const now = Math.floor(Date.now() / 1000);
467+
const res = await post('/api/campaigns', {
468+
creator: CREATOR,
469+
title,
470+
description: 'A campaign used to exercise archive/restore behavior',
471+
acceptedTokens: ['USDC'],
472+
targetAmount: 100,
473+
deadline: now + 3600,
474+
});
475+
expect(res.status).toBe(201);
476+
return res.data.data.id as string;
477+
}
478+
479+
it('DELETE /api/campaigns/:id archives the campaign and sets deletedAt', async () => {
480+
const campaignId = await createTestCampaign('Archive me');
481+
482+
const deleteRes = await del(`/api/campaigns/${campaignId}`);
483+
expect(deleteRes.status).toBe(200);
484+
expect(deleteRes.data.data.deletedAt).toBeDefined();
485+
});
486+
487+
it('archived campaigns are excluded from the default GET /api/campaigns list', async () => {
488+
const campaignId = await createTestCampaign('Hidden after archive');
489+
await del(`/api/campaigns/${campaignId}`);
490+
491+
const listRes = await get('/api/campaigns?page=1&limit=50');
492+
expect(listRes.status).toBe(200);
493+
expect(listRes.data.data.some((c: { id: string }) => c.id === campaignId)).toBe(false);
494+
});
495+
496+
it('GET /api/campaigns?includeDeleted=true includes archived campaigns', async () => {
497+
const campaignId = await createTestCampaign('Visible with includeDeleted');
498+
await del(`/api/campaigns/${campaignId}`);
499+
500+
const listRes = await get('/api/campaigns?page=1&limit=50&includeDeleted=true');
501+
expect(listRes.status).toBe(200);
502+
expect(listRes.data.data.some((c: { id: string }) => c.id === campaignId)).toBe(true);
503+
});
504+
505+
it('GET /api/campaigns?include_archived=true is accepted as an alias for includeDeleted', async () => {
506+
const campaignId = await createTestCampaign('Visible with include_archived');
507+
await del(`/api/campaigns/${campaignId}`);
508+
509+
const listRes = await get('/api/campaigns?page=1&limit=50&include_archived=true');
510+
expect(listRes.status).toBe(200);
511+
expect(listRes.data.data.some((c: { id: string }) => c.id === campaignId)).toBe(true);
512+
});
513+
514+
it('DELETE on an already-archived campaign returns 409', async () => {
515+
const campaignId = await createTestCampaign('Double archive');
516+
await del(`/api/campaigns/${campaignId}`);
517+
518+
const secondDelete = await del(`/api/campaigns/${campaignId}`);
519+
expect(secondDelete.status).toBe(409);
520+
expect(secondDelete.data.error.code).toBe('ALREADY_DELETED');
521+
});
522+
523+
it('DELETE on a nonexistent campaign returns 404', async () => {
524+
const res = await del('/api/campaigns/999999');
525+
expect(res.status).toBe(404);
526+
expect(res.data.error.code).toBe('NOT_FOUND');
527+
});
528+
529+
it('POST /api/campaigns/:id/restore un-archives the campaign', async () => {
530+
const campaignId = await createTestCampaign('Restore me');
531+
await del(`/api/campaigns/${campaignId}`);
532+
533+
const restoreRes = await post(`/api/campaigns/${campaignId}/restore`);
534+
expect(restoreRes.status).toBe(200);
535+
expect(restoreRes.data.data.deletedAt).toBeUndefined();
536+
537+
const listRes = await get('/api/campaigns?page=1&limit=50');
538+
expect(listRes.data.data.some((c: { id: string }) => c.id === campaignId)).toBe(true);
539+
});
540+
541+
it('POST restore on a campaign that is not archived returns 409', async () => {
542+
const campaignId = await createTestCampaign('Never archived');
543+
const res = await post(`/api/campaigns/${campaignId}/restore`);
544+
expect(res.status).toBe(409);
545+
expect(res.data.error.code).toBe('NOT_ARCHIVED');
546+
});
547+
548+
it('POST restore on a nonexistent campaign returns 404', async () => {
549+
const res = await post('/api/campaigns/999999/restore');
550+
expect(res.status).toBe(404);
551+
expect(res.data.error.code).toBe('NOT_FOUND');
552+
});
553+
554+
it('preserves pledges and history through an archive + restore cycle', async () => {
555+
const campaignId = await createTestCampaign('Preserve pledges');
556+
const pledgeRes = await post(`/api/campaigns/${campaignId}/pledges`, {
557+
contributor: CONTRIBUTOR,
558+
amount: 25,
559+
assetCode: 'USDC',
560+
});
561+
expect(pledgeRes.status).toBe(201);
562+
563+
await del(`/api/campaigns/${campaignId}`);
564+
await post(`/api/campaigns/${campaignId}/restore`);
565+
566+
const detailRes = await get(`/api/campaigns/${campaignId}`);
567+
expect(detailRes.status).toBe(200);
568+
expect(detailRes.data.data.pledgedAmount).toBe(25);
569+
570+
const historyRes = await get(`/api/campaigns/${campaignId}/history`);
571+
expect(historyRes.status).toBe(200);
572+
const eventTypes = historyRes.data.data.map((e: { eventType: string }) => e.eventType);
573+
expect(eventTypes).toContain('created');
574+
expect(eventTypes).toContain('pledged');
575+
expect(eventTypes).toContain('archived');
576+
expect(eventTypes).toContain('restored');
577+
});
578+
});
579+
433580
describe('GET /api/stats', () => {
434581
it('returns aggregate metrics in the correct format', async () => {
435582
const res = await get('/api/stats');

backend/src/index.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ import {
3838
type ListCampaignsOptions,
3939
reconcileOnChainPledge,
4040
refundContributor,
41+
restoreCampaign,
42+
softDeleteCampaign,
4143
SortOrder,
4244
} from './services/campaignStore';
4345
import { checkDbHealth } from './services/db';
@@ -474,6 +476,36 @@ app.get('/api/campaigns/:id', (req: Request, res: Response) => {
474476
res.json({ data: campaign });
475477
});
476478

479+
app.delete(
480+
'/api/campaigns/:id',
481+
applyRateLimit(WRITE_RATE_LIMIT_MAX_REQUESTS),
482+
(req: Request, res: Response) => {
483+
const parsedId = parseCampaignId(req.params.id);
484+
if (!parsedId.ok) {
485+
sendValidationError(parsedId.issues);
486+
}
487+
488+
const campaign = softDeleteCampaign(parsedId.value);
489+
invalidateCampaignCache();
490+
res.json({ data: { ...campaign, progress: calculateProgress(campaign) } });
491+
},
492+
);
493+
494+
app.post(
495+
'/api/campaigns/:id/restore',
496+
applyRateLimit(WRITE_RATE_LIMIT_MAX_REQUESTS),
497+
(req: Request, res: Response) => {
498+
const parsedId = parseCampaignId(req.params.id);
499+
if (!parsedId.ok) {
500+
sendValidationError(parsedId.issues);
501+
}
502+
503+
const campaign = restoreCampaign(parsedId.value);
504+
invalidateCampaignCache();
505+
res.json({ data: { ...campaign, progress: calculateProgress(campaign) } });
506+
},
507+
);
508+
477509
app.get('/api/campaigns/:id/pledges', (req: Request, res: Response) => {
478510
const parsedId = parseCampaignId(req.params.id);
479511
if (!parsedId.ok) {

backend/src/openapi.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,8 @@ const campaignEventSchema = z
175175
'updated',
176176
'metadata_updated',
177177
'pledge_limit_reached',
178+
'archived',
179+
'restored',
178180
])
179181
.openapi({ example: 'pledged' }),
180182
timestamp: unixTimestampSchema,
@@ -490,7 +492,13 @@ registry.registerPath({
490492
status: z.enum(['open', 'funded', 'claimed', 'failed']).optional(),
491493
sort: z.enum(['createdAt', 'deadline', 'pledgedAmount', 'targetAmount']).optional(),
492494
order: z.enum(['asc', 'desc']).optional(),
493-
includeDeleted: z.enum(['true', 'false']).optional(),
495+
includeDeleted: z.enum(['true', 'false']).optional().openapi({
496+
description: "Include archived (soft-deleted) campaigns. Alias: 'include_archived'.",
497+
}),
498+
include_archived: z
499+
.enum(['true', 'false'])
500+
.optional()
501+
.openapi({ description: "Alias for 'includeDeleted'." }),
494502
createdAfter: z
495503
.string()
496504
.datetime()
@@ -548,6 +556,47 @@ registry.registerPath({
548556
},
549557
});
550558

559+
registry.registerPath({
560+
method: 'delete',
561+
path: '/api/campaigns/{id}',
562+
tags: ['Campaigns'],
563+
summary: 'Archive (soft-delete) a campaign',
564+
description:
565+
'Sets the archivedAt/deletedAt timestamp on a campaign. Archived campaigns are excluded ' +
566+
"from the default campaign list but their pledges and history are preserved. Use POST " +
567+
'/api/campaigns/{id}/restore to un-archive.',
568+
request: { params: z.object({ id: campaignIdParamSchema }) },
569+
responses: {
570+
200: {
571+
description: 'Campaign archived',
572+
content: { 'application/json': { schema: registeredSchemas.CampaignDetailResponse } },
573+
},
574+
400: validationErrorResponse,
575+
404: notFoundResponse,
576+
409: { description: 'Campaign is already archived' },
577+
429: { description: 'Rate limit exceeded' },
578+
},
579+
});
580+
581+
registry.registerPath({
582+
method: 'post',
583+
path: '/api/campaigns/{id}/restore',
584+
tags: ['Campaigns'],
585+
summary: 'Restore an archived campaign',
586+
description: 'Clears the archivedAt/deletedAt timestamp, making the campaign active again.',
587+
request: { params: z.object({ id: campaignIdParamSchema }) },
588+
responses: {
589+
200: {
590+
description: 'Campaign restored',
591+
content: { 'application/json': { schema: registeredSchemas.CampaignDetailResponse } },
592+
},
593+
400: validationErrorResponse,
594+
404: notFoundResponse,
595+
409: { description: 'Campaign is not archived' },
596+
429: { description: 'Rate limit exceeded' },
597+
},
598+
});
599+
551600
registry.registerPath({
552601
method: 'get',
553602
path: '/api/campaigns/{id}/pledges',

0 commit comments

Comments
 (0)