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
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
CREATE TABLE "WebhookSubscription" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"apiKeyId" TEXT NOT NULL,
"url" TEXT NOT NULL,
"secret" TEXT NOT NULL,
"events" TEXT[] NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,

CONSTRAINT "WebhookSubscription_pkey" PRIMARY KEY ("id")
);

CREATE TABLE "WebhookDeliveryAttempt" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"subscriptionId" TEXT NOT NULL,
"event" TEXT NOT NULL,
"attempt" INTEGER NOT NULL,
"status" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"responseStatus" INTEGER,
"responseBody" TEXT,
"errorMessage" TEXT,
"deliveredAt" TIMESTAMP(3),

CONSTRAINT "WebhookDeliveryAttempt_pkey" PRIMARY KEY ("id")
);

CREATE INDEX "WebhookSubscription_apiKeyId_idx" ON "WebhookSubscription"("apiKeyId");
CREATE INDEX "WebhookSubscription_isActive_idx" ON "WebhookSubscription"("isActive");
CREATE INDEX "WebhookDeliveryAttempt_subscriptionId_createdAt_idx" ON "WebhookDeliveryAttempt"("subscriptionId", "createdAt");
CREATE INDEX "WebhookDeliveryAttempt_event_status_idx" ON "WebhookDeliveryAttempt"("event", "status");

ALTER TABLE "WebhookSubscription" ADD CONSTRAINT "WebhookSubscription_apiKeyId_fkey"
FOREIGN KEY ("apiKeyId") REFERENCES "ApiKey"("id") ON DELETE CASCADE ON UPDATE CASCADE;

ALTER TABLE "WebhookDeliveryAttempt" ADD CONSTRAINT "WebhookDeliveryAttempt_subscriptionId_fkey"
FOREIGN KEY ("subscriptionId") REFERENCES "WebhookSubscription"("id") ON DELETE CASCADE ON UPDATE CASCADE;
42 changes: 41 additions & 1 deletion app/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,46 @@ model Claim {
@@index([deletedAt])
}

model WebhookSubscription {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

apiKeyId String
apiKey ApiKey @relation(fields: [apiKeyId], references: [id], onDelete: Cascade)

url String
secret String
events String[]
isActive Boolean @default(true)

deliveries WebhookDeliveryAttempt[]

@@index([apiKeyId])
@@index([isActive])
}

model WebhookDeliveryAttempt {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

subscriptionId String
subscription WebhookSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)

event String
attempt Int
status String
payload Json
responseStatus Int?
responseBody String?
errorMessage String?
deliveredAt DateTime?

@@index([subscriptionId, createdAt])
@@index([event, status])
}

model AuditLog {
id String @id @default(cuid())
actorId String
Expand Down Expand Up @@ -149,5 +189,5 @@ model ApiKey {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([ngoId])
webhookSubscriptions WebhookSubscription[]
}
4 changes: 2 additions & 2 deletions app/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { LoggingInterceptor } from './interceptors/logging.interceptor';
import { LoggerService } from './logger/logger.service';
import { AllExceptionsFilter } from './common/filters/http-exception.filter';
import { AnalyticsModule } from './analytics/analytics.module';
import { AidEscrowModule } from './onchain/aid-escrow.module';
import { WebhooksModule } from './webhooks/webhooks.module';

@Module({
imports: [
Expand Down Expand Up @@ -74,7 +74,7 @@ import { AidEscrowModule } from './onchain/aid-escrow.module';
NotificationsModule,
JobsModule,
AnalyticsModule,
AidEscrowModule,
WebhooksModule,
],

controllers: [AppController],
Expand Down
1 change: 1 addition & 0 deletions app/backend/src/campaigns/campaigns.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
description: 'Access denied - insufficient permissions for this operation.',
})
async create(@Body() dto: CreateCampaignDto, @Req() req: Request) {
const campaign = await this.campaigns.create(dto, req.user?.ngoId);

Check warning on line 57 in app/backend/src/campaigns/campaigns.controller.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe argument of type error typed assigned to a parameter of type `string | null | undefined`
return ApiResponseDto.ok(campaign, 'Campaigns created successfully');
}

Expand All @@ -74,8 +74,8 @@
@Req() req: Request,
) {
// Scope to ngoId for NGO role; admins/operators see all
const ngoId = req.user?.role === AppRole.ngo ? req.user.ngoId : undefined;

Check warning on line 77 in app/backend/src/campaigns/campaigns.controller.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe assignment of an error typed value
const campaigns = await this.campaigns.findAll(includeArchived, ngoId);

Check warning on line 78 in app/backend/src/campaigns/campaigns.controller.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe argument of type error typed assigned to a parameter of type `string | null | undefined`
return ApiResponseDto.ok(campaigns, 'Campaigns fetched successfully');
}

Expand Down Expand Up @@ -132,6 +132,7 @@
@ApiForbiddenResponse({
description: 'Access denied - insufficient permissions.',
})
@Patch(':id/archive')
async archive(@Param('id') id: string) {
const campaignData = await this.campaigns.archive(id);
const { campaign, alreadyArchived } = campaignData;
Expand Down
3 changes: 3 additions & 0 deletions app/backend/src/campaigns/campaigns.module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { Module } from '@nestjs/common';
import { CampaignsController } from './campaigns.controller';
import { CampaignsService } from './campaigns.service';
import { PrismaModule } from '../prisma/prisma.module';
import { WebhooksModule } from '../webhooks/webhooks.module';

@Module({
imports: [PrismaModule, WebhooksModule],
controllers: [CampaignsController],
providers: [CampaignsService],
})
Expand Down
40 changes: 40 additions & 0 deletions app/backend/src/campaigns/campaigns.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@ import { Campaign, CampaignStatus, Prisma } from '@prisma/client';
import { CampaignsService } from './campaigns.service';
import { PrismaService } from 'src/prisma/prisma.service';
import { DeepMockProxy, mockDeep } from 'jest-mock-extended';
import { WebhooksService } from '../webhooks/webhooks.service';

describe('CampaignsService', () => {
let service: CampaignsService;
let prismaMock: DeepMockProxy<PrismaService>;
const webhooksService = {
enqueueEvent: jest.fn().mockResolvedValue(1),
};

const now = new Date('2026-01-25T00:00:00.000Z');

Expand All @@ -32,6 +36,7 @@ describe('CampaignsService', () => {
providers: [
CampaignsService,
{ provide: PrismaService, useValue: prismaMock },
{ provide: WebhooksService, useValue: webhooksService },
],
}).compile();

Expand Down Expand Up @@ -156,4 +161,39 @@ describe('CampaignsService', () => {
expect(updateArgs?.data).toMatchObject({ deletedAt: expect.any(Date) });
expect(result.deletedAt).not.toBeNull();
});

it('update(): emits campaign.completed when status transitions to completed', async () => {
const existing: Campaign = {
id: 'c1',
name: 'A',
status: CampaignStatus.active,
budget: new Prisma.Decimal('10.00'),
metadata: null,
archivedAt: null,
createdAt: now,
updatedAt: now,
};
const updated: Campaign = {
...existing,
status: CampaignStatus.completed,
updatedAt: new Date('2026-01-26T00:00:00.000Z'),
};

prismaMock.campaign.findUnique.mockResolvedValue(existing);
prismaMock.campaign.update.mockResolvedValue(updated);

await service.update('c1', { status: CampaignStatus.completed });

expect(webhooksService.enqueueEvent).toHaveBeenCalledWith(
'campaign.completed',
expect.objectContaining({
event: 'campaign.completed',
campaign: expect.objectContaining({
id: 'c1',
status: CampaignStatus.completed,
}),
previousStatus: CampaignStatus.active,
}),
);
});
});
30 changes: 27 additions & 3 deletions app/backend/src/campaigns/campaigns.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@ import { CampaignStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CreateCampaignDto } from './dto/create-campaign.dto';
import { UpdateCampaignDto } from './dto/update-campaign.dto';
import { WebhooksService } from '../webhooks/webhooks.service';

@Injectable()
export class CampaignsService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly webhooksService: WebhooksService,
) {}

private sanitizeMetadata(
metadata?: Record<string, unknown>,
Expand Down Expand Up @@ -51,9 +55,9 @@ export class CampaignsService {
}

async update(id: string, dto: UpdateCampaignDto) {
await this.findOne(id);
const existing = await this.findOne(id);

return this.prisma.campaign.update({
const updated = await this.prisma.campaign.update({
where: { id },
data: {
name: dto.name,
Expand All @@ -65,6 +69,26 @@ export class CampaignsService {
: this.sanitizeMetadata(dto.metadata),
},
});

if (
updated.status === CampaignStatus.completed &&
existing.status !== CampaignStatus.completed
) {
await this.webhooksService.enqueueEvent('campaign.completed', {
event: 'campaign.completed',
occurredAt: updated.updatedAt.toISOString(),
campaign: {
id: updated.id,
name: updated.name,
status: updated.status,
budget: updated.budget.toString(),
archivedAt: updated.archivedAt?.toISOString() ?? null,
},
previousStatus: existing.status,
});
}

return updated;
}

async archive(id: string) {
Expand Down
2 changes: 2 additions & 0 deletions app/backend/src/claims/claims.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { OnchainModule } from '../onchain/onchain.module';
import { MetricsModule } from '../observability/metrics/metrics.module';
import { LoggerModule } from '../logger/logger.module';
import { AuditModule } from '../audit/audit.module';
import { WebhooksModule } from '../webhooks/webhooks.module';
import { EncryptionModule } from '../common/encryption/encryption.module';

@Module({
Expand All @@ -15,6 +16,7 @@ import { EncryptionModule } from '../common/encryption/encryption.module';
MetricsModule,
LoggerModule,
AuditModule,
WebhooksModule,
EncryptionModule,
],
controllers: [ClaimsController],
Expand Down
78 changes: 78 additions & 0 deletions app/backend/src/claims/claims.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { MetricsService } from '../observability/metrics/metrics.service';
import { AuditService } from '../audit/audit.service';
import { EncryptionService } from '../common/encryption/encryption.service';
import { ClaimStatus, Prisma } from '@prisma/client';
import { WebhooksService } from '../webhooks/webhooks.service';

describe('ClaimsService', () => {
let service: ClaimsService;
Expand Down Expand Up @@ -63,6 +64,10 @@ describe('ClaimsService', () => {
record: jest.fn().mockResolvedValue({ id: 'audit-1' }),
};

const mockWebhooksService = {
enqueueEvent: jest.fn().mockResolvedValue(1),
};

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
Expand Down Expand Up @@ -121,6 +126,10 @@ describe('ClaimsService', () => {
decryptDeterministic: jest.fn((v: string) => v),
},
},
{
provide: WebhooksService,
useValue: mockWebhooksService,
},
],
}).compile();

Expand All @@ -134,6 +143,39 @@ describe('ClaimsService', () => {
jest.clearAllMocks();
});

it('verify should enqueue claim.verified webhooks after a successful transition', async () => {
jest.spyOn(prismaService.claim, 'findUnique').mockResolvedValueOnce({
...mockClaim,
status: ClaimStatus.requested,
} as typeof mockClaim);
type TxClient = { claim: { update: jest.Mock } };
jest
.spyOn(prismaService, '$transaction')
.mockImplementation(
async (callback: (tx: TxClient) => Promise<unknown>) =>
callback({
claim: {
update: jest.fn().mockResolvedValue({
...mockClaim,
status: ClaimStatus.verified,
}),
},
}),
);

await service.verify('claim-123');

expect(mockWebhooksService.enqueueEvent).toHaveBeenCalledWith(
'claim.verified',
expect.objectContaining({
event: 'claim.verified',
claim: expect.objectContaining({
id: 'claim-123',
}),
}),
);
});

describe('disburse', () => {
it('should call on-chain adapter when enabled', async () => {
jest
Expand Down Expand Up @@ -314,6 +356,10 @@ describe('ClaimsService', () => {
decryptDeterministic: jest.fn((v: string) => v),
},
},
{
provide: WebhooksService,
useValue: mockWebhooksService,
},
],
}).compile();

Expand Down Expand Up @@ -365,6 +411,38 @@ describe('ClaimsService', () => {
);
});

it('should enqueue claim.disbursed webhooks after a successful disbursement', async () => {
jest
.spyOn(prismaService.claim, 'findUnique')
.mockResolvedValue(mockClaim);
type TxClient = { claim: { update: jest.Mock } };
jest
.spyOn(prismaService, '$transaction')
.mockImplementation(
async (callback: (tx: TxClient) => Promise<unknown>) =>
callback({
claim: {
update: jest.fn().mockResolvedValue({
...mockClaim,
status: ClaimStatus.disbursed,
}),
},
}),
);

await service.disburse('claim-123');

expect(mockWebhooksService.enqueueEvent).toHaveBeenCalledWith(
'claim.disbursed',
expect.objectContaining({
event: 'claim.disbursed',
claim: expect.objectContaining({
id: 'claim-123',
}),
}),
);
});

it('should throw NotFoundException if claim does not exist', async () => {
jest.spyOn(prismaService.claim, 'findUnique').mockResolvedValue(null);

Expand Down
Loading
Loading