Skip to content

feat: Implement event-sourced campaign status derivation with new sta… - #476

Open
oyinade247 wants to merge 1 commit into
ritik4ever:mainfrom
oyinade247:Implement-event-sourced-campaign-status-derivation
Open

feat: Implement event-sourced campaign status derivation with new sta…#476
oyinade247 wants to merge 1 commit into
ritik4ever:mainfrom
oyinade247:Implement-event-sourced-campaign-status-derivation

Conversation

@oyinade247

@oyinade247 oyinade247 commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Closes #212

✅ Event-sourced campaign status is now implemented

The backend now derives campaign status by replaying explicit lifecycle transition events instead of relying only on the current clock and stored totals.

What changed

  • Added explicit transition event types for opened, funded, claimed, failed, and canceled in eventHistory.ts.
  • Introduced replay-based status derivation with a lightweight in-memory cache that is invalidated whenever a new event is written in eventHistory.ts.
  • Wired the status flow into campaign lifecycle operations in campaignStore.ts so the existing progress payload shape stays the same while the status comes from the event log.
  • Added regression coverage for replayed transitions in campaignStore.test.ts, api.test.ts, and integration.test.ts.

Verification

  • Static editor diagnostics report no TypeScript errors in the touched files.
  • I did not run the Vitest suite because terminal execution was skipped for this session.

Made changes.

Summary by CodeRabbit

  • New Features

    • Campaign status handling now includes canceled as a valid state.
    • Campaigns now reflect status changes more accurately across their lifecycle, including funded, failed, and canceled outcomes.
  • Bug Fixes

    • Improved campaign status display in campaign details and listings so the latest lifecycle state is shown consistently.
    • Test coverage was expanded to verify status updates through the full campaign event history.

@vercel

vercel Bot commented Jun 25, 2026

Copy link
Copy Markdown

@oyinade247 is attempting to deploy a commit to the ritik4ever's projects Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Campaign status now comes from replaying lifecycle events in event history, with cached derivation and cache invalidation on new writes. The store records updated lifecycle events for creation, funding, claim, and cancellation, and tests assert the replayed status flow.

Changes

Event-sourced campaign status

Layer / File(s) Summary
Lifecycle status contract and cache
backend/src/services/eventHistory.ts, backend/src/index.ts, backend/src/services/campaignStore.ts
CampaignLifecycleStatus and lifecycle event types are defined, getDerivedCampaignStatus caches replayed statuses, recordEvent invalidates the cache, canceled is accepted by status parsing, and CampaignStatus now aliases the shared lifecycle type.
Read-path status derivation
backend/src/services/campaignStore.ts
Campaign reads derive status from event history, update failed transitions through a helper, and compute canClaim, canRefund, and canPledge from the derived lifecycle status.
Lifecycle event writes
backend/src/services/campaignStore.ts
Campaign creation, pledge funding, on-chain reconciliation, claim reconciliation, and soft delete paths now record campaign_opened, campaign_funded, campaign_claimed, and campaign_canceled events with updated metadata.
Status replay tests
backend/src/api.test.ts, backend/src/services/campaignStore.test.ts, backend/tests/integration.test.ts
Test setup imports the DB reset helper, and new store/integration tests replay lifecycle events through recordEvent and assert the derived campaign status.

Estimated review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • ritik4ever

Poem

🐰 I hop through events, one by one,
Open, funded, claimed—then done.
Cache goes poof, the trail stays bright,
Canceled at last in the moonlit night.
Ears up! The status now tells the tale.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 53.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main change: event-sourced campaign status derivation.
Linked Issues check ✅ Passed Implements #212 with lifecycle events, replay-based cached status derivation, cache invalidation, unchanged API shape, and regression coverage.
Out of Scope Changes check ✅ Passed Changes stay focused on event-sourced campaign status derivation and related tests, with no obvious unrelated additions.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@drips-wave

drips-wave Bot commented Jun 25, 2026

Copy link
Copy Markdown

@oyinade247 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with 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.

Inline comments:
In `@backend/src/index.ts`:
- 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.

In `@backend/src/services/campaignStore.ts`:
- 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.
- Around line 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.

In `@backend/src/services/eventHistory.ts`:
- Around line 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.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 194f3419-c377-4321-a263-662b183d117e

📥 Commits

Reviewing files that changed from the base of the PR and between 922519c and f11dfbd.

📒 Files selected for processing (6)
  • backend/src/api.test.ts
  • backend/src/index.ts
  • backend/src/services/campaignStore.test.ts
  • backend/src/services/campaignStore.ts
  • backend/src/services/eventHistory.ts
  • backend/tests/integration.test.ts

Comment thread backend/src/index.ts
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.

} 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.

Comment on lines +154 to +175
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,
);

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.

Comment on lines +5 to +15
export type CampaignEventType =
| 'created'
| 'pledged'
| 'claimed'
| 'refunded'
| 'updated'
| 'campaign_opened'
| 'campaign_funded'
| 'campaign_claimed'
| 'campaign_failed'
| 'campaign_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 | 🔴 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement event-sourced campaign status derivation

1 participant