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
40 changes: 40 additions & 0 deletions apps/api/src/controllers/Campaigns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {CampaignService} from '../services/CampaignService.js';
import {DomainService} from '../services/DomainService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
import {parseListSort} from '../utils/listSort.js';

@Controller('campaigns')
export class Campaigns {
Expand Down Expand Up @@ -57,6 +58,13 @@ export class Campaigns {
/**
* Get all campaigns for a project
* GET /campaigns
*
* Query params:
* - page, pageSize: pagination
* - status: filter by CampaignStatus
* - search: filter by name/subject/from
* - sort: name | createdAt | updatedAt (default: createdAt)
* - dir: asc | desc (default: desc)
*/
@Get('')
@Middleware([requireAuth, requireEmailVerified])
Expand All @@ -67,6 +75,7 @@ export class Campaigns {
const search = typeof req.query.search === 'string' ? req.query.search.trim() || undefined : undefined;
const page = parseInt(req.query.page as string) || 1;
const pageSize = parseInt(req.query.pageSize as string) || 20;
const sort = parseListSort(req.query.sort, req.query.dir, {field: 'createdAt', direction: 'desc'});

// Validate status if provided
if (status && !Object.values(CampaignStatus).includes(status)) {
Expand All @@ -78,11 +87,42 @@ export class Campaigns {
search,
page,
pageSize,
sort,
});

return res.json(result);
}

/**
* Apply a bulk operation to multiple campaigns at once.
* POST /campaigns/bulk-update
*
* Currently supports `{ids: string[], delete: true}` for bulk delete. The
* schema is intentionally open-ended so future bulk operations can stack on
* the same endpoint.
*
* Atomicity: the underlying service wraps the ownership check, the
* draft-only guard, and the delete in a single Prisma transaction, so a
* partial bulk delete is not possible.
*
* NOTE: This must be defined BEFORE the :id route to avoid conflicts.
*/
@Post('bulk-update')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async bulkUpdate(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;

// Let Zod throw on invalid input so the global error handler in app.ts
// formats it into the standard error envelope, matching the other
// schema-validated endpoints in this controller.
const data = CampaignSchemas.bulkUpdate.parse(req.body);

const result = await CampaignService.bulkUpdate(auth.projectId, data);

return res.status(200).json(result);
}

/**
* Get a specific campaign
* GET /campaigns/:id
Expand Down
41 changes: 40 additions & 1 deletion apps/api/src/controllers/Templates.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
import {TemplateType} from '@plunk/db';
import {TemplateSchemas} from '@plunk/shared';
import type {NextFunction, Request, Response} from 'express';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {DomainService} from '../services/DomainService.js';
import {TemplateService} from '../services/TemplateService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
import {parseListSort} from '../utils/listSort.js';

@Controller('templates')
export class Templates {
/**
* GET /templates
* List all templates for the authenticated project
*
* Query params:
* - page, pageSize: pagination
* - search: filter by name/description/subject
* - type: filter by TemplateType
* - sort: name | createdAt | updatedAt (default: createdAt)
* - dir: asc | desc (default: desc)
*/
@Get('')
@Middleware([requireAuth, requireEmailVerified])
Expand All @@ -21,8 +30,9 @@ export class Templates {
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
const search = req.query.search as string | undefined;
const type = req.query.type as TemplateType | undefined;
const sort = parseListSort(req.query.sort, req.query.dir, {field: 'createdAt', direction: 'desc'});

const result = await TemplateService.list(auth.projectId!, page, pageSize, search, type);
const result = await TemplateService.list(auth.projectId!, page, pageSize, search, type, sort);

return res.status(200).json(result);
}
Expand Down Expand Up @@ -146,6 +156,35 @@ export class Templates {
return res.status(204).send();
}

/**
* POST /templates/bulk-update
* Apply a bulk operation to multiple templates at once.
*
* Currently supports `{ids: string[], delete: true}` for bulk delete.
* The schema is intentionally open-ended so tag-related fields
* (addTags / removeTags) can stack on the same endpoint once a
* `Template.tags` column exists.
*
* Atomicity: the underlying service wraps the ownership check, the
* workflow-step-reference check, and the delete in a single Prisma
* transaction, so a partial bulk delete is not possible.
*/
@Post('bulk-update')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async bulkUpdate(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;

// Let Zod throw on invalid input so the global error handler in app.ts
// formats it into the standard {success:false, error:{message, ...}}
// envelope, matching every other endpoint in this controller.
const data = TemplateSchemas.bulkUpdate.parse(req.body);

const result = await TemplateService.bulkUpdate(auth.projectId!, data);

return res.status(200).json(result);
}

/**
* POST /templates/:id/duplicate
* Duplicate a template
Expand Down
49 changes: 48 additions & 1 deletion apps/api/src/controllers/Workflows.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
import {WorkflowExecutionStatus} from '@plunk/db';
import {WorkflowSchemas} from '@plunk/shared';
import type {NextFunction, Request, Response} from 'express';
import signale from 'signale';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {WorkflowService} from '../services/WorkflowService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
import {parseListSort} from '../utils/listSort.js';

@Controller('workflows')
export class Workflows {
/**
* GET /workflows
* List all workflows for the authenticated project
*
* Query params:
* - page, pageSize: pagination
* - search: filter by name/description
* - status: active | disabled — maps to the `enabled` boolean facet
* - sort: name | createdAt | updatedAt | steps (default: createdAt)
* `steps` sorts by the related step count.
* - dir: asc | desc (default: desc)
*/
@Get('')
@Middleware([requireAuth, requireEmailVerified])
Expand All @@ -20,8 +30,15 @@ export class Workflows {
const page = parseInt(req.query.page as string) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
const search = req.query.search as string | undefined;
// `steps` is a workflow-specific sortable column (orders by step count).
const sort = parseListSort(req.query.sort, req.query.dir, {field: 'createdAt', direction: 'desc'}, ['steps']);

const result = await WorkflowService.list(auth.projectId!, page, pageSize, search);
// Status facet: `active` / `disabled` -> `enabled` boolean. Any other value
// (or absent) leaves the filter off.
const statusRaw = req.query.status as string | undefined;
const enabled = statusRaw === 'active' ? true : statusRaw === 'disabled' ? false : undefined;

const result = await WorkflowService.list(auth.projectId!, page, pageSize, search, sort, enabled);

return res.status(200).json(result);
}
Expand Down Expand Up @@ -51,6 +68,36 @@ export class Workflows {
}
}

/**
* POST /workflows/bulk-update
* Apply a bulk operation to multiple workflows at once.
*
* Currently supports `{ids: string[], delete: true}` for bulk delete. The
* schema is intentionally open-ended so future bulk operations can stack on
* the same endpoint.
*
* Atomicity: the underlying service wraps the ownership check, the
* active-execution guard, and the delete in a single Prisma transaction, so a
* partial bulk delete is not possible.
*
* NOTE: This must be defined BEFORE the :id route to avoid conflicts.
*/
@Post('bulk-update')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async bulkUpdate(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;

// Let Zod throw on invalid input so the global error handler in app.ts
// formats it into the standard error envelope, matching the other
// schema-validated endpoints.
const data = WorkflowSchemas.bulkUpdate.parse(req.body);

const result = await WorkflowService.bulkUpdate(auth.projectId!, data);

return res.status(200).json(result);
}

/**
* GET /workflows/:id
* Get a specific workflow with all steps and transitions
Expand Down
80 changes: 78 additions & 2 deletions apps/api/src/services/CampaignService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import signale from 'signale';

import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js';
import type {ListSort} from '../utils/listSort.js';
import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js';

import {BillingLimitService} from './BillingLimitService.js';
Expand Down Expand Up @@ -189,9 +190,10 @@ export class CampaignService {
search?: string;
page?: number;
pageSize?: number;
sort?: ListSort;
} = {},
): Promise<PaginatedResponse<Campaign>> {
const {status, search, page = 1, pageSize = 20} = options;
const {status, search, page = 1, pageSize = 20, sort = {field: 'createdAt', direction: 'desc'}} = options;
const skip = (page - 1) * pageSize;

const where: Prisma.CampaignWhereInput = {
Expand All @@ -214,7 +216,7 @@ export class CampaignService {
include: {
segment: true,
},
orderBy: {createdAt: 'desc'},
orderBy: {[sort.field]: sort.direction} as Prisma.CampaignOrderByWithRelationInput,
skip,
take: pageSize,
}),
Expand Down Expand Up @@ -260,6 +262,80 @@ export class CampaignService {
await NtfyService.notifyCampaignDeleted(campaign.name, campaign.project.name, projectId);
}

/**
* Apply a bulk operation to multiple campaigns at once.
*
* The payload is intentionally open-ended (a single endpoint) so future bulk
* operations can stack on the same operation. For now the only supported mode
* is `delete: true` (bulk delete).
*
* Atomicity: every selected campaign must belong to the requesting project AND
* every one of them must be a DRAFT. Both checks plus the `deleteMany` are
* folded into a single Prisma transaction, so a partial bulk delete is
* impossible — either every selected campaign is removed, or the whole
* operation rolls back.
*
* Guards mirror the single-campaign `delete()` above:
* - 404 if any id is missing from this project (foreign / cross-project id).
* - 400 if any selected campaign is not a DRAFT (only drafts are deletable;
* SCHEDULED / SENDING / SENT / CANCELLED campaigns are rejected, exactly as
* the single delete path does). Non-deletable campaigns are not silently
* skipped — the whole operation rolls back.
*/
public static async bulkUpdate(
projectId: string,
options: {ids: string[]; delete?: boolean},
): Promise<{deleted?: number; updated?: number}> {
const {ids, delete: shouldDelete} = options;

// Dedup defensively — the schema permits the same id twice and we don't
// want duplicates inflating the ownership / row counts below.
const uniqueIds = Array.from(new Set(ids));

if (uniqueIds.length === 0) {
return {updated: 0};
}

if (shouldDelete) {
return prisma.$transaction(async tx => {
// 1. Ownership / project-scope check. Cross-project leaks are the main
// thing this endpoint must defend against.
const owned = await tx.campaign.findMany({
where: {id: {in: uniqueIds}, projectId},
select: {id: true, status: true},
});

if (owned.length !== uniqueIds.length) {
throw new HttpException(404, 'One or more campaigns not found in this project');
}

// 2. Reject the whole bulk delete if ANY selected campaign is not a
// DRAFT. Mirrors the single delete() guard — no partial wipes.
const nonDraft = owned.filter(c => c.status !== CampaignStatus.DRAFT);

if (nonDraft.length > 0) {
const count = nonDraft.length;
throw new HttpException(
400,
`Can only delete draft campaigns: ${count} of the selected campaign${
count === 1 ? ' is' : 's are'
} not a draft.`,
);
}

const result = await tx.campaign.deleteMany({
where: {id: {in: uniqueIds}, projectId},
});

return {deleted: result.count};
});
}

// No-op shape for forward-compat: when other bulk modes ship they'll branch
// off here. Returning {updated: 0} keeps the response shape stable.
return {updated: 0};
}

/**
* Duplicate a campaign
*/
Expand Down
Loading
Loading