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
2 changes: 1 addition & 1 deletion backend/src/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import path from 'path';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { app } from './index';
import { initCampaignStore } from './services/campaignStore';
import { getDb } from './services/db';
import { getDb, resetDbForTests } from './services/db';

// Mock sorobanRpc to avoid real network calls during tests
vi.mock('./services/sorobanRpc', () => ({
Expand Down
2 changes: 1 addition & 1 deletion backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
reconcileOnChainPledge,
refundContributor,
SortOrder,
updateCampaign,

Check failure on line 37 in backend/src/index.ts

View workflow job for this annotation

GitHub Actions / Backend lint and tests

'updateCampaign' is defined but never used

Check failure on line 37 in backend/src/index.ts

View workflow job for this annotation

GitHub Actions / Backend Build

'updateCampaign' is defined but never used
} from './services/campaignStore';
import { checkDbHealth } from './services/db';
import { listCampaignHistory } from './services/eventHistory';
Expand All @@ -60,7 +60,7 @@

type CampaignListItem = CampaignRecord & { progress: CampaignProgress };

const CAMPAIGN_STATUSES: CampaignStatus[] = ['open', 'funded', 'claimed', 'failed'];
const CAMPAIGN_STATUSES: CampaignStatus[] = ['open', 'funded', 'claimed', 'failed', 'canceled'];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Wire canceled through the list filter before accepting it.

normalizeStatusFilter now accepts canceled, but listCampaigns has no case 'canceled', so ?status=canceled falls through without a status predicate and can return the wrong campaigns.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/index.ts` at line 63, The status filter in listCampaigns is
missing support for canceled, so requests with ?status=canceled can fall through
without applying the intended predicate. Update normalizeStatusFilter and the
listCampaigns status switch together so canceled is handled explicitly, using
the existing CampaignStatus/CAMPAIGN_STATUSES flow and the same filtering logic
as the other statuses.

const CONTRACT_AMOUNT_DECIMALS = Number(process.env.CONTRACT_AMOUNT_DECIMALS ?? 2);
const RATE_LIMIT_WINDOW_MS = 60_000;
const RATE_LIMIT_MAX_REQUESTS = 120;
Expand Down Expand Up @@ -596,7 +596,7 @@
}
});

app.use((err: any, req: Request, res: Response, _next: express.NextFunction) => {

Check failure on line 599 in backend/src/index.ts

View workflow job for this annotation

GitHub Actions / Backend lint and tests

'_next' is defined but never used

Check failure on line 599 in backend/src/index.ts

View workflow job for this annotation

GitHub Actions / Backend lint and tests

Unexpected any. Specify a different type

Check failure on line 599 in backend/src/index.ts

View workflow job for this annotation

GitHub Actions / Backend Build

'_next' is defined but never used

Check failure on line 599 in backend/src/index.ts

View workflow job for this annotation

GitHub Actions / Backend Build

Unexpected any. Specify a different type
if (err.type === 'entity.too.large') {
return res.status(413).json({
success: false,
Expand Down
31 changes: 30 additions & 1 deletion backend/src/services/campaignStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ let getPledges: CampaignStoreModule['getPledges'];
let getGlobalStats: CampaignStoreModule['getGlobalStats'];
let getDb: DbModule['getDb'];
let getCampaignHistory: EventHistoryModule['getCampaignHistory'];
let recordEvent: EventHistoryModule['recordEvent'];
let addPledge: CampaignStoreModule['addPledge'];
let getCampaignWithProgress: CampaignStoreModule['getCampaignWithProgress'];

const CREATOR = `G${'A'.repeat(55)}`;
const CONTRIBUTOR = `G${'B'.repeat(55)}`;
Expand All @@ -36,6 +38,7 @@ beforeAll(async () => {
({
createCampaign,

getCampaignWithProgress,
initCampaignStore,
listCampaigns,
listCampaignPledges,
Expand All @@ -46,7 +49,7 @@ beforeAll(async () => {
addPledge,
} = await import('./campaignStore'));
({ getDb } = await import('./db'));
({ getCampaignHistory } = await import('./eventHistory'));
({ getCampaignHistory, recordEvent } = await import('./eventHistory'));
initCampaignStore();
});

Expand Down Expand Up @@ -166,6 +169,32 @@ describe('on-chain pledge reconciliation', () => {
});
});

describe('event-sourced campaign status', () => {
it('replays lifecycle transition events to derive the final status', () => {
const campaign = createCampaign({
creator: CREATOR,
title: 'Lifecycle replay campaign',
description: 'A campaign used to verify status derivation from event history.',
assetCode: 'USDC',
targetAmount: 250,
deadline: Math.floor(Date.now() / 1000) + 86400,
});

const openedAt = campaign.createdAt + 10;
recordEvent(campaign.id, 'campaign_opened', openedAt, campaign.creator);
expect(getCampaign(campaign.id)?.createdAt).toBe(campaign.createdAt);

recordEvent(campaign.id, 'campaign_funded', openedAt + 30, campaign.creator, 250);
expect(getCampaignWithProgress(campaign.id)?.progress.status).toBe('funded');

recordEvent(campaign.id, 'campaign_failed', openedAt + 60, campaign.creator, 0);
expect(getCampaignWithProgress(campaign.id)?.progress.status).toBe('failed');

recordEvent(campaign.id, 'campaign_canceled', openedAt + 90, campaign.creator, 0);
expect(getCampaignWithProgress(campaign.id)?.progress.status).toBe('canceled');
});
});

describe('campaign pledge pagination', () => {
it('returns pledges in reverse chronological order with pagination metadata inputs', () => {
const futureDeadline = Math.floor(Date.now() / 1000) + 86400;
Expand Down
149 changes: 128 additions & 21 deletions backend/src/services/campaignStore.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { getDb, initDb } from './db';
import { getCampaignHistory, recordEvent, BlockchainMetadata } from './eventHistory';
import {
getCampaignHistory,
recordEvent,
BlockchainMetadata,
getDerivedCampaignStatus,
type CampaignLifecycleStatus,
} from './eventHistory';

export type CampaignStatus = 'open' | 'funded' | 'claimed' | 'failed';
export type CampaignStatus = CampaignLifecycleStatus;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Handle the new canceled status in listCampaigns.

CampaignStatus now includes canceled, but the status filter switch only handles claimed, funded, failed, and open. Add a canceled branch and avoid pairing it with the default deleted_at IS NULL filter.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/services/campaignStore.ts` at line 10, The listCampaigns status
filter in CampaignStore is missing support for the new canceled CampaignStatus.
Update the status switch to handle the canceled branch explicitly alongside
claimed, funded, failed, and open, and ensure it does not fall through to the
default deleted_at IS NULL behavior used for active campaigns. Use the
CampaignStatus and listCampaigns symbols to locate the switch and add the
canceled-specific filtering logic.


export interface CampaignInput {
creator: string;
Expand Down Expand Up @@ -132,6 +138,48 @@
return Number(value.toFixed(2));
}

function deriveLegacyCampaignStatus(campaign: CampaignRecord, at: number): CampaignStatus {
if (campaign.claimedAt !== undefined) {
return 'claimed';
}
if (campaign.pledgedAmount >= campaign.targetAmount) {
return 'funded';
}
if (at >= campaign.deadline) {
return 'failed';
}
return 'open';
}

function transitionCampaignToFailed(campaign: CampaignRecord, at: number): boolean {
if (
campaign.claimedAt === undefined &&
campaign.pledgedAmount < campaign.targetAmount &&
at >= campaign.deadline &&
campaign.failedAt === undefined
) {
const db = getDb();
db.prepare(`UPDATE campaigns SET failed_at = ? WHERE id = ? AND failed_at IS NULL`).run(
campaign.deadline,
campaign.id,
);

recordEvent(
campaign.id,
'campaign_failed',
campaign.deadline,
campaign.creator,
campaign.pledgedAmount,
{ targetAmount: campaign.targetAmount, deadline: campaign.deadline },
{ source: 'local' } as BlockchainMetadata,
);
Comment on lines +154 to +175

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Prevent canceled campaigns and failed no-ops from writing campaign_failed.

A campaign canceled before its deadline still satisfies this condition after the deadline. Recording campaign_failed at campaign.deadline makes replay overwrite the earlier campaign_canceled. Also, the conditional update result is ignored, so stale records can append duplicate failed events.

Proposed fix
 function transitionCampaignToFailed(campaign: CampaignRecord, at: number): boolean {
   if (
     campaign.claimedAt === undefined &&
+    campaign.deletedAt === undefined &&
     campaign.pledgedAmount < campaign.targetAmount &&
     at >= campaign.deadline &&
     campaign.failedAt === undefined
   ) {
     const db = getDb();
-    db.prepare(`UPDATE campaigns SET failed_at = ? WHERE id = ? AND failed_at IS NULL`).run(
+    const changes = db.prepare(`UPDATE campaigns SET failed_at = ? WHERE id = ? AND failed_at IS NULL`).run(
       campaign.deadline,
       campaign.id,
     );
+    if (changes.changes === 0) {
+      return false;
+    }
 
     recordEvent(
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function transitionCampaignToFailed(campaign: CampaignRecord, at: number): boolean {
if (
campaign.claimedAt === undefined &&
campaign.pledgedAmount < campaign.targetAmount &&
at >= campaign.deadline &&
campaign.failedAt === undefined
) {
const db = getDb();
db.prepare(`UPDATE campaigns SET failed_at = ? WHERE id = ? AND failed_at IS NULL`).run(
campaign.deadline,
campaign.id,
);
recordEvent(
campaign.id,
'campaign_failed',
campaign.deadline,
campaign.creator,
campaign.pledgedAmount,
{ targetAmount: campaign.targetAmount, deadline: campaign.deadline },
{ source: 'local' } as BlockchainMetadata,
);
function transitionCampaignToFailed(campaign: CampaignRecord, at: number): boolean {
if (
campaign.claimedAt === undefined &&
campaign.deletedAt === undefined &&
campaign.pledgedAmount < campaign.targetAmount &&
at >= campaign.deadline &&
campaign.failedAt === undefined
) {
const db = getDb();
const changes = db.prepare(`UPDATE campaigns SET failed_at = ? WHERE id = ? AND failed_at IS NULL`).run(
campaign.deadline,
campaign.id,
);
if (changes.changes === 0) {
return false;
}
recordEvent(
campaign.id,
'campaign_failed',
campaign.deadline,
campaign.creator,
campaign.pledgedAmount,
{ targetAmount: campaign.targetAmount, deadline: campaign.deadline },
{ source: 'local' } as BlockchainMetadata,
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/services/campaignStore.ts` around lines 154 - 175, The
transitionCampaignToFailed logic is writing campaign_failed for campaigns that
were already canceled, and it can also emit duplicate events when the database
update is a no-op. Update transitionCampaignToFailed to first guard against
canceled campaigns and then use the result of db.prepare(...).run(...) to only
call recordEvent when the failed_at update actually changed a row; use the
existing CampaignRecord, getDb, and recordEvent flow to keep the replay order
stable.


return true;
}

return false;
}

function rowToCampaign(row: CampaignRow): CampaignRecord {
const acceptedTokens = JSON.parse(row.accepted_tokens_json);
return {
Expand Down Expand Up @@ -241,24 +289,23 @@
*/
export function calculateProgress(campaign: CampaignRecord, at = nowInSeconds()): CampaignProgress {
const deadlineReached = at >= campaign.deadline;
const fallbackStatus = deriveLegacyCampaignStatus(campaign, at);
if (fallbackStatus === 'failed') {
transitionCampaignToFailed(campaign, at);
}

const status = getDerivedCampaignStatus(campaign.id, fallbackStatus);
const canClaim =
status === 'funded' &&
campaign.claimedAt === undefined &&
deadlineReached &&
campaign.pledgedAmount >= campaign.targetAmount;
const canRefund =
status === 'failed' &&
campaign.claimedAt === undefined &&
deadlineReached &&
campaign.pledgedAmount < campaign.targetAmount;
const canPledge = campaign.claimedAt === undefined && !deadlineReached;

let status: CampaignStatus = 'open';
if (campaign.claimedAt !== undefined) {
status = 'claimed';
} else if (campaign.pledgedAmount >= campaign.targetAmount) {
status = 'funded';
} else if (deadlineReached) {
status = 'failed';
}
const canPledge = status === 'open' && campaign.claimedAt === undefined && !deadlineReached;

return {
status,
Expand Down Expand Up @@ -340,7 +387,7 @@
const offset = paginate ? (page - 1) * limit : 0;

const whereClauses: string[] = [];
const params: any[] = [];

Check failure on line 390 in backend/src/services/campaignStore.ts

View workflow job for this annotation

GitHub Actions / Backend lint and tests

Unexpected any. Specify a different type

Check failure on line 390 in backend/src/services/campaignStore.ts

View workflow job for this annotation

GitHub Actions / Backend Build

Unexpected any. Specify a different type

if (options?.searchQuery && options.searchQuery.trim()) {
const searchTerm = `%${options.searchQuery.trim().toLowerCase()}%`;
Expand Down Expand Up @@ -422,15 +469,16 @@
const pledgeCounts: Record<string, number> = {};
const campaigns = rows.map((row) => {
pledgeCounts[row.id] = row.pledge_count;
const { pledge_count, ...campaignRow } = row;

Check failure on line 472 in backend/src/services/campaignStore.ts

View workflow job for this annotation

GitHub Actions / Backend lint and tests

'pledge_count' is assigned a value but never used

Check failure on line 472 in backend/src/services/campaignStore.ts

View workflow job for this annotation

GitHub Actions / Backend Build

'pledge_count' is assigned a value but never used

const now = Math.floor(Date.now() / 1000);
if (campaignRow.claimed_at === null && campaignRow.pledged_amount < campaignRow.target_amount && now >= campaignRow.deadline && campaignRow.failed_at === null) {
campaignRow.failed_at = campaignRow.deadline;
db.prepare(`UPDATE campaigns SET failed_at = ? WHERE id = ?`).run(campaignRow.deadline, campaignRow.id);
const campaign = rowToCampaign(campaignRow as CampaignRow);
if (transitionCampaignToFailed(campaign, now)) {
campaignRow.failed_at = campaign.deadline;
return rowToCampaign(campaignRow as CampaignRow);
}
return rowToCampaign(campaignRow as CampaignRow);

return campaign;
});

return {
Expand All @@ -454,11 +502,12 @@

if (row) {
const now = Math.floor(Date.now() / 1000);
if (row.claimed_at === null && row.pledged_amount < row.target_amount && now >= row.deadline && row.failed_at === null) {
row.failed_at = row.deadline;
db.prepare(`UPDATE campaigns SET failed_at = ? WHERE id = ?`).run(row.deadline, row.id);
const campaign = rowToCampaign(row);
if (transitionCampaignToFailed(campaign, now)) {
row.failed_at = campaign.deadline;
return rowToCampaign(row);
}
return rowToCampaign(row);
return campaign;
}
return undefined;
}
Expand Down Expand Up @@ -658,6 +707,15 @@
},
{ source: 'local' } as BlockchainMetadata,
);
recordEvent(
campaign.id,
'campaign_opened',
campaign.createdAt,
campaign.creator,
undefined,
{ targetAmount: campaign.targetAmount, deadline: campaign.deadline },
{ source: 'local' } as BlockchainMetadata,
);

return campaign;
}
Expand Down Expand Up @@ -732,6 +790,18 @@
{ source: 'local' } as BlockchainMetadata,
);

if (campaign.pledgedAmount < campaign.targetAmount && nextPledgedAmount >= campaign.targetAmount) {
recordEvent(
campaignId,
'campaign_funded',
createdAt,
input.contributor,
nextPledgedAmount,
{ targetAmount: campaign.targetAmount, assetCode },
{ source: 'local' } as BlockchainMetadata,
);
}

// Check if contributor has reached their limit and record event
if (
campaign.maxPerContributor !== undefined &&
Expand Down Expand Up @@ -850,6 +920,21 @@
txHash: input.transactionHash,
} as BlockchainMetadata,
);

if (campaign.pledgedAmount < campaign.targetAmount && nextPledgedAmount >= campaign.targetAmount) {
recordEvent(
campaignId,
'campaign_funded',
createdAt,
input.contributor,
nextPledgedAmount,
{ targetAmount: campaign.targetAmount, assetCode, onChain: true },
{
source: 'soroban',
txHash: input.transactionHash,
} as BlockchainMetadata,
);
}
});

reconcile();
Expand Down Expand Up @@ -947,6 +1032,18 @@
txHash: input.transactionHash,
} as BlockchainMetadata,
);
recordEvent(
campaignId,
'campaign_claimed',
claimedAt,
input.creator,
campaign.pledgedAmount,
{ targetAmount: campaign.targetAmount },
{
source: 'soroban',
txHash: input.transactionHash,
} as BlockchainMetadata,
);
});

commit();
Expand Down Expand Up @@ -993,6 +1090,16 @@
if (changes.changes === 0) {
throw toServiceError('Campaign not found or already deleted.', 404, 'NOT_FOUND');
}

recordEvent(
campaignId,
'campaign_canceled',
deletedAt,
campaign.creator,
undefined,
{ deletedAt },
{ source: 'local' } as BlockchainMetadata,
);
}

/**
Expand Down
52 changes: 51 additions & 1 deletion backend/src/services/eventHistory.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
import { getDb } from './db';

export type CampaignEventType = 'created' | 'pledged' | 'claimed' | 'refunded' | 'updated';
export type CampaignLifecycleStatus = 'open' | 'funded' | 'claimed' | 'failed' | 'canceled';

export type CampaignEventType =
| 'created'
| 'pledged'
| 'claimed'
| 'refunded'
| 'updated'
| 'campaign_opened'
| 'campaign_funded'
| 'campaign_claimed'
| 'campaign_failed'
| 'campaign_canceled';
Comment on lines +5 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Keep existing event literals in CampaignEventType.

recordEvent is still called with 'pledge_limit_reached' in backend/src/services/campaignStore.ts Line 816, but this union no longer accepts it. That will break TypeScript checking for the existing event write.

Proposed fix
 export type CampaignEventType =
   | 'created'
   | 'pledged'
   | 'claimed'
   | 'refunded'
   | 'updated'
+  | 'pledge_limit_reached'
   | 'campaign_opened'
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export type CampaignEventType =
| 'created'
| 'pledged'
| 'claimed'
| 'refunded'
| 'updated'
| 'campaign_opened'
| 'campaign_funded'
| 'campaign_claimed'
| 'campaign_failed'
| 'campaign_canceled';
export type CampaignEventType =
| 'created'
| 'pledged'
| 'claimed'
| 'refunded'
| 'updated'
| 'pledge_limit_reached'
| 'campaign_opened'
| 'campaign_funded'
| 'campaign_claimed'
| 'campaign_failed'
| 'campaign_canceled';
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/services/eventHistory.ts` around lines 5 - 15,
`CampaignEventType` is missing an event literal still used by `recordEvent`, so
update the union in `eventHistory` to keep the existing `'pledge_limit_reached'`
value accepted alongside the current campaign event types. Check the
`CampaignEventType` definition and ensure any callers like `recordEvent` in
`campaignStore` continue to type-check without changing existing event-writing
behavior.


export interface BlockchainMetadata {
txHash?: string;
Expand All @@ -22,6 +34,16 @@ export interface CampaignEvent {
blockchainMetadata?: BlockchainMetadata;
}

const STATUS_TRANSITION_EVENTS: Partial<Record<CampaignEventType, CampaignLifecycleStatus>> = {
campaign_opened: 'open',
campaign_funded: 'funded',
campaign_claimed: 'claimed',
campaign_failed: 'failed',
campaign_canceled: 'canceled',
};

const statusCache = new Map<string, CampaignLifecycleStatus>();

interface EventRow {
id: number;
campaign_id: string;
Expand Down Expand Up @@ -59,6 +81,32 @@ function rowToEvent(row: EventRow): CampaignEvent {
* @param metadata - Optional arbitrary key-value data about the event.
* @param blockchainMetadata - Optional on-chain context (tx hash, ledger info, source).
*/
export function invalidateCampaignStatusCache(campaignId: string): void {
statusCache.delete(campaignId);
}

export function getDerivedCampaignStatus(
campaignId: string,
fallbackStatus: CampaignLifecycleStatus,
): CampaignLifecycleStatus {
const cachedStatus = statusCache.get(campaignId);
if (cachedStatus !== undefined) {
return cachedStatus;
}

const history = getCampaignHistory(campaignId);
let currentStatus = fallbackStatus;
for (const event of history) {
const nextStatus = STATUS_TRANSITION_EVENTS[event.eventType];
if (nextStatus) {
currentStatus = nextStatus;
}
}

statusCache.set(campaignId, currentStatus);
return currentStatus;
}

export function recordEvent(
campaignId: string,
eventType: CampaignEventType,
Expand All @@ -83,6 +131,8 @@ export function recordEvent(
? JSON.stringify(blockchainMetadata)
: null,
});

invalidateCampaignStatusCache(campaignId);
}

export interface CampaignHistoryPage {
Expand Down
Loading
Loading