diff --git a/apps/api/src/controllers/Campaigns.ts b/apps/api/src/controllers/Campaigns.ts index d2033d0c..b776ebe0 100644 --- a/apps/api/src/controllers/Campaigns.ts +++ b/apps/api/src/controllers/Campaigns.ts @@ -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 { @@ -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]) @@ -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)) { @@ -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 diff --git a/apps/api/src/controllers/Templates.ts b/apps/api/src/controllers/Templates.ts index 26a9cf3e..20d83a0e 100644 --- a/apps/api/src/controllers/Templates.ts +++ b/apps/api/src/controllers/Templates.ts @@ -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]) @@ -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); } @@ -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 diff --git a/apps/api/src/controllers/Workflows.ts b/apps/api/src/controllers/Workflows.ts index 87e29168..273862d7 100644 --- a/apps/api/src/controllers/Workflows.ts +++ b/apps/api/src/controllers/Workflows.ts @@ -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]) @@ -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); } @@ -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 diff --git a/apps/api/src/services/CampaignService.ts b/apps/api/src/services/CampaignService.ts index 06a30c36..1e0ef466 100644 --- a/apps/api/src/services/CampaignService.ts +++ b/apps/api/src/services/CampaignService.ts @@ -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'; @@ -189,9 +190,10 @@ export class CampaignService { search?: string; page?: number; pageSize?: number; + sort?: ListSort; } = {}, ): Promise> { - 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 = { @@ -214,7 +216,7 @@ export class CampaignService { include: { segment: true, }, - orderBy: {createdAt: 'desc'}, + orderBy: {[sort.field]: sort.direction} as Prisma.CampaignOrderByWithRelationInput, skip, take: pageSize, }), @@ -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 */ diff --git a/apps/api/src/services/TemplateService.ts b/apps/api/src/services/TemplateService.ts index 5c7af7f1..8b46117b 100644 --- a/apps/api/src/services/TemplateService.ts +++ b/apps/api/src/services/TemplateService.ts @@ -4,6 +4,7 @@ import type {PaginatedResponse} from '@plunk/types'; 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'; export class TemplateService { @@ -16,6 +17,7 @@ export class TemplateService { pageSize = 20, search?: string, type?: Template['type'], + sort: ListSort = {field: 'createdAt', direction: 'desc'}, ): Promise> { const skip = (page - 1) * pageSize; @@ -38,7 +40,7 @@ export class TemplateService { where, skip, take: pageSize, - orderBy: {createdAt: 'desc'}, + orderBy: {[sort.field]: sort.direction} as Prisma.TemplateOrderByWithRelationInput, }), prisma.template.count({where}), ]); @@ -159,6 +161,86 @@ export class TemplateService { }); } + /** + * Apply a bulk operation to multiple templates at once. + * + * The payload is intentionally open-ended (a single endpoint) so future + * tag-related fields (`addTags` / `removeTags`) can stack on the same + * operation once a `Template.tags` column exists. For now the only + * supported mode is `delete: true` (bulk delete). + * + * Atomicity: every selected template must belong to the requesting project + * AND none of them may currently be referenced by a workflow step. Both + * checks plus the `deleteMany` are folded into a single Prisma transaction, + * so a partial bulk delete is impossible — either every selected template is + * removed, or the whole operation rolls back. + * + * - 404 if any id is missing from this project (foreign / cross-project id). + * - 409 if any selected template is still referenced by a workflow step + * (mirrors the single-template `delete()` guard above). + */ + 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.template.findMany({ + where: {id: {in: uniqueIds}, projectId}, + select: {id: true}, + }); + + if (owned.length !== uniqueIds.length) { + throw new HttpException(404, 'One or more templates not found in this project'); + } + + // 2. Reject the whole bulk delete if ANY selected template is still + // referenced by a workflow step. Mirrors the single delete() + // behavior — no partial wipes. + const referenced = await tx.workflowStep.findMany({ + where: { + templateId: {in: uniqueIds}, + workflow: {projectId}, + }, + select: {templateId: true}, + distinct: ['templateId'], + }); + + if (referenced.length > 0) { + const count = referenced.length; + throw new HttpException( + 409, + `Cannot delete: ${count} of the selected template${ + count === 1 ? ' is' : 's are' + } currently used in workflow steps. Remove from workflows first.`, + ); + } + + const result = await tx.template.deleteMany({ + where: {id: {in: uniqueIds}, projectId}, + }); + + return {deleted: result.count}; + }); + } + + // No-op shape for forward-compat: when tag add/remove modes ship they'll + // branch off here. Returning {updated: 0} keeps the response shape stable. + return {updated: 0}; + } + /** * Duplicate a template */ diff --git a/apps/api/src/services/WorkflowService.ts b/apps/api/src/services/WorkflowService.ts index 407ddeeb..11dde477 100644 --- a/apps/api/src/services/WorkflowService.ts +++ b/apps/api/src/services/WorkflowService.ts @@ -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 {ContactService} from './ContactService.js'; import {EventService} from './EventService.js'; @@ -21,11 +22,16 @@ export class WorkflowService { page = 1, pageSize = 20, search?: string, + sort: ListSort = {field: 'createdAt', direction: 'desc'}, + enabled?: boolean, ): Promise> { const skip = (page - 1) * pageSize; const where: Prisma.WorkflowWhereInput = { projectId, + // Status facet (Active / Disabled). Undefined = no filter; the + // `@@index([projectId, enabled])` covers this predicate. + ...(enabled !== undefined ? {enabled} : {}), ...(search ? { OR: [ @@ -36,12 +42,20 @@ export class WorkflowService { : {}), }; + // The `steps` column sorts by the related step count, which Prisma expresses + // as `orderBy: {steps: {_count}}` rather than a scalar field. Every other + // sortable field (name/createdAt/updatedAt) maps straight onto the scalar. + const orderBy: Prisma.WorkflowOrderByWithRelationInput = + sort.field === 'steps' + ? {steps: {_count: sort.direction}} + : ({[sort.field]: sort.direction} as Prisma.WorkflowOrderByWithRelationInput); + const [workflows, total] = await Promise.all([ prisma.workflow.findMany({ where, skip, take: pageSize, - orderBy: {createdAt: 'desc'}, + orderBy, include: { _count: { select: { @@ -310,6 +324,94 @@ export class WorkflowService { await NtfyService.notifyWorkflowDeleted(workflow.name, workflow.project.name, projectId); } + /** + * Apply a bulk operation to multiple workflows at once. + * + * The payload is intentionally open-ended (a single endpoint) so future bulk + * operations (e.g. enable / disable) can stack on the same operation. For now + * the only supported mode is `delete: true` (bulk delete). + * + * Atomicity: every selected workflow must belong to the requesting project AND + * none of them may currently have active executions. Both checks plus the + * `deleteMany` are folded into a single Prisma transaction, so a partial bulk + * delete is impossible — either every selected workflow is removed, or the + * whole operation rolls back. + * + * Guards mirror the single-workflow `delete()` above: + * - 404 if any id is missing from this project (foreign / cross-project id). + * - 409 if any selected workflow has active (RUNNING / WAITING) executions. + * + * Deleting a workflow cascades its steps, transitions and executions exactly + * as the single `delete()` does (Prisma relations). After the transaction + * commits, the enabled-workflow cache is invalidated once if any deleted + * workflow was enabled, matching the single-delete side effect. + */ + 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) { + const {deleted, hadEnabled} = await 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.workflow.findMany({ + where: {id: {in: uniqueIds}, projectId}, + select: {id: true, enabled: true}, + }); + + if (owned.length !== uniqueIds.length) { + throw new HttpException(404, 'One or more workflows not found in this project'); + } + + // 2. Reject the whole bulk delete if ANY selected workflow has active + // executions. Mirrors the single delete() guard — no partial wipes. + const activeExecutions = await tx.workflowExecution.count({ + where: { + workflowId: {in: uniqueIds}, + status: { + in: [WorkflowExecutionStatus.RUNNING, WorkflowExecutionStatus.WAITING], + }, + }, + }); + + if (activeExecutions > 0) { + throw new HttpException( + 409, + `Cannot delete: ${activeExecutions} active execution(s) across the selected workflows. ` + + 'Please wait for them to complete or cancel them first.', + ); + } + + const result = await tx.workflow.deleteMany({ + where: {id: {in: uniqueIds}, projectId}, + }); + + return {deleted: result.count, hadEnabled: owned.some(w => w.enabled)}; + }); + + // Invalidate workflow cache if any deleted workflow was enabled. + if (hadEnabled) { + await EventService.invalidateWorkflowCache(projectId); + } + + return {deleted}; + } + + // 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 workflow including all steps and transitions. * The duplicate always starts disabled to prevent accidental triggering. diff --git a/apps/api/src/services/__tests__/CampaignService.test.ts b/apps/api/src/services/__tests__/CampaignService.test.ts index dfd2dab4..e20e23ef 100644 --- a/apps/api/src/services/__tests__/CampaignService.test.ts +++ b/apps/api/src/services/__tests__/CampaignService.test.ts @@ -131,6 +131,82 @@ describe('CampaignService', () => { }); }); + describe('bulkUpdate (delete)', () => { + it('should bulk-delete multiple draft campaigns in this project', async () => { + const a = await factories.createCampaign({projectId, status: CampaignStatus.DRAFT}); + const b = await factories.createCampaign({projectId, status: CampaignStatus.DRAFT}); + const c = await factories.createCampaign({projectId, status: CampaignStatus.DRAFT}); + + const result = await CampaignService.bulkUpdate(projectId, { + ids: [a.id, b.id, c.id], + delete: true, + }); + + expect(result.deleted).toBe(3); + expect(await prisma.campaign.count({where: {projectId}})).toBe(0); + }); + + it('should dedup repeated ids so the deleted count is not inflated', async () => { + const a = await factories.createCampaign({projectId, status: CampaignStatus.DRAFT}); + + const result = await CampaignService.bulkUpdate(projectId, { + ids: [a.id, a.id, a.id], + delete: true, + }); + + expect(result.deleted).toBe(1); + expect(await prisma.campaign.findUnique({where: {id: a.id}})).toBeNull(); + }); + + it('should throw 404 (and delete nothing) when any id does not exist', async () => { + const a = await factories.createCampaign({projectId, status: CampaignStatus.DRAFT}); + + await expect( + CampaignService.bulkUpdate(projectId, {ids: [a.id, '00000000-0000-0000-0000-000000000000'], delete: true}), + ).rejects.toThrow(/not found in this project/i); + + // Nothing deleted — the whole operation rolled back. + expect(await prisma.campaign.findUnique({where: {id: a.id}})).not.toBeNull(); + }); + + it('should throw 404 (and delete nothing) when an id belongs to another project', async () => { + const {project: otherProject} = await factories.createUserWithProject(); + const mine = await factories.createCampaign({projectId, status: CampaignStatus.DRAFT}); + const foreign = await factories.createCampaign({projectId: otherProject.id, status: CampaignStatus.DRAFT}); + + await expect( + CampaignService.bulkUpdate(projectId, {ids: [mine.id, foreign.id], delete: true}), + ).rejects.toThrow(/not found in this project/i); + + // Both survive — atomic rollback, no cross-project leak. + expect(await prisma.campaign.findUnique({where: {id: mine.id}})).not.toBeNull(); + expect(await prisma.campaign.findUnique({where: {id: foreign.id}})).not.toBeNull(); + }); + + it('should throw 400 (and delete nothing) when any selected campaign is not a draft', async () => { + const draft = await factories.createCampaign({projectId, status: CampaignStatus.DRAFT}); + const sent = await factories.createCampaign({projectId, status: CampaignStatus.SENT}); + + await expect( + CampaignService.bulkUpdate(projectId, {ids: [draft.id, sent.id], delete: true}), + ).rejects.toThrow(/can only delete draft campaigns/i); + + // Partial delete must be impossible — the draft must survive too. + expect(await prisma.campaign.findUnique({where: {id: draft.id}})).not.toBeNull(); + expect(await prisma.campaign.findUnique({where: {id: sent.id}})).not.toBeNull(); + }); + + it('should return {updated: 0} when delete flag is omitted (forward-compat no-op)', async () => { + const a = await factories.createCampaign({projectId, status: CampaignStatus.DRAFT}); + + const result = await CampaignService.bulkUpdate(projectId, {ids: [a.id]}); + + expect(result).toEqual({updated: 0}); + // No-op: the campaign is untouched. + expect(await prisma.campaign.findUnique({where: {id: a.id}})).not.toBeNull(); + }); + }); + describe('duplicate', () => { it('should duplicate a campaign with (Copy) suffix', async () => { const original = await factories.createCampaign({ diff --git a/apps/api/src/services/__tests__/TemplateService.test.ts b/apps/api/src/services/__tests__/TemplateService.test.ts index b472a1ca..f39a7f81 100644 --- a/apps/api/src/services/__tests__/TemplateService.test.ts +++ b/apps/api/src/services/__tests__/TemplateService.test.ts @@ -396,6 +396,88 @@ describe('TemplateService', () => { }); }); + describe('bulkUpdate (delete)', () => { + it('should bulk-delete multiple templates in this project', async () => { + const a = await factories.createTemplate({projectId}); + const b = await factories.createTemplate({projectId}); + const c = await factories.createTemplate({projectId}); + + const result = await TemplateService.bulkUpdate(projectId, { + ids: [a.id, b.id, c.id], + delete: true, + }); + + expect(result.deleted).toBe(3); + expect(await prisma.template.count({where: {projectId}})).toBe(0); + }); + + it('should dedup repeated ids so the deleted count is not inflated', async () => { + const a = await factories.createTemplate({projectId}); + + const result = await TemplateService.bulkUpdate(projectId, { + ids: [a.id, a.id, a.id], + delete: true, + }); + + expect(result.deleted).toBe(1); + expect(await prisma.template.findUnique({where: {id: a.id}})).toBeNull(); + }); + + it('should throw 404 when any id does not exist', async () => { + const a = await factories.createTemplate({projectId}); + + await expect( + TemplateService.bulkUpdate(projectId, {ids: [a.id, 'non-existent-id'], delete: true}), + ).rejects.toThrow(/not found in this project/i); + + // Nothing deleted — the whole operation rolled back. + expect(await prisma.template.findUnique({where: {id: a.id}})).not.toBeNull(); + }); + + it('should throw 404 (and delete nothing) when an id belongs to another project', async () => { + const {project: otherProject} = await factories.createUserWithProject(); + const mine = await factories.createTemplate({projectId}); + const foreign = await factories.createTemplate({projectId: otherProject.id}); + + await expect( + TemplateService.bulkUpdate(projectId, {ids: [mine.id, foreign.id], delete: true}), + ).rejects.toThrow(/not found in this project/i); + + // Both survive — atomic rollback, no cross-project leak. + expect(await prisma.template.findUnique({where: {id: mine.id}})).not.toBeNull(); + expect(await prisma.template.findUnique({where: {id: foreign.id}})).not.toBeNull(); + }); + + it('should throw 409 (and delete nothing) when any selected template is used in a workflow step', async () => { + const used = await factories.createTemplate({projectId}); + const free = await factories.createTemplate({projectId}); + const workflow = await factories.createWorkflow({projectId}); + await factories.createWorkflowStep({ + workflowId: workflow.id, + templateId: used.id, + type: 'SEND_EMAIL', + }); + + await expect( + TemplateService.bulkUpdate(projectId, {ids: [used.id, free.id], delete: true}), + ).rejects.toThrow(/currently used in workflow steps/i); + + // Partial delete must be impossible — the un-referenced template must survive too. + expect(await prisma.template.findUnique({where: {id: used.id}})).not.toBeNull(); + expect(await prisma.template.findUnique({where: {id: free.id}})).not.toBeNull(); + }); + + it('should return {updated: 0} when delete flag is omitted (forward-compat no-op)', async () => { + const a = await factories.createTemplate({projectId}); + + const result = await TemplateService.bulkUpdate(projectId, {ids: [a.id]}); + + expect(result).toEqual({updated: 0}); + // No-op: the template is untouched. + expect(await prisma.template.findUnique({where: {id: a.id}})).not.toBeNull(); + }); + }); + describe('duplicate', () => { it('should duplicate a template with (Copy) suffix', async () => { const original = await factories.createTemplate({ diff --git a/apps/api/src/services/__tests__/WorkflowService.test.ts b/apps/api/src/services/__tests__/WorkflowService.test.ts index 11287989..f21253e5 100644 --- a/apps/api/src/services/__tests__/WorkflowService.test.ts +++ b/apps/api/src/services/__tests__/WorkflowService.test.ts @@ -240,6 +240,44 @@ describe('WorkflowService', () => { expect(found?._count.steps).toBe(3); // TRIGGER + 2 added expect(found?._count.executions).toBe(1); }); + + it('should filter by enabled status', async () => { + await factories.createWorkflow({projectId, name: 'Active A', enabled: true}); + await factories.createWorkflow({projectId, name: 'Active B', enabled: true}); + await factories.createWorkflow({projectId, name: 'Disabled', enabled: false}); + + const active = await WorkflowService.list(projectId, 1, 20, undefined, undefined, true); + expect(active.total).toBe(2); + expect(active.data.every(w => w.enabled)).toBe(true); + + const disabled = await WorkflowService.list(projectId, 1, 20, undefined, undefined, false); + expect(disabled.total).toBe(1); + expect(disabled.data.every(w => !w.enabled)).toBe(true); + + // Omitting the filter returns both. + const all = await WorkflowService.list(projectId, 1, 20, undefined, undefined, undefined); + expect(all.total).toBe(3); + }); + + it('should sort by step count ascending and descending', async () => { + // `few` keeps just its TRIGGER step (count 1); `many` gets two more (count 3). + const few = await factories.createWorkflow({projectId, name: 'Few Steps'}); + const many = await factories.createWorkflow({projectId, name: 'Many Steps'}); + await factories.createWorkflowStep({workflowId: many.id}); + await factories.createWorkflowStep({workflowId: many.id}); + + const asc = await WorkflowService.list(projectId, 1, 20, undefined, { + field: 'steps', + direction: 'asc', + }); + expect(asc.data.map(w => w.id)).toEqual([few.id, many.id]); + + const desc = await WorkflowService.list(projectId, 1, 20, undefined, { + field: 'steps', + direction: 'desc', + }); + expect(desc.data.map(w => w.id)).toEqual([many.id, few.id]); + }); }); describe('update', () => { @@ -343,6 +381,88 @@ describe('WorkflowService', () => { }); }); + describe('bulkUpdate (delete)', () => { + it('should bulk-delete multiple workflows (and their steps) in this project', async () => { + const a = await factories.createWorkflow({projectId}); + const b = await factories.createWorkflow({projectId}); + const c = await factories.createWorkflow({projectId}); + + const result = await WorkflowService.bulkUpdate(projectId, { + ids: [a.id, b.id, c.id], + delete: true, + }); + + expect(result.deleted).toBe(3); + expect(await prisma.workflow.count({where: {projectId}})).toBe(0); + // Steps cascade-delete with their workflows. + expect(await prisma.workflowStep.count({where: {workflowId: {in: [a.id, b.id, c.id]}}})).toBe(0); + }); + + it('should dedup repeated ids so the deleted count is not inflated', async () => { + const a = await factories.createWorkflow({projectId}); + + const result = await WorkflowService.bulkUpdate(projectId, { + ids: [a.id, a.id, a.id], + delete: true, + }); + + expect(result.deleted).toBe(1); + expect(await prisma.workflow.findUnique({where: {id: a.id}})).toBeNull(); + }); + + it('should throw 404 (and delete nothing) when any id does not exist', async () => { + const a = await factories.createWorkflow({projectId}); + + await expect( + WorkflowService.bulkUpdate(projectId, {ids: [a.id, '00000000-0000-0000-0000-000000000000'], delete: true}), + ).rejects.toThrow(/not found in this project/i); + + // Nothing deleted — the whole operation rolled back. + expect(await prisma.workflow.findUnique({where: {id: a.id}})).not.toBeNull(); + }); + + it('should throw 404 (and delete nothing) when an id belongs to another project', async () => { + const {project: otherProject} = await factories.createUserWithProject(); + const mine = await factories.createWorkflow({projectId}); + const foreign = await factories.createWorkflow({projectId: otherProject.id}); + + await expect( + WorkflowService.bulkUpdate(projectId, {ids: [mine.id, foreign.id], delete: true}), + ).rejects.toThrow(/not found in this project/i); + + // Both survive — atomic rollback, no cross-project leak. + expect(await prisma.workflow.findUnique({where: {id: mine.id}})).not.toBeNull(); + expect(await prisma.workflow.findUnique({where: {id: foreign.id}})).not.toBeNull(); + }); + + it('should throw 409 (and delete nothing) when any selected workflow has active executions', async () => { + const busy = await factories.createWorkflow({projectId, enabled: true}); + const idle = await factories.createWorkflow({projectId}); + const contact = await factories.createContact({projectId}); + await factories.createWorkflowExecution(busy.id, contact.id, { + status: WorkflowExecutionStatus.RUNNING, + }); + + await expect( + WorkflowService.bulkUpdate(projectId, {ids: [busy.id, idle.id], delete: true}), + ).rejects.toThrow(/active execution/i); + + // Partial delete must be impossible — the idle workflow must survive too. + expect(await prisma.workflow.findUnique({where: {id: busy.id}})).not.toBeNull(); + expect(await prisma.workflow.findUnique({where: {id: idle.id}})).not.toBeNull(); + }); + + it('should return {updated: 0} when delete flag is omitted (forward-compat no-op)', async () => { + const a = await factories.createWorkflow({projectId}); + + const result = await WorkflowService.bulkUpdate(projectId, {ids: [a.id]}); + + expect(result).toEqual({updated: 0}); + // No-op: the workflow is untouched. + expect(await prisma.workflow.findUnique({where: {id: a.id}})).not.toBeNull(); + }); + }); + // ======================================== // WORKFLOW STEPS // ======================================== diff --git a/apps/api/src/utils/listSort.ts b/apps/api/src/utils/listSort.ts new file mode 100644 index 00000000..aad2c46a --- /dev/null +++ b/apps/api/src/utils/listSort.ts @@ -0,0 +1,53 @@ +/** + * Shared sort-query parsing for list endpoints. + * + * Accepts `?sort=name|createdAt|updatedAt&dir=asc|desc` on list endpoints and + * returns a `{field, direction}` shape that maps directly onto a Prisma + * `orderBy` clause. Invalid values silently fall back to the supplied default + * so existing callers keep their current behavior. + * + * Callers that expose additional, entity-specific sortable columns (e.g. the + * workflows list sorting by its `steps` relation count) can opt those fields in + * via the `extraFields` argument without widening the shared default set. + */ + +export type ListSortField = 'name' | 'createdAt' | 'updatedAt'; +export type ListSortDirection = 'asc' | 'desc'; + +export interface ListSort { + /** + * A base field name OR an entity-specific extra field opted in by the caller + * (see `extraFields`). The caller is responsible for mapping any extra field + * onto a valid Prisma `orderBy` shape. + */ + field: ListSortField | (string & {}); + direction: ListSortDirection; +} + +const ALLOWED_FIELDS = new Set(['name', 'createdAt', 'updatedAt']); +const ALLOWED_DIRECTIONS = new Set(['asc', 'desc']); + +/** + * Parse `sort` and `dir` query-string values into a typed sort descriptor. + * Unknown or missing values fall back to `defaultSort`. + * + * @param extraFields Optional entity-specific field names to also accept (e.g. + * `['steps']` for workflows). These bypass the shared allow-list but are still + * validated against this explicit per-caller set, so arbitrary client input + * can never reach the Prisma `orderBy`. + */ +export function parseListSort( + sortRaw: unknown, + dirRaw: unknown, + defaultSort: ListSort, + extraFields: readonly string[] = [], +): ListSort { + const isAllowed = + typeof sortRaw === 'string' && + (ALLOWED_FIELDS.has(sortRaw as ListSortField) || extraFields.includes(sortRaw)); + const sort = isAllowed ? (sortRaw as ListSort['field']) : defaultSort.field; + const dir = typeof dirRaw === 'string' && ALLOWED_DIRECTIONS.has(dirRaw as ListSortDirection) + ? (dirRaw as ListSortDirection) + : defaultSort.direction; + return {field: sort, direction: dir}; +} diff --git a/apps/web/package.json b/apps/web/package.json index 133ab059..ad1d0d08 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,6 +17,7 @@ "@plunk/db": "*", "@plunk/shared": "*", "@plunk/ui": "*", + "@tanstack/react-table": "^8.21.3", "@tiptap/core": "^3.11.0", "@tiptap/extension-color": "^3.11.0", "@tiptap/extension-image": "^3.11.0", diff --git a/apps/web/src/components/data-table/BulkActionBar.tsx b/apps/web/src/components/data-table/BulkActionBar.tsx new file mode 100644 index 00000000..e7282b36 --- /dev/null +++ b/apps/web/src/components/data-table/BulkActionBar.tsx @@ -0,0 +1,68 @@ +import {Button} from '@plunk/ui'; +import {X} from 'lucide-react'; +import type {ReactNode} from 'react'; + +interface BulkActionBarProps { + /** Number of currently-selected rows. The bar renders only when this is > 0. */ + selectedCount: number; + /** Singular noun for the selected item type (e.g. "template"). Pluralized with a trailing 's'. */ + itemNoun: string; + /** Clears the row selection state. */ + onClear: () => void; + /** + * Action slot — render the Buttons that belong to the bar here. Kept as + * `children` (rather than a fixed `actions` prop) so future bulk operations + * (e.g. tag add/remove, a separate PR) can be spliced in next to the delete + * button without touching this component. + */ + children?: ReactNode; +} + +/** + * Slim selection-driven action bar that sits above a table when one or more + * rows are selected. This PR wires it for the templates table with a single + * "Delete selected" action; the `children` slot keeps it open for later bulk + * operations. Generic and reusable across list tables. + * + * Visually a neutral pill above the table — not sticky, so it scrolls with the + * page, matching the rest of the dashboard's affordances and keeping tab order + * predictable. + */ +export function BulkActionBar({selectedCount, itemNoun, onClear, children}: BulkActionBarProps) { + if (selectedCount <= 0) return null; + + const noun = selectedCount === 1 ? itemNoun : `${itemNoun}s`; + + return ( +
+
+ + + {selectedCount} {noun} selected + +
+ {children ? ( + // Pin every action button to the same height/padding so the bar reads as + // a uniform group. `outline` triggers (which carry a border) and the + // solid `destructive` button otherwise read at subtly different sizes; + // forcing `h-8` + `px-3` here keeps them identical regardless of variant. +
+ {children} +
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/data-table/DataTable.tsx b/apps/web/src/components/data-table/DataTable.tsx new file mode 100644 index 00000000..bdb7b55f --- /dev/null +++ b/apps/web/src/components/data-table/DataTable.tsx @@ -0,0 +1,97 @@ +import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@plunk/ui'; +import {flexRender, type Table as TanstackTable} from '@tanstack/react-table'; + +/** + * Per-column presentation hints read off `columnDef.meta`. Optional — a column + * without them just gets the default cell padding/alignment. + */ +export interface DataTableColumnMeta { + /** Display label, used by the column-visibility menu. */ + label?: string; + /** Extra className applied to this column's ``. */ + headClassName?: string; + /** Extra className applied to this column's `` cells. */ + cellClassName?: string; +} + +interface DataTableProps { + table: TanstackTable; + /** + * Optional empty-state node rendered (spanning all columns) when the table + * has zero rows. Callers usually short-circuit before rendering the table at + * all, but this keeps the component self-sufficient. + */ + emptyState?: React.ReactNode; +} + +/** + * Generic, presentation-only table built on tanstack-react-table + Plunk's + * `@plunk/ui` Table primitives, following the shadcn `DataTable` convention. + * + * The table instance (and therefore all sorting / selection / visibility state) + * is owned by the caller and passed in, so this component stays reusable for + * any list page — workflows and campaigns can adopt it with just a column + * definition. Sorting indicators live in the column headers + * (`DataTableColumnHeader`); this component owns the `aria-sort` attribute on + * each `` so it always mirrors the real sort state. + */ +export function DataTable({table, emptyState}: DataTableProps) { + const rows = table.getRowModel().rows; + const leafColumnCount = table.getVisibleLeafColumns().length; + + return ( + + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => { + const meta = header.column.columnDef.meta as DataTableColumnMeta | undefined; + const sorted = header.column.getIsSorted(); + return ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ); + })} + + ))} + + + {rows.length === 0 ? ( + + + {emptyState ?? 'No results.'} + + + ) : ( + rows.map(row => ( + + {row.getVisibleCells().map(cell => { + const meta = cell.column.columnDef.meta as DataTableColumnMeta | undefined; + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} + + )) + )} + +
+ ); +} diff --git a/apps/web/src/components/data-table/DataTableColumnHeader.tsx b/apps/web/src/components/data-table/DataTableColumnHeader.tsx new file mode 100644 index 00000000..1cfba06f --- /dev/null +++ b/apps/web/src/components/data-table/DataTableColumnHeader.tsx @@ -0,0 +1,78 @@ +import type {Column} from '@tanstack/react-table'; +import {ChevronDown, ChevronUp, ChevronsUpDown} from 'lucide-react'; +import type {ReactNode} from 'react'; + +interface DataTableColumnHeaderProps { + column: Column; + children: ReactNode; + /** Align the header content (defaults to `left`). */ + align?: 'left' | 'right' | 'center'; + /** + * Optional slot rendered next to the sort control — used for the per-column + * faceted filter (`DataTableFacetedFilter`). Table-view-only. + */ + filter?: ReactNode; +} + +const alignClass = (align: 'left' | 'right' | 'center') => + align === 'right' ? 'justify-end' : align === 'center' ? 'justify-center' : 'justify-start'; + +/** + * Sortable column header for the tanstack-driven `DataTable`, following the + * shadcn data-table convention but adapted to Plunk's primitives. Clicking the + * label cycles asc → desc → unsorted with chevron indicators; the surrounding + * `` is responsible for the `aria-sort` attribute (see `DataTable`). + * + * An optional `filter` slot lets fixed-value columns (e.g. Type) render a + * faceted dropdown right in the header — the table-view-only Excel-style filter. + */ +export function DataTableColumnHeader({ + column, + children, + align = 'left', + filter, +}: DataTableColumnHeaderProps) { + const canSort = column.getCanSort(); + const sorted = column.getIsSorted(); + + // Match the plain (non-sortable) `` header styling exactly so every column + // header reads uniformly — see `DataTable`'s `` className + // (`text-xs font-medium uppercase tracking-wider text-neutral-500`). The + // sortable label is a ` + ) : ( + {children} + ); + + return ( + + {label} + {filter} + + ); +} diff --git a/apps/web/src/components/data-table/DataTableFacetedFilter.tsx b/apps/web/src/components/data-table/DataTableFacetedFilter.tsx new file mode 100644 index 00000000..41de2f49 --- /dev/null +++ b/apps/web/src/components/data-table/DataTableFacetedFilter.tsx @@ -0,0 +1,131 @@ +import { + Badge, + Command, + CommandEmpty, + CommandGroup, + CommandItem, + CommandList, + CommandSeparator, + Popover, + PopoverContent, + PopoverTrigger, +} from '@plunk/ui'; +import {Check, ListFilter} from 'lucide-react'; +import type {ComponentType, ReactNode} from 'react'; + +export interface FacetedFilterOption { + /** Stable value sent back through `onChange` (e.g. a `TemplateType` enum member). */ + value: string; + /** Human label shown in the dropdown. */ + label: ReactNode; + /** Optional leading icon. */ + icon?: ComponentType<{className?: string}>; +} + +interface DataTableFacetedFilterProps { + /** Title shown in the dropdown header and the trigger's accessible name. */ + title: string; + options: FacetedFilterOption[]; + /** Currently-selected values (controlled). The component itself owns no state. */ + selected: string[]; + /** Called with the full next selection whenever the user toggles an option or clears. */ + onChange: (next: string[]) => void; + /** + * When false, only a single option can be selected at a time (a fresh pick + * replaces the prior one). Defaults to true (multi-select). Templates use + * single-select for Type. + */ + multiple?: boolean; +} + +/** + * Excel-style faceted filter rendered inside a column header (shadcn data-table + * convention), adapted to Plunk's `@plunk/ui` Popover + Command primitives. + * + * It is intentionally controlled and value-agnostic: the parent decides what a + * selection means (the templates page maps it straight onto the `?type=` query + * param so the server stays authoritative). Only meant for fixed-value columns — + * free text stays in the global search bar. + */ +export function DataTableFacetedFilter({ + title, + options, + selected, + onChange, + multiple = true, +}: DataTableFacetedFilterProps) { + const selectedSet = new Set(selected); + + const toggle = (value: string) => { + if (selectedSet.has(value)) { + onChange(selected.filter(v => v !== value)); + return; + } + onChange(multiple ? [...selected, value] : [value]); + }; + + return ( + + + + + + + + No options. + + {options.map(option => { + const isSelected = selectedSet.has(option.value); + const Icon = option.icon; + return ( + toggle(option.value)} className="cursor-pointer"> + + + {Icon ? + ); + })} + + {selectedSet.size > 0 && ( + <> + + + onChange([])} + className="cursor-pointer justify-center text-center text-sm text-neutral-600" + > + Clear filter + + + + )} + + + + + ); +} diff --git a/apps/web/src/components/data-table/DataTableViewOptions.tsx b/apps/web/src/components/data-table/DataTableViewOptions.tsx new file mode 100644 index 00000000..b09d4892 --- /dev/null +++ b/apps/web/src/components/data-table/DataTableViewOptions.tsx @@ -0,0 +1,67 @@ +import { + Button, + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@plunk/ui'; +import type {Table} from '@tanstack/react-table'; +import {Columns3} from 'lucide-react'; + +interface DataTableViewOptionsProps { + table: Table; + /** + * Column IDs pinned visible (their checkbox is disabled). A column declared + * with `enableHiding: false` is treated as locked even if not listed here. + */ + lockedColumnIds?: ReadonlyArray; +} + +/** + * "Columns" selector (shadcn `DataTableViewOptions` convention) built on + * Plunk's `@plunk/ui` DropdownMenu. Lists every leaf column with a checkbox, + * honouring each column's `enableHiding` flag and the optional `lockedColumnIds` + * (Name + Actions stay locked-visible on the templates table). Reads each + * column's display label from `columnDef.meta.label`, falling back to its id. + */ +export function DataTableViewOptions({table, lockedColumnIds = []}: DataTableViewOptionsProps) { + const lockedSet = new Set(lockedColumnIds); + const columns = table.getAllLeafColumns(); + + return ( + + + + + + Visible columns + + {columns.map(column => { + const id = column.id; + const locked = lockedSet.has(id) || !column.getCanHide(); + const label = (column.columnDef.meta as {label?: string} | undefined)?.label ?? id; + return ( + { + if (locked) return; + column.toggleVisibility(!!value); + }} + onSelect={e => e.preventDefault()} + className="capitalize" + > + {label} + + ); + })} + + + ); +} diff --git a/apps/web/src/components/data-table/DataTableViewSwitcher.tsx b/apps/web/src/components/data-table/DataTableViewSwitcher.tsx new file mode 100644 index 00000000..5d0382f6 --- /dev/null +++ b/apps/web/src/components/data-table/DataTableViewSwitcher.tsx @@ -0,0 +1,48 @@ +import {Button} from '@plunk/ui'; +import {LayoutGrid, List} from 'lucide-react'; + +export type DataTableView = 'card' | 'table'; + +export const isDataTableView = (value: string): value is DataTableView => value === 'card' || value === 'table'; + +interface DataTableViewSwitcherProps { + view: DataTableView; + onChange: (next: DataTableView) => void; +} + +/** + * Card / table view toggle (icon segmented control) built on Plunk's Button. + * Generic and stateless — the parent owns the value (persisted via + * `usePersistentState`) so any list page can drop this in. Card view is the + * default everywhere; this only switches the rendering, never the data. + */ +export function DataTableViewSwitcher({view, onChange}: DataTableViewSwitcherProps) { + return ( +
+ + +
+ ); +} diff --git a/apps/web/src/components/data-table/NoResultsState.tsx b/apps/web/src/components/data-table/NoResultsState.tsx new file mode 100644 index 00000000..ec0e679f --- /dev/null +++ b/apps/web/src/components/data-table/NoResultsState.tsx @@ -0,0 +1,39 @@ +import {Button, EmptyState} from '@plunk/ui'; +import {FilterX} from 'lucide-react'; +import type {LucideIcon} from 'lucide-react'; + +interface NoResultsStateProps { + /** Icon for the empty state — usually the same icon the page uses elsewhere. */ + icon?: LucideIcon; + /** Optional override for the noun shown in the default copy (e.g. "templates"). */ + itemNoun?: string; + /** Resets the page's search + all active facet/tag/type/status filters + pagination. */ + onClear: () => void; +} + +/** + * Distinct "no results match your filters" state for the list pages. Separate + * from the first-run "No X yet" empty state: this one only shows when the user + * HAS items but the current search/facet/tag filters match none, and it always + * offers a one-click "Clear filters" recovery so the user is never stuck behind + * a filter that vanished the table (and, in table view, its header facets). + */ +export function NoResultsState({icon = FilterX, itemNoun, onClear}: NoResultsStateProps) { + return ( + + + Clear filters + + } + /> + ); +} diff --git a/apps/web/src/components/data-table/index.ts b/apps/web/src/components/data-table/index.ts new file mode 100644 index 00000000..95474e4b --- /dev/null +++ b/apps/web/src/components/data-table/index.ts @@ -0,0 +1,7 @@ +export {BulkActionBar} from './BulkActionBar'; +export {DataTable, type DataTableColumnMeta} from './DataTable'; +export {DataTableColumnHeader} from './DataTableColumnHeader'; +export {DataTableFacetedFilter, type FacetedFilterOption} from './DataTableFacetedFilter'; +export {DataTableViewOptions} from './DataTableViewOptions'; +export {DataTableViewSwitcher, isDataTableView, type DataTableView} from './DataTableViewSwitcher'; +export {NoResultsState} from './NoResultsState'; diff --git a/apps/web/src/lib/hooks/useColumnVisibility.ts b/apps/web/src/lib/hooks/useColumnVisibility.ts new file mode 100644 index 00000000..24dfa41c --- /dev/null +++ b/apps/web/src/lib/hooks/useColumnVisibility.ts @@ -0,0 +1,47 @@ +import {useEffect, useState} from 'react'; +import type {VisibilityState} from '@tanstack/react-table'; + +/** + * Persist a tanstack `columnVisibility` state to localStorage under `storageKey`. + * + * Only string-keyed boolean entries that exist in `defaults` are merged, so a + * malformed or stale stored value can't leak unknown keys into the tanstack + * state. SSR-safe: reads happen inside an effect and the initial state is always + * `defaults` (so server and first client render match). + */ +export function useColumnVisibility(storageKey: string, defaults: VisibilityState) { + const [columnVisibility, setColumnVisibility] = useState(defaults); + + // Hydrate from localStorage on first mount (client only). + useEffect(() => { + if (typeof window === 'undefined') return; + try { + const raw = window.localStorage.getItem(storageKey); + if (!raw) return; + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== 'object') return; + const sanitized: VisibilityState = {...defaults}; + for (const [key, value] of Object.entries(parsed as Record)) { + if (typeof value === 'boolean' && key in defaults) { + sanitized[key] = value; + } + } + setColumnVisibility(sanitized); + } catch { + // Ignore malformed JSON — fall back to defaults. + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [storageKey]); + + // Persist whenever it changes. + useEffect(() => { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(storageKey, JSON.stringify(columnVisibility)); + } catch { + // Ignore quota / private-mode errors. + } + }, [storageKey, columnVisibility]); + + return [columnVisibility, setColumnVisibility] as const; +} diff --git a/apps/web/src/lib/hooks/usePersistentState.ts b/apps/web/src/lib/hooks/usePersistentState.ts new file mode 100644 index 00000000..e73e4d48 --- /dev/null +++ b/apps/web/src/lib/hooks/usePersistentState.ts @@ -0,0 +1,51 @@ +import {useCallback, useEffect, useState} from 'react'; + +/** + * A tiny `useState` wrapper that persists a small, validated value in + * localStorage under `storageKey`. + * + * Used by the card/table view switcher (`plunk:templates:view`) but kept + * generic so other list pages (workflows, campaigns) can reuse the same + * switcher with their own key. SSR-safe: the initial render always returns + * `defaultValue` and hydration happens inside an effect, so server and first + * client render agree. + * + * `validate` guards against stale / malformed stored values — only a value it + * returns `true` for is adopted from storage. + */ +export function usePersistentState( + storageKey: string, + defaultValue: T, + validate: (value: string) => value is T, +): readonly [T, (next: T) => void] { + const [value, setValue] = useState(defaultValue); + + useEffect(() => { + if (typeof window === 'undefined') return; + try { + const stored = window.localStorage.getItem(storageKey); + if (stored !== null && validate(stored)) { + setValue(stored); + } + } catch { + // Ignore read errors (private mode etc.) — keep the default. + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [storageKey]); + + const set = useCallback( + (next: T) => { + setValue(next); + if (typeof window !== 'undefined') { + try { + window.localStorage.setItem(storageKey, next); + } catch { + // Ignore quota / private-mode errors. + } + } + }, + [storageKey], + ); + + return [value, set] as const; +} diff --git a/apps/web/src/lib/hooks/useShiftClickSelection.ts b/apps/web/src/lib/hooks/useShiftClickSelection.ts new file mode 100644 index 00000000..4ba1894d --- /dev/null +++ b/apps/web/src/lib/hooks/useShiftClickSelection.ts @@ -0,0 +1,65 @@ +import {useCallback, useRef} from 'react'; +import type {Row, RowSelectionState, Table} from '@tanstack/react-table'; + +interface ShiftClickHandlers { + /** + * Attach to the selection control's `onClick` to capture the shift-key state + * of the click. Pair with `onCheckedChange` below. + */ + onClick: (event: React.MouseEvent) => void; + /** + * Attach to the selection control's `onCheckedChange`. On a plain click this + * toggles the single row; when the preceding click had `shiftKey` pressed AND + * a prior anchor row exists, every row between the anchor and this row is set + * to the new checked state. + */ + onCheckedChange: (row: Row, value: boolean | 'indeterminate') => void; +} + +/** + * Range-selection helper for tanstack tables driven by a checkbox cell. + * + * Tracks the most-recently-clicked row as an anchor and, on a shift-click, + * selects/deselects every row between the anchor and the current row using + * `table.setRowSelection`. + * + * The anchor updates on every click (shift or not) so a user can chain + * selections naturally: click row 1, shift-click row 10 (selects 1–10), + * then shift-click row 5 (the new anchor is row 10, so rows 5–10 are set to + * the checked state of row 5's click). + */ +export function useShiftClickSelection(table: Table): ShiftClickHandlers { + const shiftHeldRef = useRef(false); + const anchorRowIdRef = useRef(null); + + const onClick = useCallback((event: React.MouseEvent) => { + shiftHeldRef.current = event.shiftKey; + }, []); + + const onCheckedChange = useCallback( + (row: Row, value: boolean | 'indeterminate') => { + const nextSelected = value === true; + const rows = table.getRowModel().rows; + const currentIndex = rows.findIndex(r => r.id === row.id); + const anchorIndex = anchorRowIdRef.current ? rows.findIndex(r => r.id === anchorRowIdRef.current) : -1; + + if (shiftHeldRef.current && anchorIndex !== -1 && currentIndex !== -1 && anchorIndex !== currentIndex) { + const [start, end] = anchorIndex < currentIndex ? [anchorIndex, currentIndex] : [currentIndex, anchorIndex]; + const updates: RowSelectionState = {}; + for (let i = start; i <= end; i++) { + const r = rows[i]; + if (r) updates[r.id] = nextSelected; + } + table.setRowSelection(prev => ({...prev, ...updates})); + } else { + row.toggleSelected(nextSelected); + } + + anchorRowIdRef.current = row.id; + shiftHeldRef.current = false; + }, + [table], + ); + + return {onClick, onCheckedChange}; +} diff --git a/apps/web/src/pages/campaigns/index.tsx b/apps/web/src/pages/campaigns/index.tsx index ef2c51fc..d879188c 100644 --- a/apps/web/src/pages/campaigns/index.tsx +++ b/apps/web/src/pages/campaigns/index.tsx @@ -3,46 +3,133 @@ import { Button, Card, CardContent, + Checkbox, ConfirmDialog, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + EmptyState, + IconSpinner, Input, } from '@plunk/ui'; import type {Campaign, Template} from '@plunk/db'; import {CampaignStatus} from '@plunk/db'; +import {CampaignSchemas} from '@plunk/shared'; import type {PaginatedResponse} from '@plunk/types'; -import {EmptyState} from '@plunk/ui'; +import { + getCoreRowModel, + useReactTable, + type ColumnDef, + type RowSelectionState, + type SortingState, + type VisibilityState, +} from '@tanstack/react-table'; import {DashboardLayout} from '../../components/DashboardLayout'; import {TemplateSelectionDialog} from '../../components/TemplateSelectionDialog'; import {CampaignSelectionDialog} from '../../components/CampaignSelectionDialog'; +import { + BulkActionBar, + DataTable, + DataTableColumnHeader, + DataTableFacetedFilter, + DataTableViewOptions, + DataTableViewSwitcher, + NoResultsState, + isDataTableView, + type DataTableColumnMeta, + type DataTableView, +} from '../../components/data-table'; +import {useShiftClickSelection} from '../../lib/hooks/useShiftClickSelection'; import {network} from '../../lib/network'; import {formatRelativeTime} from '../../lib/dateUtils'; import {Ban, Calendar, ChevronDown, Copy, Edit, FileText, Mail, Plus, RefreshCw, Search, Trash2, X} from 'lucide-react'; import {NextSeo} from 'next-seo'; import Link from 'next/link'; import {useRouter} from 'next/router'; -import {useEffect, useState} from 'react'; +import {useEffect, useMemo, useState} from 'react'; import {toast} from 'sonner'; import useSWR from 'swr'; import dayjs from 'dayjs'; +import {useColumnVisibility} from '../../lib/hooks/useColumnVisibility'; +import {usePersistentState} from '../../lib/hooks/usePersistentState'; + +type StatusFilter = 'ALL' | 'DRAFT' | 'SCHEDULED' | 'SENDING' | 'SENT' | 'CANCELLED'; + +const VIEW_STORAGE_KEY = 'plunk:campaigns:view'; +const COLUMNS_STORAGE_KEY = 'plunk:campaigns:columns'; + +// Name + Actions are locked-visible (see lockedColumnIds below). `select` is +// also locked. Everything starts visible. +const DEFAULT_COLUMN_VISIBILITY: VisibilityState = { + select: true, + name: true, + subject: true, + status: true, + recipients: true, + updatedAt: true, + actions: true, +}; + +// Fixed-value options for the Status column's faceted filter (table view) and +// the existing card-view pill row. Single source of truth for both. +const STATUS_OPTIONS: ReadonlyArray> = [ + 'DRAFT', + 'SCHEDULED', + 'SENDING', + 'SENT', + 'CANCELLED', +]; + +const statusBadgeConfig: Record = { + DRAFT: {label: 'Draft', variant: 'neutral'}, + SCHEDULED: {label: 'Scheduled', variant: 'default'}, + SENDING: {label: 'Sending', variant: 'default'}, + SENT: {label: 'Sent', variant: 'success'}, + CANCELLED: {label: 'Cancelled', variant: 'neutral'}, +}; + +const getStatusBadge = (status: CampaignStatus) => { + const {label, variant} = statusBadgeConfig[status]; + return ( + + {label} + + ); +}; export default function CampaignsPage() { const router = useRouter(); const [page, setPage] = useState(1); const [search, setSearch] = useState(''); const [searchInput, setSearchInput] = useState(''); - const [statusFilter, setStatusFilter] = useState<'ALL' | 'DRAFT' | 'SCHEDULED' | 'SENDING' | 'SENT' | 'CANCELLED'>('ALL'); + const [statusFilter, setStatusFilter] = useState('ALL'); const [showCancelDialog, setShowCancelDialog] = useState(false); const [campaignToCancel, setCampaignToCancel] = useState(null); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [campaignToDelete, setCampaignToDelete] = useState(null); + const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false); + const [bulkDeleteStatus, setBulkDeleteStatus] = useState<'idle' | 'loading'>('idle'); const [showTemplateDialog, setShowTemplateDialog] = useState(false); const [showCampaignDialog, setShowCampaignDialog] = useState(false); + const [view, setView] = usePersistentState(VIEW_STORAGE_KEY, 'card', isDataTableView); + + // Tanstack table state. + const [sorting, setSorting] = useState([]); + const [columnVisibility, setColumnVisibility] = useColumnVisibility(COLUMNS_STORAGE_KEY, DEFAULT_COLUMN_VISIBILITY); + // Row-selection state drives the BulkActionBar above the table. + const [rowSelection, setRowSelection] = useState({}); + + // Build the sort query string from tanstack state. The backend is + // authoritative (`?sort=&dir=asc|desc`); without those params it falls + // back to its default order. manualSorting is on, so the client only mirrors. + const sortParam = sorting[0]?.id ?? ''; + const dirParam = sorting[0] ? (sorting[0].desc ? 'desc' : 'asc') : ''; const {data, mutate, isLoading} = useSWR>( - `/campaigns?page=${page}&pageSize=20${search ? `&search=${encodeURIComponent(search)}` : ''}${statusFilter !== 'ALL' ? `&status=${statusFilter}` : ''}`, + `/campaigns?page=${page}&pageSize=20${search ? `&search=${encodeURIComponent(search)}` : ''}${ + statusFilter !== 'ALL' ? `&status=${statusFilter}` : '' + }${sortParam ? `&sort=${sortParam}&dir=${dirParam}` : ''}`, {revalidateOnFocus: false}, ); @@ -54,17 +141,13 @@ export default function CampaignsPage() { return () => clearTimeout(timer); }, [searchInput]); - const getStatusBadge = (status: CampaignStatus) => { - const config: Record = { - DRAFT: {label: 'Draft', variant: 'neutral'}, - SCHEDULED: {label: 'Scheduled', variant: 'default'}, - SENDING: {label: 'Sending', variant: 'default'}, - SENT: {label: 'Sent', variant: 'success'}, - CANCELLED: {label: 'Cancelled', variant: 'neutral'}, - }; - const {label, variant} = config[status]; - return {label}; - }; + // Clear row selection whenever the visible data set changes (page, search, + // status filter). Selections only make sense for currently-visible rows — + // keeping a stale selection across pagination would let the user bulk-delete + // campaigns they can no longer see. + useEffect(() => { + setRowSelection({}); + }, [page, search, statusFilter]); const handleCancel = async () => { if (!campaignToCancel) return; @@ -104,6 +187,32 @@ export default function CampaignsPage() { } }; + const selectedIds = useMemo(() => Object.keys(rowSelection).filter(id => rowSelection[id]), [rowSelection]); + + const handleBulkDelete = async () => { + if (selectedIds.length === 0) return; + setBulkDeleteStatus('loading'); + try { + const result = await network.fetch<{deleted?: number}, typeof CampaignSchemas.bulkUpdate>( + 'POST', + '/campaigns/bulk-update', + { + ids: selectedIds, + delete: true, + }, + ); + const count = result?.deleted ?? selectedIds.length; + toast.success(`${count} campaign${count === 1 ? '' : 's'} deleted`); + setRowSelection({}); + void mutate(); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to delete campaigns'); + } finally { + // ConfirmDialog closes itself after onConfirm resolves. + setBulkDeleteStatus('idle'); + } + }; + const handleSelectTemplate = ( template: Template, selectedFields: { @@ -191,6 +300,252 @@ export default function CampaignsPage() { }); }; + // Recipients/delivered summary mirroring what each card surfaces, condensed + // into a single cell appropriate per status. + const recipientSummary = (campaign: Campaign) => { + const deliveryPct = + campaign.totalRecipients > 0 ? (campaign.sentCount / campaign.totalRecipients) * 100 : 0; + const openRate = campaign.sentCount > 0 ? (campaign.openedCount / campaign.sentCount) * 100 : 0; + + switch (campaign.status) { + case 'SENT': + return ( + + {campaign.sentCount.toLocaleString()} + sent + · + {openRate.toFixed(1)}% + opens + + ); + case 'SENDING': + return ( + + {deliveryPct.toFixed(0)}% + delivered + + ); + default: + return ( + + {campaign.totalRecipients.toLocaleString()} + recipients + + ); + } + }; + + const columns = useMemo>>( + () => [ + { + id: 'select', + enableSorting: false, + enableHiding: false, // Selection column is locked-visible. + meta: {label: 'Select', headClassName: 'w-10', cellClassName: 'w-10'} satisfies DataTableColumnMeta, + header: ({table}) => ( + table.toggleAllPageRowsSelected(!!value)} + /> + ), + cell: ({row}) => ( + { + e.stopPropagation(); + shiftSelect.onClick(e); + }} + onCheckedChange={value => shiftSelect.onCheckedChange(row, value)} + /> + ), + }, + { + id: 'name', + accessorKey: 'name', + enableHiding: false, // Name column is locked-visible. + meta: {label: 'Name'} satisfies DataTableColumnMeta, + header: ({column}) => Name, + cell: ({row}) => ( + + {row.original.name} + + ), + }, + { + id: 'subject', + accessorKey: 'subject', + enableSorting: false, // No backend sort field for subject. + meta: {label: 'Subject', cellClassName: 'max-w-xs'} satisfies DataTableColumnMeta, + header: ({column}) => Subject, + cell: ({row}) => ( +

+ {row.original.subject} +

+ ), + }, + { + id: 'status', + accessorKey: 'status', + enableSorting: false, // Status is faceted-filtered, not sorted. + meta: {label: 'Status'} satisfies DataTableColumnMeta, + header: ({column}) => ( + ({value: s, label: statusBadgeConfig[s].label}))} + selected={statusFilter === 'ALL' ? [] : [statusFilter]} + onChange={next => { + setStatusFilter((next[0] as StatusFilter) ?? 'ALL'); + setPage(1); + }} + /> + } + > + Status + + ), + cell: ({row}) => getStatusBadge(row.original.status), + }, + { + id: 'recipients', + enableSorting: false, // No backend sort field for computed counts. + meta: {label: 'Recipients'} satisfies DataTableColumnMeta, + header: ({column}) => Recipients, + cell: ({row}) => recipientSummary(row.original), + }, + { + id: 'updatedAt', + accessorKey: 'updatedAt', + // ISO-string values sort ascending on first click by default; flip so + // the first click on "Updated" surfaces the most recently edited rows. + sortDescFirst: true, + meta: {label: 'Updated'} satisfies DataTableColumnMeta, + header: ({column}) => Updated, + cell: ({row}) => ( +
+ {formatRelativeTime(row.original.updatedAt)} +
+ {dayjs(row.original.updatedAt).format('DD MMMM YYYY, hh:mm')} +
+
+ ), + }, + { + id: 'actions', + enableSorting: false, + enableHiding: false, // Actions column is locked-visible. + meta: {label: 'Actions', headClassName: 'text-right', cellClassName: 'text-right'} satisfies DataTableColumnMeta, + header: () => Actions, + cell: ({row}) => ( +
+ + + {row.original.status === 'DRAFT' && ( + + )} + {(row.original.status === 'SCHEDULED' || row.original.status === 'SENDING') && ( + + )} +
+ ), + }, + ], + // Re-creating columns on every render is cheap and avoids stale-closure bugs + // for the statusFilter-driven facet and cancel/delete/duplicate handlers. + // eslint-disable-next-line react-hooks/exhaustive-deps + [statusFilter], + ); + + const table = useReactTable({ + data: data?.data ?? [], + columns, + state: {sorting, columnVisibility, rowSelection}, + onSortingChange: setSorting, + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + enableRowSelection: true, + enableMultiSort: false, + manualSorting: true, // Backend handles sorting; client just exposes ?sort=&dir=. + getCoreRowModel: getCoreRowModel(), + getRowId: row => row.id, + }); + + // Range (shift-click) selection for the checkbox column. + const shiftSelect = useShiftClickSelection(table); + + const hasData = data && data.data.length > 0; + + // Whether any search/facet filter is currently narrowing the list. Drives the + // "no results vs first-run empty" distinction below. + const hasActiveFilters = search !== '' || statusFilter !== 'ALL'; + + // Reset everything that can hide rows (search + status + pagination) so the + // user can recover from a filter combination that matched nothing. + const clearFilters = () => { + setSearchInput(''); + setSearch(''); + setStatusFilter('ALL'); + setPage(1); + }; + return ( <> @@ -251,7 +606,12 @@ export default function CampaignsPage() { - {/* Search & Filters */} + {/* Control row. + - Search input: always present (both views). + - Status filter pills: CARD VIEW ONLY (unchanged from before). In + table view the Status filter lives in the column header facet, so + the pills are not rendered. + - Columns selector (table view) + view switcher round out the row. */}
@@ -277,248 +637,312 @@ export default function CampaignsPage() { )}
-
- {(['ALL', 'DRAFT', 'SCHEDULED', 'SENDING', 'SENT', 'CANCELLED'] as const).map(status => ( - - ))} -
+ {view === 'card' && ( +
+ {(['ALL', ...STATUS_OPTIONS] as const).map(status => ( + + ))} +
+ )} + {view === 'table' && ( +
+ +
+ )} +
- {/* Campaigns List */} + {/* Bulk action bar — table view only (the selection column lives + there). Wires the delete action; the children slot stays open for + future bulk operations. */} + {view === 'table' && ( + setRowSelection({})}> + + + )} + + {/* Campaigns */}
- {isLoading && ( + {isLoading ? ( - Loading campaigns... + +
+ +
+
- )} - - {!isLoading && data?.data.length === 0 && ( + ) : !hasData ? ( - - - - - - - - -
- Empty Campaign - - Start from scratch with a blank canvas - + {hasActiveFilters ? ( + // Items exist, but the active search/status filters matched + // none — offer a one-click recovery. + + ) : ( + // Genuinely empty project — first-run state. + + + + + + + + +
+ Empty Campaign + + Start from scratch with a blank canvas + +
+ +
+ setShowTemplateDialog(true)} className="py-3 cursor-pointer"> +
+ +
+ From Template + + Use an existing template as a starting point + +
- -
- setShowTemplateDialog(true)} className="py-3 cursor-pointer"> -
- -
- From Template - - Use an existing template as a starting point - + + setShowCampaignDialog(true)} className="py-3 cursor-pointer"> +
+ +
+ From Previous Campaign + + Copy content and settings from an existing campaign + +
-
- - setShowCampaignDialog(true)} className="py-3 cursor-pointer"> -
- -
- From Previous Campaign - - Copy content and settings from an existing campaign - -
-
-
- - - } - /> + + + + } + /> + )} - )} + ) : view === 'card' ? ( + <> + {/* Card List View — unchanged from before. */} + {data?.data.map(campaign => { + const openRate = campaign.sentCount > 0 ? (campaign.openedCount / campaign.sentCount) * 100 : 0; + const clickRate = campaign.sentCount > 0 ? (campaign.clickedCount / campaign.sentCount) * 100 : 0; + const deliveryPct = + campaign.totalRecipients > 0 ? (campaign.sentCount / campaign.totalRecipients) * 100 : 0; - {data?.data.map(campaign => { - const openRate = campaign.sentCount > 0 ? (campaign.openedCount / campaign.sentCount) * 100 : 0; - const clickRate = campaign.sentCount > 0 ? (campaign.clickedCount / campaign.sentCount) * 100 : 0; - const deliveryPct = campaign.totalRecipients > 0 ? (campaign.sentCount / campaign.totalRecipients) * 100 : 0; - - return ( - - -
-

{campaign.name}

- {getStatusBadge(campaign.status)} -
+ return ( + + +
+

{campaign.name}

+ {getStatusBadge(campaign.status)} +
-
- {campaign.status === 'DRAFT' && ( - <> - - {campaign.totalRecipients.toLocaleString()} - estimated recipients - - - )} - {campaign.status === 'SCHEDULED' && ( - <> - - {campaign.totalRecipients.toLocaleString()} - recipients - - {campaign.scheduledFor && ( +
+ {campaign.status === 'DRAFT' && ( <> + + {campaign.totalRecipients.toLocaleString()} + estimated recipients + + + )} + {campaign.status === 'SCHEDULED' && ( + <> + + {campaign.totalRecipients.toLocaleString()} + recipients + + {campaign.scheduledFor && ( + <> + + + Sending {dayjs(campaign.scheduledFor).format('MMM D, YYYY [at] h:mm A')} + + + )} + + )} + {campaign.status === 'SENDING' && ( + <> + + {deliveryPct.toFixed(0)}% + delivered + - - Sending {dayjs(campaign.scheduledFor).format('MMM D, YYYY [at] h:mm A')} + + {openRate.toFixed(1)}% + opens )} - - )} - {campaign.status === 'SENDING' && ( - <> - - {deliveryPct.toFixed(0)}% - delivered - - - - {openRate.toFixed(1)}% - opens - - - )} - {campaign.status === 'SENT' && ( - <> - - {campaign.sentCount.toLocaleString()} - sent - - - - {openRate.toFixed(1)}% - opens - - {clickRate > 0 && ( + {campaign.status === 'SENT' && ( <> + + {campaign.sentCount.toLocaleString()} + sent + - {clickRate.toFixed(1)}% - clicks + {openRate.toFixed(1)}% + opens + {clickRate > 0 && ( + <> + + + {clickRate.toFixed(1)}% + clicks + + + )} )} - - )} - {campaign.status === 'CANCELLED' && ( - - {campaign.totalRecipients.toLocaleString()} - recipients - - )} -
- + {campaign.status === 'CANCELLED' && ( + + {campaign.totalRecipients.toLocaleString()} + recipients + + )} +
+ -
-
- -
- Updated {formatRelativeTime(campaign.updatedAt)} -
- {dayjs(campaign.updatedAt).format('DD MMMM YYYY, hh:mm')} +
+
+ +
+ Updated {formatRelativeTime(campaign.updatedAt)} +
+ {dayjs(campaign.updatedAt).format('DD MMMM YYYY, hh:mm')} +
+
+
+
+ + + {campaign.status === 'DRAFT' && ( + + )} + {(campaign.status === 'SCHEDULED' || campaign.status === 'SENDING') && ( + + )}
-
-
- - - {campaign.status === 'DRAFT' && ( - - )} - {(campaign.status === 'SCHEDULED' || campaign.status === 'SENDING') && ( - - )} -
+ + ); + })} + + {/* Pagination */} + {data && data.totalPages > 1 && ( +
+ + + Page {page} of {data.totalPages} + +
+ )} + + ) : ( + <> + {/* Table View (tanstack-driven) */} + + + + - ); - })} -
- {/* Pagination */} - {data && data.totalPages > 1 && ( -
- - - Page {page} of {data.totalPages} - - -
- )} + {/* Pagination */} + {data && data.totalPages > 1 && ( +
+ + + Page {page} of {data.totalPages} + + +
+ )} + + )} +
+ + ('ALL'); + const [typeFilter, setTypeFilter] = useState('ALL'); + const [view, setView] = usePersistentState(VIEW_STORAGE_KEY, 'card', isDataTableView); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [templateToDelete, setTemplateToDelete] = useState(null); + const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false); + const [bulkDeleteStatus, setBulkDeleteStatus] = useState<'idle' | 'loading'>('idle'); + + // Tanstack table state. + const [sorting, setSorting] = useState([]); + const [columnVisibility, setColumnVisibility] = useColumnVisibility(COLUMNS_STORAGE_KEY, DEFAULT_COLUMN_VISIBILITY); + // Row-selection state drives the BulkActionBar above the table. + const [rowSelection, setRowSelection] = useState({}); + + // Build the sort query string from tanstack state. The backend is + // authoritative (`?sort=&dir=asc|desc`); without those params it falls + // back to its default order. manualSorting is on, so the client only mirrors. + const sortParam = sorting[0]?.id ?? ''; + const dirParam = sorting[0] ? (sorting[0].desc ? 'desc' : 'asc') : ''; const {data, mutate, isLoading} = useSWR>( - `/templates?page=${page}&pageSize=20${search ? `&search=${search}` : ''}${typeFilter !== 'ALL' ? `&type=${typeFilter}` : ''}`, + `/templates?page=${page}&pageSize=20${search ? `&search=${encodeURIComponent(search)}` : ''}${ + typeFilter !== 'ALL' ? `&type=${typeFilter}` : '' + }${sortParam ? `&sort=${sortParam}&dir=${dirParam}` : ''}`, {revalidateOnFocus: false}, ); @@ -42,6 +104,14 @@ export default function TemplatesPage() { return () => clearTimeout(timer); }, [searchInput]); + // Clear row selection whenever the visible data set changes (page, search, + // type filter). Selections only make sense for currently-visible rows — + // keeping a stale selection across pagination would let the user bulk-delete + // templates they can no longer see. + useEffect(() => { + setRowSelection({}); + }, [page, search, typeFilter]); + const handleDelete = async () => { if (!templateToDelete) return; @@ -66,6 +136,216 @@ export default function TemplatesPage() { } }; + const selectedIds = useMemo(() => Object.keys(rowSelection).filter(id => rowSelection[id]), [rowSelection]); + + const handleBulkDelete = async () => { + if (selectedIds.length === 0) return; + setBulkDeleteStatus('loading'); + try { + const result = await network.fetch<{deleted?: number}, typeof TemplateSchemas.bulkUpdate>( + 'POST', + '/templates/bulk-update', + { + ids: selectedIds, + delete: true, + }, + ); + const count = result?.deleted ?? selectedIds.length; + toast.success(`${count} template${count === 1 ? '' : 's'} deleted`); + setRowSelection({}); + void mutate(); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to delete templates'); + } finally { + // ConfirmDialog closes itself after onConfirm resolves. + setBulkDeleteStatus('idle'); + } + }; + + const columns = useMemo>>( + () => [ + { + id: 'select', + enableSorting: false, + enableHiding: false, // Selection column is locked-visible. + meta: {label: 'Select', headClassName: 'w-10', cellClassName: 'w-10'} satisfies DataTableColumnMeta, + header: ({table}) => ( + table.toggleAllPageRowsSelected(!!value)} + /> + ), + cell: ({row}) => ( + { + e.stopPropagation(); + shiftSelect.onClick(e); + }} + onCheckedChange={value => shiftSelect.onCheckedChange(row, value)} + /> + ), + }, + { + id: 'name', + accessorKey: 'name', + enableHiding: false, // Name column is locked-visible. + meta: {label: 'Name'} satisfies DataTableColumnMeta, + header: ({column}) => Name, + cell: ({row}) => ( + + {row.original.name} + + ), + }, + { + id: 'type', + accessorKey: 'type', + enableSorting: false, // Type is faceted-filtered, not sorted. + meta: {label: 'Type'} satisfies DataTableColumnMeta, + header: ({column}) => ( + ({value: t, label: t.toLowerCase()}))} + selected={typeFilter === 'ALL' ? [] : [typeFilter]} + onChange={next => { + setTypeFilter((next[0] as TypeFilter) ?? 'ALL'); + setPage(1); + }} + /> + } + > + Type + + ), + cell: ({row}) => ( + + {row.original.type.toLowerCase()} + + ), + }, + { + id: 'subject', + accessorKey: 'subject', + enableSorting: false, // No backend sort field for subject. + meta: {label: 'Subject', cellClassName: 'max-w-xs'} satisfies DataTableColumnMeta, + header: ({column}) => Subject, + cell: ({row}) => ( +

+ {row.original.subject} +

+ ), + }, + { + id: 'updatedAt', + accessorKey: 'updatedAt', + // ISO-string values sort ascending on first click by default; flip so + // the first click on "Updated" surfaces the most recently edited rows. + sortDescFirst: true, + meta: {label: 'Updated'} satisfies DataTableColumnMeta, + header: ({column}) => Updated, + cell: ({row}) => ( +
+ {formatRelativeTime(row.original.updatedAt)} +
+ {dayjs(row.original.updatedAt).format('DD MMMM YYYY, hh:mm')} +
+
+ ), + }, + { + id: 'actions', + enableSorting: false, + enableHiding: false, // Actions column is locked-visible. + meta: {label: 'Actions', headClassName: 'text-right', cellClassName: 'text-right'} satisfies DataTableColumnMeta, + header: () => Actions, + cell: ({row}) => ( +
+ + + +
+ ), + }, + ], + // Re-creating columns on every render is cheap and avoids stale-closure bugs + // for the typeFilter-driven facet and delete/duplicate handlers. + // eslint-disable-next-line react-hooks/exhaustive-deps + [typeFilter], + ); + + const table = useReactTable