Skip to content
Merged
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,20 @@
-- Add ngoId to Campaign and ApiKey (issue #239: multi-NGO RBAC)
ALTER TABLE "Campaign" ADD COLUMN "ngoId" TEXT;
ALTER TABLE "ApiKey" ADD COLUMN "ngoId" TEXT;

-- Soft-delete columns (issue #236: soft deletes)
ALTER TABLE "Campaign" ADD COLUMN "deletedAt" TIMESTAMP(3);
ALTER TABLE "Claim" ADD COLUMN "deletedAt" TIMESTAMP(3);

-- Performance indexes on Claim (issue #236)
CREATE INDEX "Claim_status_idx" ON "Claim"("status");
CREATE INDEX "Claim_campaignId_idx" ON "Claim"("campaignId");
CREATE INDEX "Claim_createdAt_idx" ON "Claim"("createdAt");
CREATE INDEX "Claim_deletedAt_idx" ON "Claim"("deletedAt");

-- Performance indexes on Campaign (issue #236 + #239)
CREATE INDEX "Campaign_ngoId_idx" ON "Campaign"("ngoId");
CREATE INDEX "Campaign_deletedAt_idx" ON "Campaign"("deletedAt");

-- Index on ApiKey.ngoId (issue #239)
CREATE INDEX "ApiKey_ngoId_idx" ON "ApiKey"("ngoId");
14 changes: 14 additions & 0 deletions app/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ model Claim {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?

status ClaimStatus @default(requested)

Expand All @@ -80,6 +81,11 @@ model Claim {

recipientRef String
evidenceRef String?

@@index([status])
@@index([campaignId])
@@index([createdAt])
@@index([deletedAt])
}

model AuditLog {
Expand All @@ -106,7 +112,10 @@ model Campaign {

metadata Json?

ngoId String?

archivedAt DateTime?
deletedAt DateTime?

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Expand All @@ -115,6 +124,8 @@ model Campaign {

@@index([status])
@@index([archivedAt])
@@index([ngoId])
@@index([deletedAt])
}

model Role {
Expand All @@ -133,7 +144,10 @@ model ApiKey {
id String @id @default(cuid())
key String @unique
role AppRole
ngoId String?
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([ngoId])
}
19 changes: 15 additions & 4 deletions app/backend/src/campaigns/campaigns.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
Patch,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import {
ApiBody,
Expand All @@ -21,12 +23,14 @@ import {
ApiTags,
ApiBearerAuth,
} from '@nestjs/swagger';
import { Request } from 'express';
import { CampaignsService } from './campaigns.service';
import { CreateCampaignDto } from './dto/create-campaign.dto';
import { UpdateCampaignDto } from './dto/update-campaign.dto';
import { ApiResponseDto } from '../common/dto/api-response.dto';
import { Roles } from 'src/auth/roles.decorator';
import { AppRole } from 'src/auth/app-role.enum';
import { OrgOwnershipGuard } from '../common/guards/org-ownership.guard';

@ApiTags('Campaigns')
@ApiBearerAuth('JWT-auth')
Expand All @@ -36,6 +40,7 @@ export class CampaignsController {

@Post()
@Roles(AppRole.admin, AppRole.ngo)
@UseGuards(OrgOwnershipGuard)
@ApiOperation({ summary: 'Create a campaign' })
@ApiBody({ type: CreateCampaignDto })
@ApiCreatedResponse({ description: 'Campaign created successfully.' })
Expand All @@ -48,15 +53,16 @@ export class CampaignsController {
@ApiForbiddenResponse({
description: 'Access denied - insufficient permissions for this operation.',
})
async create(@Body() dto: CreateCampaignDto) {
const campaign = await this.campaigns.create(dto);
async create(@Body() dto: CreateCampaignDto, @Req() req: Request) {
const campaign = await this.campaigns.create(dto, req.user?.ngoId);
return ApiResponseDto.ok(campaign, 'Campaigns created successfully');
}

@Get()
@ApiOperation({
summary: 'List all campaigns',
description: 'Retrieves a list of all active or archived campaigns.',
description:
"Retrieves campaigns. NGO operators only see their own organization's campaigns.",
})
@ApiOkResponse({ description: 'List of campaigns retrieved successfully.' })
@ApiUnauthorizedResponse({
Expand All @@ -65,8 +71,11 @@ export class CampaignsController {
async list(
@Query('includeArchived', new DefaultValuePipe(false), ParseBoolPipe)
includeArchived: boolean,
@Req() req: Request,
) {
const campaigns = await this.campaigns.findAll(includeArchived);
// Scope to ngoId for NGO role; admins/operators see all
const ngoId = req.user?.role === AppRole.ngo ? req.user.ngoId : undefined;
const campaigns = await this.campaigns.findAll(includeArchived, ngoId);
return ApiResponseDto.ok(campaigns, 'Campaigns fetched successfully');
}

Expand All @@ -86,6 +95,7 @@ export class CampaignsController {
}

@Patch(':id')
@UseGuards(OrgOwnershipGuard)
@ApiOperation({
summary: 'Update a campaign',
description:
Expand All @@ -108,6 +118,7 @@ export class CampaignsController {
}

@Patch(':id/archive')
@UseGuards(OrgOwnershipGuard)
@ApiOperation({
summary: 'Archive campaign (soft archive)',
description:
Expand Down
113 changes: 65 additions & 48 deletions app/backend/src/campaigns/campaigns.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,19 @@ describe('CampaignsService', () => {

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

const baseCampaign: Campaign = {
id: 'c1',
name: 'Winter Relief 2026',
status: CampaignStatus.draft,
budget: new Prisma.Decimal('1000.00'),
metadata: { region: 'Lagos' } as Prisma.JsonValue,
ngoId: null,
archivedAt: null,
deletedAt: null,
createdAt: now,
updatedAt: now,
};

beforeEach(async () => {
jest.clearAllMocks();
prismaMock = mockDeep<PrismaService>();
Expand All @@ -26,18 +39,7 @@ describe('CampaignsService', () => {
});

it('create(): creates a campaign with Decimal budget', async () => {
const mockCreated: Campaign = {
id: 'c1',
name: 'Winter Relief 2026',
status: CampaignStatus.draft,
budget: new Prisma.Decimal('1000.00'),
metadata: { region: 'Lagos' } as Prisma.JsonValue,
archivedAt: null,
createdAt: now,
updatedAt: now,
};

prismaMock.campaign.create.mockResolvedValue(mockCreated);
prismaMock.campaign.create.mockResolvedValue(baseCampaign);

const created = await service.create({
name: 'Winter Relief 2026',
Expand All @@ -46,7 +48,6 @@ describe('CampaignsService', () => {
status: CampaignStatus.draft,
});

// Avoid @typescript-eslint/unbound-method: inspect calls instead of asserting on method ref
const createArgs = prismaMock.campaign.create.mock.calls[0]?.[0];
expect(createArgs).toEqual(
expect.objectContaining({
Expand All @@ -58,21 +59,37 @@ describe('CampaignsService', () => {
}),
);

expect(created).toEqual(mockCreated);
expect(created.id).toBe('c1');
expect(created).toEqual(baseCampaign);
});

it('create(): attaches ngoId when provided', async () => {
prismaMock.campaign.create.mockResolvedValue({
...baseCampaign,
ngoId: 'ngo-1',
});

await service.create({ name: 'Test', budget: 100 }, 'ngo-1');

const createArgs = prismaMock.campaign.create.mock.calls[0]?.[0];
expect(createArgs?.data).toMatchObject({ ngoId: 'ngo-1' });
});

it('findAll(): excludes archived campaigns by default', async () => {
it('findAll(): excludes archived and deleted campaigns by default', async () => {
prismaMock.campaign.findMany.mockResolvedValue([]);

await service.findAll(false);

const args = prismaMock.campaign.findMany.mock.calls[0]?.[0];
expect(args).toEqual(
expect.objectContaining({
where: { archivedAt: null },
}),
);
expect(args?.where).toMatchObject({ archivedAt: null, deletedAt: null });
});

it('findAll(): scopes by ngoId when provided', async () => {
prismaMock.campaign.findMany.mockResolvedValue([]);

await service.findAll(false, 'ngo-42');

const args = prismaMock.campaign.findMany.mock.calls[0]?.[0];
expect(args?.where).toMatchObject({ ngoId: 'ngo-42' });
});

it('findAll(true): includes archived campaigns', async () => {
Expand All @@ -81,11 +98,7 @@ describe('CampaignsService', () => {
await service.findAll(true);

const args = prismaMock.campaign.findMany.mock.calls[0]?.[0];
expect(args).toEqual(
expect.objectContaining({
where: undefined,
}),
);
expect(args?.where).not.toHaveProperty('archivedAt');
});

it('findOne(): throws NotFoundException when missing', async () => {
Expand All @@ -94,12 +107,16 @@ describe('CampaignsService', () => {
await expect(service.findOne('missing')).rejects.toBeInstanceOf(
NotFoundException,
);
});

const args = prismaMock.campaign.findUnique.mock.calls[0]?.[0];
expect(args).toEqual(
expect.objectContaining({
where: { id: 'missing' },
}),
it('findOne(): throws NotFoundException when soft-deleted', async () => {
prismaMock.campaign.findUnique.mockResolvedValue({
...baseCampaign,
deletedAt: now,
});

await expect(service.findOne('c1')).rejects.toBeInstanceOf(
NotFoundException,
);
});

Expand All @@ -113,30 +130,30 @@ describe('CampaignsService', () => {
expect(prismaMock.campaign.update.mock.calls.length).toBe(0);
});

it('archive(): idempotent when already archived (does not call update)', async () => {
const alreadyArchived: Campaign = {
id: 'c1',
name: 'A',
it('archive(): idempotent when already archived', async () => {
prismaMock.campaign.findUnique.mockResolvedValue({
...baseCampaign,
status: CampaignStatus.archived,
budget: new Prisma.Decimal('10.00'),
metadata: null,
archivedAt: now,
createdAt: now,
updatedAt: now,
};

prismaMock.campaign.findUnique.mockResolvedValue(alreadyArchived);
});

const result = await service.archive('c1');

expect(result.alreadyArchived).toBe(true);
expect(prismaMock.campaign.update.mock.calls.length).toBe(0);
});

const args = prismaMock.campaign.findUnique.mock.calls[0]?.[0];
expect(args).toEqual(
expect.objectContaining({
where: { id: 'c1' },
}),
);
it('softDelete(): sets deletedAt on the campaign', async () => {
prismaMock.campaign.findUnique.mockResolvedValue(baseCampaign);
prismaMock.campaign.update.mockResolvedValue({
...baseCampaign,
deletedAt: now,
});

const result = await service.softDelete('c1');

const updateArgs = prismaMock.campaign.update.mock.calls[0]?.[0];
expect(updateArgs?.data).toMatchObject({ deletedAt: expect.any(Date) });
expect(result.deletedAt).not.toBeNull();
});
});
30 changes: 25 additions & 5 deletions app/backend/src/campaigns/campaigns.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,27 +15,38 @@ export class CampaignsService {
return metadata as Prisma.InputJsonValue;
}

async create(dto: CreateCampaignDto) {
async create(dto: CreateCampaignDto, ngoId?: string | null) {
return this.prisma.campaign.create({
data: {
name: dto.name,
status: dto.status ?? CampaignStatus.draft,
budget: dto.budget,
metadata: this.sanitizeMetadata(dto.metadata),
ngoId: ngoId ?? null,
},
});
}

async findAll(includeArchived = false) {
async findAll(includeArchived = false, ngoId?: string | null) {
const where: Prisma.CampaignWhereInput = {
deletedAt: null,
...(includeArchived ? {} : { archivedAt: null }),
...(ngoId ? { ngoId } : {}),
};

return this.prisma.campaign.findMany({
where: includeArchived ? undefined : { archivedAt: null },
where,
orderBy: { createdAt: 'desc' },
});
}

async findOne(id: string) {
const campaign = await this.prisma.campaign.findUnique({ where: { id } });
if (!campaign) throw new NotFoundException('Campaign not found');
const campaign = await this.prisma.campaign.findUnique({
where: { id },
});
if (!campaign || campaign.deletedAt) {
throw new NotFoundException('Campaign not found');
}
return campaign;
}

Expand Down Expand Up @@ -70,4 +81,13 @@ export class CampaignsService {

return { campaign: updated, alreadyArchived: false };
}

/** Soft-delete a campaign (sets deletedAt). */
async softDelete(id: string) {
await this.findOne(id);
return this.prisma.campaign.update({
where: { id },
data: { deletedAt: new Date() },
});
}
}
Loading
Loading