From 9e641dc0c553d701ee992f03bc3e2f1b42becde6 Mon Sep 17 00:00:00 2001 From: D-K-P <8297864+D-K-P@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:08:02 +0100 Subject: [PATCH 1/6] feat(database): add isDraft flag to PlatformNotification Backs staging a notification without a schedule. The column defaults to false, so existing rows are unaffected. --- .../migration.sql | 2 ++ internal-packages/database/prisma/schema.prisma | 5 +++++ 2 files changed, 7 insertions(+) create mode 100644 internal-packages/database/prisma/migrations/20260817000000_add_platform_notification_is_draft/migration.sql diff --git a/internal-packages/database/prisma/migrations/20260817000000_add_platform_notification_is_draft/migration.sql b/internal-packages/database/prisma/migrations/20260817000000_add_platform_notification_is_draft/migration.sql new file mode 100644 index 00000000000..0c45d9e74eb --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260817000000_add_platform_notification_is_draft/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "PlatformNotification" ADD COLUMN IF NOT EXISTS "isDraft" BOOLEAN NOT NULL DEFAULT false; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 3803e74c7f0..28b578e22d0 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -3081,6 +3081,11 @@ model PlatformNotification { /// Ordering within same scope level (higher = more important) priority Int @default(0) + /// Staged-but-unpublished. While true the notification is hidden from all + /// user-facing reads regardless of startsAt/endsAt; publishing sets real + /// dates and flips this to false. + isDraft Boolean @default(false) + /// Soft delete archivedAt DateTime? From 846e0a73d2892aff28370d29b2d307c935e4c908 Mon Sep 17 00:00:00 2001 From: D-K-P <8297864+D-K-P@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:08:07 +0100 Subject: [PATCH 2/6] feat(webapp): draft platform notifications, hidden until published Adds create, update, and publish paths for draft notifications and gates every user-facing read (webapp panel, CLI, changelogs) on the draft flag, so a draft never leaks to users regardless of its dates. Publishing sets the real start and end dates and clears the flag. --- .../services/platformNotificationSchemas.ts | 69 +++++- .../services/platformNotifications.server.ts | 221 ++++++++++++++---- .../webapp/test/platformNotifications.test.ts | 173 +++++++++++++- 3 files changed, 415 insertions(+), 48 deletions(-) diff --git a/apps/webapp/app/services/platformNotificationSchemas.ts b/apps/webapp/app/services/platformNotificationSchemas.ts index a91521e07a5..90794ea53fd 100644 --- a/apps/webapp/app/services/platformNotificationSchemas.ts +++ b/apps/webapp/app/services/platformNotificationSchemas.ts @@ -75,7 +75,9 @@ const SCOPE_REQUIRED_FK: Record new Date(s)), priority: z.number().int().default(0), cliMaxDaysAfterFirstSeen: z.number().int().positive().optional(), cliMaxShowCount: z.number().int().positive().optional(), cliShowEvery: z.number().int().min(2).optional(), }; +const NotificationBaseFields = { + ...NotificationContentFields, + endsAt: z + .string() + .datetime() + .transform((s) => new Date(s)), +}; + export const CreatePlatformNotificationSchema = z .object({ ...NotificationBaseFields, @@ -210,6 +216,59 @@ function validateEndsAt(data: { startsAt?: Date; endsAt: Date }, ctx: z.Refineme export type CreatePlatformNotificationInput = z.input; +// A draft has no schedule yet: startsAt/endsAt are collected at publish time. +export const CreateDraftPlatformNotificationSchema = z + .object({ + ...NotificationContentFields, + }) + .superRefine((data, ctx) => { + validateScopeForeignKeys(data, ctx); + validateSurfaceFields(data, ctx); + validatePayloadTypeForSurface(data, ctx); + }); + +export type CreateDraftPlatformNotificationInput = z.input< + typeof CreateDraftPlatformNotificationSchema +>; + +// Editing a draft keeps it a draft — content changes only, still no schedule. +export const UpdateDraftPlatformNotificationSchema = z + .object({ + ...NotificationContentFields, + id: z.string().min(1), + }) + .superRefine((data, ctx) => { + validateScopeForeignKeys(data, ctx); + validateSurfaceFields(data, ctx); + validatePayloadTypeForSurface(data, ctx); + }); + +export type UpdateDraftPlatformNotificationInput = z.input< + typeof UpdateDraftPlatformNotificationSchema +>; + +// Publishing a draft is where the schedule finally becomes required and validated. +export const PublishDraftPlatformNotificationSchema = z + .object({ + id: z.string().min(1), + startsAt: z + .string() + .datetime() + .transform((s) => new Date(s)), + endsAt: z + .string() + .datetime() + .transform((s) => new Date(s)), + }) + .superRefine((data, ctx) => { + validateStartsAt(data, ctx); + validateEndsAt(data, ctx); + }); + +export type PublishDraftPlatformNotificationInput = z.input< + typeof PublishDraftPlatformNotificationSchema +>; + export const UpdatePlatformNotificationSchema = z .object({ ...NotificationBaseFields, diff --git a/apps/webapp/app/services/platformNotifications.server.ts b/apps/webapp/app/services/platformNotifications.server.ts index 24e95740a70..e869e1d1ff0 100644 --- a/apps/webapp/app/services/platformNotifications.server.ts +++ b/apps/webapp/app/services/platformNotifications.server.ts @@ -4,22 +4,38 @@ import { prisma } from "~/db.server"; import { type PlatformNotificationScope, type PlatformNotificationSurface, + type PrismaClientOrTransaction, } from "@trigger.dev/database"; import { incrementCliRequestCounter } from "./platformNotificationCounter.server"; import { + CreateDraftPlatformNotificationSchema, + type CreateDraftPlatformNotificationInput, CreatePlatformNotificationSchema, type CreatePlatformNotificationInput, type PayloadV1, PayloadV1Schema, + PublishDraftPlatformNotificationSchema, + type PublishDraftPlatformNotificationInput, + UpdateDraftPlatformNotificationSchema, + type UpdateDraftPlatformNotificationInput, UpdatePlatformNotificationSchema, } from "./platformNotificationSchemas"; import { isCliVersionEligible } from "./platformNotificationVersionTargeting"; export { + CreateDraftPlatformNotificationSchema, CreatePlatformNotificationSchema, + PublishDraftPlatformNotificationSchema, + UpdateDraftPlatformNotificationSchema, UpdatePlatformNotificationSchema, } from "./platformNotificationSchemas"; -export type { CreatePlatformNotificationInput, PayloadV1 } from "./platformNotificationSchemas"; +export type { + CreateDraftPlatformNotificationInput, + CreatePlatformNotificationInput, + PayloadV1, + PublishDraftPlatformNotificationInput, + UpdateDraftPlatformNotificationInput, +} from "./platformNotificationSchemas"; export type PlatformNotificationWithPayload = { id: string; @@ -80,6 +96,7 @@ export async function getAdminNotificationsList({ priority: n.priority, startsAt: n.startsAt, endsAt: n.endsAt, + isDraft: n.isDraft, archivedAt: n.archivedAt, createdAt: n.createdAt, payload: n.payload, @@ -115,21 +132,25 @@ export async function getAdminNotificationsList({ // --- Read: active notifications for webapp --- -export async function getActivePlatformNotifications({ - userId, - organizationId, - projectId, -}: { - userId: string; - organizationId: string; - projectId?: string; -}) { +export async function getActivePlatformNotifications( + { + userId, + organizationId, + projectId, + }: { + userId: string; + organizationId: string; + projectId?: string; + }, + db: PrismaClientOrTransaction = prisma +) { const now = new Date(); - const notifications = await prisma.platformNotification.findMany({ + const notifications = await db.platformNotification.findMany({ where: { surface: "WEBAPP", archivedAt: null, + isDraft: false, startsAt: { lte: now }, endsAt: { gt: now }, AND: [ @@ -299,25 +320,30 @@ export async function verifyOrgMembership({ // --- Read: recent changelogs (for Help & Feedback) --- -export async function getRecentChangelogs({ - userId, - organizationId, - projectId, - limit = 2, -}: { - userId: string; - organizationId?: string; - projectId?: string; - limit?: number; -}) { +export async function getRecentChangelogs( + { + userId, + organizationId, + projectId, + limit = 2, + }: { + userId: string; + organizationId?: string; + projectId?: string; + limit?: number; + }, + db: PrismaClientOrTransaction = prisma +) { // NOTE: Intentionally not filtering by archivedAt or endsAt. // We want to show archived and expired changelogs in the "What's new" section // so users can still find recent release notes. - // We DO filter by scope (to prevent user-scoped changelogs leaking to others) - // and by startsAt (to hide changelogs scheduled for the future). - const notifications = await prisma.platformNotification.findMany({ + // We DO filter by scope (to prevent user-scoped changelogs leaking to others), + // by startsAt (to hide changelogs scheduled for the future), and by isDraft + // (drafts have no real schedule and must never surface to users). + const notifications = await db.platformNotification.findMany({ where: { surface: "WEBAPP", + isDraft: false, payload: { path: ["data", "type"], equals: "changelog" }, startsAt: { lte: new Date() }, OR: [ @@ -353,7 +379,8 @@ function isCliNotificationExpired( id: string; cliMaxDaysAfterFirstSeen: number | null; cliMaxShowCount: number | null; - } + }, + db: PrismaClientOrTransaction = prisma ): boolean { if (!interaction) return false; @@ -377,7 +404,7 @@ function isCliNotificationExpired( // For time-based expiration, persist the dismiss on the next request // (showCount-based dismissal is handled inline at display time) if (expired && !interaction.cliDismissedAt) { - void prisma.platformNotificationInteraction.update({ + void db.platformNotificationInteraction.update({ where: { notificationId_userId: { notificationId: notification.id, @@ -391,15 +418,18 @@ function isCliNotificationExpired( return expired; } -export async function getNextCliNotification({ - userId, - projectRef, - cliVersion, -}: { - userId: string; - projectRef?: string; - cliVersion?: string; -}): Promise<{ +export async function getNextCliNotification( + { + userId, + projectRef, + cliVersion, + }: { + userId: string; + projectRef?: string; + cliVersion?: string; + }, + db: PrismaClientOrTransaction = prisma +): Promise<{ id: string; payload: PayloadV1; showCount: number; @@ -412,7 +442,7 @@ export async function getNextCliNotification({ let projectId: string | undefined; if (projectRef) { - const project = await prisma.project.findFirst({ + const project = await db.project.findFirst({ where: { externalRef: projectRef, deletedAt: null, @@ -432,7 +462,7 @@ export async function getNextCliNotification({ // If no projectRef or project not found, get org from membership if (!organizationId) { - const membership = await prisma.orgMember.findFirst({ + const membership = await db.orgMember.findFirst({ where: { userId }, select: { organizationId: true }, }); @@ -454,10 +484,11 @@ export async function getNextCliNotification({ scopeFilter.push({ scope: "PROJECT", projectId }); } - const notifications = await prisma.platformNotification.findMany({ + const notifications = await db.platformNotification.findMany({ where: { surface: "CLI", archivedAt: null, + isDraft: false, startsAt: { lte: now }, endsAt: { gt: now }, AND: [{ OR: scopeFilter }], @@ -485,7 +516,7 @@ export async function getNextCliNotification({ const parsed = PayloadV1Schema.safeParse(n.payload); if (!parsed.success) continue; if (!isCliVersionEligible(parsed.data.data.minimumCliVersion, cliVersion)) continue; - if (isCliNotificationExpired(interaction, n)) continue; + if (isCliNotificationExpired(interaction, n, db)) continue; // Check cliShowEvery using the global request counter if (n.cliShowEvery !== null && requestCounter % n.cliShowEvery !== 0) { @@ -498,7 +529,7 @@ export async function getNextCliNotification({ const reachedMaxShows = n.cliMaxShowCount !== null && (interaction?.showCount ?? 0) + 1 >= n.cliMaxShowCount; - const updated = await prisma.platformNotificationInteraction.upsert({ + const updated = await db.platformNotificationInteraction.upsert({ where: { notificationId_userId: { notificationId: n.id, userId } }, update: { showCount: { increment: 1 }, @@ -604,6 +635,114 @@ export function updatePlatformNotification( ); } +export function createDraftPlatformNotification( + input: CreateDraftPlatformNotificationInput, + db: PrismaClientOrTransaction = prisma +): ResultAsync<{ id: string; friendlyId: string }, CreateError> { + const parseResult = CreateDraftPlatformNotificationSchema.safeParse(input); + + if (!parseResult.success) { + return errAsync({ type: "validation", issues: parseResult.error.issues }); + } + + const data = parseResult.data; + + // Drafts carry no real schedule. Store placeholder dates (ignored while + // isDraft is true) — publishing sets the real startsAt/endsAt. + const now = new Date(); + + return fromPromise( + db.platformNotification.create({ + data: { + title: data.title, + payload: data.payload, + surface: data.surface as PlatformNotificationSurface, + scope: data.scope as PlatformNotificationScope, + userId: data.userId, + organizationId: data.organizationId, + projectId: data.projectId, + startsAt: now, + endsAt: now, + priority: data.priority, + cliMaxDaysAfterFirstSeen: data.cliMaxDaysAfterFirstSeen, + cliMaxShowCount: data.cliMaxShowCount, + cliShowEvery: data.cliShowEvery, + isDraft: true, + }, + select: { id: true, friendlyId: true }, + }), + (e): CreateError => ({ + type: "db", + message: e instanceof Error ? e.message : String(e), + }) + ); +} + +export function updateDraftPlatformNotification( + input: UpdateDraftPlatformNotificationInput, + db: PrismaClientOrTransaction = prisma +): ResultAsync<{ id: string; friendlyId: string }, CreateError> { + const parseResult = UpdateDraftPlatformNotificationSchema.safeParse(input); + + if (!parseResult.success) { + return errAsync({ type: "validation", issues: parseResult.error.issues }); + } + + const data = parseResult.data; + + // Editing a draft touches content only; startsAt/endsAt/isDraft are left as-is + // so the notification stays an unscheduled draft until it is published. + return fromPromise( + db.platformNotification.update({ + where: { id: data.id }, + data: { + title: data.title, + payload: data.payload, + surface: data.surface as PlatformNotificationSurface, + scope: data.scope as PlatformNotificationScope, + userId: data.scope === "USER" ? data.userId : null, + organizationId: data.scope === "ORGANIZATION" ? data.organizationId : null, + projectId: data.scope === "PROJECT" ? data.projectId : null, + priority: data.priority, + cliMaxDaysAfterFirstSeen: + data.surface === "CLI" ? (data.cliMaxDaysAfterFirstSeen ?? null) : null, + cliMaxShowCount: data.surface === "CLI" ? (data.cliMaxShowCount ?? null) : null, + cliShowEvery: data.surface === "CLI" ? (data.cliShowEvery ?? null) : null, + }, + select: { id: true, friendlyId: true }, + }), + (e): CreateError => ({ + type: "db", + message: e instanceof Error ? e.message : String(e), + }) + ); +} + +export function publishDraftPlatformNotification( + input: PublishDraftPlatformNotificationInput, + db: PrismaClientOrTransaction = prisma +): ResultAsync<{ id: string; friendlyId: string }, CreateError> { + const parseResult = PublishDraftPlatformNotificationSchema.safeParse(input); + + if (!parseResult.success) { + return errAsync({ type: "validation", issues: parseResult.error.issues }); + } + + const data = parseResult.data; + + return fromPromise( + db.platformNotification.update({ + where: { id: data.id }, + data: { startsAt: data.startsAt, endsAt: data.endsAt, isDraft: false }, + select: { id: true, friendlyId: true }, + }), + (e): CreateError => ({ + type: "db", + message: e instanceof Error ? e.message : String(e), + }) + ); +} + export async function deletePlatformNotification(id: string): Promise { await prisma.platformNotification.delete({ where: { id } }); } diff --git a/apps/webapp/test/platformNotifications.test.ts b/apps/webapp/test/platformNotifications.test.ts index b7b4634339c..810342e74f9 100644 --- a/apps/webapp/test/platformNotifications.test.ts +++ b/apps/webapp/test/platformNotifications.test.ts @@ -1,7 +1,19 @@ -import { describe, expect, it } from "vitest"; -import { CreatePlatformNotificationSchema } from "~/services/platformNotificationSchemas"; +import { postgresTest } from "@internal/testcontainers"; +import { type Prisma, type PrismaClient } from "@trigger.dev/database"; +import { describe, expect, it, vi } from "vitest"; +import { + createDraftPlatformNotification, + CreatePlatformNotificationSchema, + getActivePlatformNotifications, + getNextCliNotification, + getRecentChangelogs, + publishDraftPlatformNotification, +} from "~/services/platformNotifications.server"; import { isCliVersionEligible } from "~/services/platformNotificationVersionTargeting"; +// Container provisioning on the first draft tests can exceed the 5s default. +vi.setConfig({ testTimeout: 60_000 }); + function createNotificationInput({ surface = "CLI", minimumCliVersion, @@ -98,3 +110,160 @@ describe("CLI notification version eligibility", () => { expect(isCliVersionEligible("4.5.7-beta.2", "4.5.7")).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// Drafts: a draft must never leak to users regardless of its (placeholder) +// dates — the `isDraft` gate is enforced in every user-facing read, and +// publishing sets real dates and flips the gate off. +// The DB is never mocked; every read runs against a real Postgres container. +// --------------------------------------------------------------------------- + +let seq = 0; +const suffix = () => `${Date.now()}_${seq++}`; + +const HOUR_MS = 60 * 60 * 1000; + +function webappCardPayload(title: string): Prisma.InputJsonValue { + return { version: "1", data: { type: "card", title, description: "body" } }; +} + +function changelogPayload(title: string): Prisma.InputJsonValue { + return { version: "1", data: { type: "changelog", title, description: "body" } }; +} + +function cliInfoPayload(title: string): Prisma.InputJsonValue { + return { version: "1", data: { type: "info", title, description: "body" } }; +} + +/** Seed a notification directly, so a draft can be given "active" dates and still be gated. */ +async function seedNotification( + prisma: PrismaClient, + overrides: { + surface: "WEBAPP" | "CLI"; + payload: Prisma.InputJsonValue; + isDraft: boolean; + startsAt?: Date; + endsAt?: Date; + } +) { + const now = new Date(); + return prisma.platformNotification.create({ + data: { + title: `admin_${suffix()}`, + payload: overrides.payload, + surface: overrides.surface, + scope: "GLOBAL", + startsAt: overrides.startsAt ?? new Date(now.getTime() - HOUR_MS), + endsAt: overrides.endsAt ?? new Date(now.getTime() + HOUR_MS), + isDraft: overrides.isDraft, + }, + select: { id: true, friendlyId: true }, + }); +} + +describe("platform notification drafts are hidden from users", () => { + postgresTest("getActivePlatformNotifications excludes drafts", async ({ prisma }) => { + const published = await seedNotification(prisma, { + surface: "WEBAPP", + payload: webappCardPayload("published"), + isDraft: false, + }); + // Draft with dates that WOULD make it active — proves the gate, not the schedule. + await seedNotification(prisma, { + surface: "WEBAPP", + payload: webappCardPayload("draft"), + isDraft: true, + }); + + const { notifications } = await getActivePlatformNotifications( + { userId: `usr_${suffix()}`, organizationId: `org_${suffix()}` }, + prisma + ); + + const ids = notifications.map((n) => n.id); + expect(ids).toContain(published.id); + expect(ids).toHaveLength(1); + }); + + postgresTest("getRecentChangelogs excludes drafts", async ({ prisma }) => { + const published = await seedNotification(prisma, { + surface: "WEBAPP", + payload: changelogPayload("published changelog"), + isDraft: false, + }); + await seedNotification(prisma, { + surface: "WEBAPP", + payload: changelogPayload("draft changelog"), + isDraft: true, + }); + + const changelogs = await getRecentChangelogs({ userId: `usr_${suffix()}` }, prisma); + + const ids = changelogs.map((c) => c.id); + expect(ids).toContain(published.id); + expect(ids).toHaveLength(1); + }); + + postgresTest("getNextCliNotification excludes drafts", async ({ prisma }) => { + // Real user required: the returned notification records an interaction (FK to User). + const user = await prisma.user.create({ + data: { email: `cli_${suffix()}@example.com`, authenticationMethod: "MAGIC_LINK" }, + }); + + const published = await seedNotification(prisma, { + surface: "CLI", + payload: cliInfoPayload("published cli"), + isDraft: false, + }); + await seedNotification(prisma, { + surface: "CLI", + payload: cliInfoPayload("draft cli"), + isDraft: true, + }); + + const next = await getNextCliNotification({ userId: user.id }, prisma); + + expect(next?.id).toBe(published.id); + }); + + postgresTest("publishing a draft flips isDraft and sets real dates", async ({ prisma }) => { + const created = await createDraftPlatformNotification( + { + title: "admin label", + payload: { version: "1", data: { type: "card", title: "to publish", description: "body" } }, + surface: "WEBAPP", + scope: "GLOBAL", + }, + prisma + ); + expect(created.isOk()).toBe(true); + const id = created._unsafeUnwrap().id; + + // Before publish: a draft, hidden from users. + const before = await getActivePlatformNotifications( + { userId: `usr_${suffix()}`, organizationId: `org_${suffix()}` }, + prisma + ); + expect(before.notifications.map((n) => n.id)).not.toContain(id); + + const startsAt = new Date(Date.now() - 60 * 1000); // just now, within the last hour + const endsAt = new Date(Date.now() + 24 * HOUR_MS); + const published = await publishDraftPlatformNotification( + { id, startsAt: startsAt.toISOString(), endsAt: endsAt.toISOString() }, + prisma + ); + expect(published.isOk()).toBe(true); + + const row = await prisma.platformNotification.findFirst({ where: { id } }); + expect(row?.isDraft).toBe(false); + expect(row?.startsAt.toISOString()).toBe(startsAt.toISOString()); + expect(row?.endsAt.toISOString()).toBe(endsAt.toISOString()); + + // After publish: now visible to users. + const after = await getActivePlatformNotifications( + { userId: `usr_${suffix()}`, organizationId: `org_${suffix()}` }, + prisma + ); + expect(after.notifications.map((n) => n.id)).toContain(id); + }); +}); From db89813179196504633c12589b270a02b62c3bbf Mon Sep 17 00:00:00 2001 From: D-K-P <8297864+D-K-P@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:08:11 +0100 Subject: [PATCH 3/6] feat(webapp): draft controls and edit previews in notifications admin Save a notification as a draft, then publish it later by entering start and end dates, with validation and inline errors. Drafts show a draft status and Edit, Publish, and Delete actions. The 'Send preview to me' test button now also appears when editing a notification, not just when creating one. --- .../platform-notification-drafts.md | 6 + .../webapp/app/routes/admin.notifications.tsx | 336 +++++++++++++++--- 2 files changed, 299 insertions(+), 43 deletions(-) create mode 100644 .server-changes/platform-notification-drafts.md diff --git a/.server-changes/platform-notification-drafts.md b/.server-changes/platform-notification-drafts.md new file mode 100644 index 00000000000..9d0c54b8e29 --- /dev/null +++ b/.server-changes/platform-notification-drafts.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Notifications can now be saved as a draft without a schedule and published later, when you set the start and end dates. diff --git a/apps/webapp/app/routes/admin.notifications.tsx b/apps/webapp/app/routes/admin.notifications.tsx index 8e9e4beb374..8d176042b11 100644 --- a/apps/webapp/app/routes/admin.notifications.tsx +++ b/apps/webapp/app/routes/admin.notifications.tsx @@ -43,10 +43,13 @@ import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashb import { logger } from "~/services/logger.server"; import { archivePlatformNotification, + createDraftPlatformNotification, createPlatformNotification, deletePlatformNotification, getAdminNotificationsList, + publishDraftPlatformNotification, publishNowPlatformNotification, + updateDraftPlatformNotification, updatePlatformNotification, } from "~/services/platformNotifications.server"; import { createSearchParams } from "~/utils/searchParams"; @@ -94,6 +97,10 @@ export const action = dashboardAction( return handleCreateAction(formData, userId, _action === "create-preview"); } + if (_action === "create-draft") { + return handleCreateDraftAction(formData); + } + if (_action === "archive") { return handleArchiveAction(formData); } @@ -106,10 +113,18 @@ export const action = dashboardAction( return handlePublishNowAction(formData); } + if (_action === "publish-draft") { + return handlePublishDraftAction(formData); + } + if (_action === "edit") { return handleEditAction(formData); } + if (_action === "edit-draft") { + return handleEditDraftAction(formData); + } + return typedjson({ error: "Unknown action" }, { status: 400 }); } ); @@ -209,9 +224,10 @@ async function handleCreateAction(formData: FormData, userId: string, isPreview: !fields.adminLabel || !fields.title || !fields.description || - !fields.endsAt || !fields.surface || - !fields.payloadType + !fields.payloadType || + // A preview synthesizes its own dates, so endsAt is only required for a real create. + (!isPreview && !fields.endsAt) ) { return typedjson({ error: "Missing required fields" }, { status: 400 }); } @@ -270,6 +286,57 @@ async function handleCreateAction(formData: FormData, userId: string, isPreview: return typedjson({ success: true, id: result.value.id }); } +async function handleCreateDraftAction(formData: FormData) { + const fields = parseNotificationFormData(formData); + + // Drafts don't need a schedule yet, so startsAt/endsAt are not required here. + if ( + !fields.adminLabel || + !fields.title || + !fields.description || + !fields.surface || + !fields.payloadType + ) { + return typedjson({ error: "Missing required fields" }, { status: 400 }); + } + + const result = await createDraftPlatformNotification({ + title: fields.adminLabel, + payload: buildPayloadInput(fields), + surface: fields.surface as "CLI" | "WEBAPP", + scope: fields.scope as "USER" | "PROJECT" | "ORGANIZATION" | "GLOBAL", + ...(fields.scope === "USER" && fields.scopeUserId ? { userId: fields.scopeUserId } : {}), + ...(fields.scope === "ORGANIZATION" && fields.scopeOrganizationId + ? { organizationId: fields.scopeOrganizationId } + : {}), + ...(fields.scope === "PROJECT" && fields.scopeProjectId + ? { projectId: fields.scopeProjectId } + : {}), + priority: fields.priority, + ...(fields.surface === "CLI" + ? { + cliMaxShowCount: fields.cliMaxShowCount, + cliMaxDaysAfterFirstSeen: fields.cliMaxDaysAfterFirstSeen, + cliShowEvery: fields.cliShowEvery, + } + : {}), + }); + + if (result.isErr()) { + const err = result.error; + if (err.type === "validation") { + return typedjson( + { error: err.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ") }, + { status: 400 } + ); + } + logger.error("Failed to create draft platform notification", { error: err }); + return typedjson({ error: "Something went wrong, please try again." }, { status: 500 }); + } + + return typedjson({ success: true, id: result.value.id }); +} + async function handleArchiveAction(formData: FormData) { const notificationId = formData.get("notificationId") as string; if (!notificationId) { @@ -324,6 +391,39 @@ async function handlePublishNowAction(formData: FormData) { } } +async function handlePublishDraftAction(formData: FormData) { + const notificationId = formData.get("notificationId") as string; + const startsAt = formData.get("startsAt") as string; + const endsAt = formData.get("endsAt") as string; + + if (!notificationId || !startsAt || !endsAt) { + return typedjson({ error: "Start and end dates are required to publish." }, { status: 400 }); + } + + const result = await publishDraftPlatformNotification({ + id: notificationId, + startsAt: new Date(startsAt + "Z").toISOString(), + endsAt: new Date(endsAt + "Z").toISOString(), + }); + + if (result.isErr()) { + const err = result.error; + if (err.type === "validation") { + return typedjson( + { error: err.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ") }, + { status: 400 } + ); + } + logger.error("Failed to publish draft platform notification", { error: err, notificationId }); + return typedjson( + { error: "Failed to publish notification, please try again." }, + { status: 500 } + ); + } + + return typedjson({ success: true, id: result.value.id }); +} + async function handleEditAction(formData: FormData) { const notificationId = formData.get("notificationId") as string; const fields = parseNotificationFormData(formData); @@ -381,6 +481,61 @@ async function handleEditAction(formData: FormData) { return typedjson({ success: true, id: result.value.id }); } +async function handleEditDraftAction(formData: FormData) { + const notificationId = formData.get("notificationId") as string; + const fields = parseNotificationFormData(formData); + + // Editing a draft keeps it a draft: dates are collected at publish time, so + // startsAt/endsAt are not required here. + if ( + !notificationId || + !fields.adminLabel || + !fields.title || + !fields.description || + !fields.surface || + !fields.payloadType + ) { + return typedjson({ error: "Missing required fields" }, { status: 400 }); + } + + const result = await updateDraftPlatformNotification({ + id: notificationId, + title: fields.adminLabel, + payload: buildPayloadInput(fields), + surface: fields.surface as "CLI" | "WEBAPP", + scope: fields.scope as "USER" | "PROJECT" | "ORGANIZATION" | "GLOBAL", + ...(fields.scope === "USER" && fields.scopeUserId ? { userId: fields.scopeUserId } : {}), + ...(fields.scope === "ORGANIZATION" && fields.scopeOrganizationId + ? { organizationId: fields.scopeOrganizationId } + : {}), + ...(fields.scope === "PROJECT" && fields.scopeProjectId + ? { projectId: fields.scopeProjectId } + : {}), + priority: fields.priority, + ...(fields.surface === "CLI" + ? { + cliMaxShowCount: fields.cliMaxShowCount, + cliMaxDaysAfterFirstSeen: fields.cliMaxDaysAfterFirstSeen, + cliShowEvery: fields.cliShowEvery, + } + : {}), + }); + + if (result.isErr()) { + const err = result.error; + if (err.type === "validation") { + return typedjson( + { error: err.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ") }, + { status: 400 } + ); + } + logger.error("Failed to update draft platform notification", { error: err }); + return typedjson({ error: "Something went wrong, please try again." }, { status: 500 }); + } + + return typedjson({ success: true, id: result.value.id }); +} + export default function AdminNotificationsRoute() { const { notifications, total, page, pageCount } = useTypedLoaderData(); const [showCreate, setShowCreate] = useState(false); @@ -505,15 +660,19 @@ export default function AdminNotificationsRoute() {
+ {status === "draft" && } {status === "pending" && } - {(status === "pending" || + {(status === "draft" || + status === "pending" || status === "releasing" || status === "active") && ( )} - {status !== "archived" && } + {status !== "archived" && status !== "draft" && ( + + )}
@@ -617,6 +776,74 @@ function PublishNowButton({ notificationId }: { notificationId: string }) { ); } +function PublishDraftButton({ notificationId }: { notificationId: string }) { + const [open, setOpen] = useState(false); + const fetcher = useFetcher<{ success?: boolean; error?: string }>(); + + useEffect(() => { + if (fetcher.data?.success) setOpen(false); + }, [fetcher.data]); + + return ( + <> + + + + + Publish notification + + + + + + Set the schedule for this notification. It becomes visible to users at the start time. + +
+
+ + +
+
+ + +
+
+ + {fetcher.data?.error && ( + {fetcher.data.error} + )} + + + +
+
+
+ + ); +} + function DeleteConfirmationButton({ notificationId }: { notificationId: string }) { const [open, setOpen] = useState(false); const fetcher = useFetcher(); @@ -655,6 +882,7 @@ function DeleteConfirmationButton({ notificationId }: { notificationId: string } type NotificationFormDefaults = { id?: string; + isDraft?: boolean; title?: string; surface?: string; scope?: string; @@ -721,15 +949,13 @@ function NotificationForm({ }, [fetcher.data, onClose]); const isEdit = mode === "edit"; + // Editing a draft stays a draft (dates are set at publish time), so it routes + // to a distinct action and hides the schedule fields. + const isDraftEdit = isEdit && !!n?.isDraft; return ( - {isEdit && ( - <> - - - - )} + {isEdit && } @@ -998,34 +1224,40 @@ function NotificationForm({ )} -
-
- - -
-
- - + {isDraftEdit ? ( + + This is a draft — you'll set the start and end dates when you publish it. + + ) : ( +
+
+ + +
+
+ + +
-
+ )} {surface === "CLI" && ( <> @@ -1149,7 +1381,7 @@ function NotificationForm({ {!isEdit && fetcher.data?.success && !fetcher.data.previewId && ( Created successfully )} - {!isEdit && fetcher.data?.previewId && ( + {fetcher.data?.previewId && ( Preview sent (ID: {fetcher.data.previewId}) @@ -1157,8 +1389,23 @@ function NotificationForm({
+ {isEdit ? ( - ) : ( @@ -1166,11 +1413,11 @@ function NotificationForm({
-
- - {isEdit ? ( + {isEdit ? ( + // "Save changes" is first in DOM so pressing Enter in a field saves + // (HTML implicit submission uses the first submit button); flex-row-reverse + // keeps the preview button on the left and the primary action on the right. +
- ) : ( - <> - - - - )} -
+ +
+ ) : ( +
+ + + +
+ )}
); diff --git a/apps/webapp/app/services/platformNotifications.server.ts b/apps/webapp/app/services/platformNotifications.server.ts index e869e1d1ff0..626b74edfc7 100644 --- a/apps/webapp/app/services/platformNotifications.server.ts +++ b/apps/webapp/app/services/platformNotifications.server.ts @@ -1,5 +1,5 @@ import type { z } from "zod"; -import { errAsync, fromPromise, type ResultAsync } from "neverthrow"; +import { errAsync, fromPromise, okAsync, type ResultAsync } from "neverthrow"; import { prisma } from "~/db.server"; import { type PlatformNotificationScope, @@ -48,19 +48,26 @@ export type PlatformNotificationWithPayload = { // --- Read: admin list with interaction stats --- -export async function getAdminNotificationsList({ - page = 1, - pageSize = 20, - hideInactive = false, -}: { - page?: number; - pageSize?: number; - hideInactive?: boolean; -}) { - const where = hideInactive ? { archivedAt: null, endsAt: { gt: new Date() } } : {}; +export async function getAdminNotificationsList( + { + page = 1, + pageSize = 20, + hideInactive = false, + }: { + page?: number; + pageSize?: number; + hideInactive?: boolean; + }, + db: PrismaClientOrTransaction = prisma +) { + // Drafts carry placeholder dates, so exempt them from the "inactive" (expired) + // filter: a draft is neither active nor expired and must stay visible to admins. + const where = hideInactive + ? { archivedAt: null, OR: [{ isDraft: true }, { endsAt: { gt: new Date() } }] } + : {}; const [notifications, total] = await Promise.all([ - prisma.platformNotification.findMany({ + db.platformNotification.findMany({ where, orderBy: [{ createdAt: "desc" }], skip: (page - 1) * pageSize, @@ -78,7 +85,7 @@ export async function getAdminNotificationsList({ }, }, }), - prisma.platformNotification.count({ where }), + db.platformNotification.count({ where }), ]); return { @@ -557,7 +564,10 @@ export async function getNextCliNotification( // --- Create and update: admin endpoint support --- -type CreateError = { type: "validation"; issues: z.ZodIssue[] } | { type: "db"; message: string }; +type CreateError = + | { type: "validation"; issues: z.ZodIssue[] } + | { type: "db"; message: string } + | { type: "conflict"; message: string }; export function createPlatformNotification( input: CreatePlatformNotificationInput @@ -681,7 +691,7 @@ export function createDraftPlatformNotification( export function updateDraftPlatformNotification( input: UpdateDraftPlatformNotificationInput, db: PrismaClientOrTransaction = prisma -): ResultAsync<{ id: string; friendlyId: string }, CreateError> { +): ResultAsync<{ id: string }, CreateError> { const parseResult = UpdateDraftPlatformNotificationSchema.safeParse(input); if (!parseResult.success) { @@ -692,9 +702,11 @@ export function updateDraftPlatformNotification( // Editing a draft touches content only; startsAt/endsAt/isDraft are left as-is // so the notification stays an unscheduled draft until it is published. + // `isDraft: true` in the predicate makes this a no-op against a non-draft row, + // so draft-only semantics can never be applied to an active/pending/archived one. return fromPromise( - db.platformNotification.update({ - where: { id: data.id }, + db.platformNotification.updateMany({ + where: { id: data.id, isDraft: true }, data: { title: data.title, payload: data.payload, @@ -709,19 +721,25 @@ export function updateDraftPlatformNotification( cliMaxShowCount: data.surface === "CLI" ? (data.cliMaxShowCount ?? null) : null, cliShowEvery: data.surface === "CLI" ? (data.cliShowEvery ?? null) : null, }, - select: { id: true, friendlyId: true }, }), (e): CreateError => ({ type: "db", message: e instanceof Error ? e.message : String(e), }) + ).andThen(({ count }) => + count === 0 + ? errAsync<{ id: string }, CreateError>({ + type: "conflict", + message: "Notification not found or is not a draft", + }) + : okAsync({ id: data.id }) ); } export function publishDraftPlatformNotification( input: PublishDraftPlatformNotificationInput, db: PrismaClientOrTransaction = prisma -): ResultAsync<{ id: string; friendlyId: string }, CreateError> { +): ResultAsync<{ id: string }, CreateError> { const parseResult = PublishDraftPlatformNotificationSchema.safeParse(input); if (!parseResult.success) { @@ -730,16 +748,25 @@ export function publishDraftPlatformNotification( const data = parseResult.data; + // `isDraft: true` in the predicate ensures we only publish an actual draft: + // a request naming a non-draft id updates zero rows and reports a conflict + // rather than resetting a live notification's schedule. return fromPromise( - db.platformNotification.update({ - where: { id: data.id }, + db.platformNotification.updateMany({ + where: { id: data.id, isDraft: true }, data: { startsAt: data.startsAt, endsAt: data.endsAt, isDraft: false }, - select: { id: true, friendlyId: true }, }), (e): CreateError => ({ type: "db", message: e instanceof Error ? e.message : String(e), }) + ).andThen(({ count }) => + count === 0 + ? errAsync<{ id: string }, CreateError>({ + type: "conflict", + message: "Notification not found or is not a draft", + }) + : okAsync({ id: data.id }) ); } diff --git a/apps/webapp/test/platformNotifications.test.ts b/apps/webapp/test/platformNotifications.test.ts index 810342e74f9..333b188d6fb 100644 --- a/apps/webapp/test/platformNotifications.test.ts +++ b/apps/webapp/test/platformNotifications.test.ts @@ -5,9 +5,11 @@ import { createDraftPlatformNotification, CreatePlatformNotificationSchema, getActivePlatformNotifications, + getAdminNotificationsList, getNextCliNotification, getRecentChangelogs, publishDraftPlatformNotification, + updateDraftPlatformNotification, } from "~/services/platformNotifications.server"; import { isCliVersionEligible } from "~/services/platformNotificationVersionTargeting"; @@ -267,3 +269,83 @@ describe("platform notification drafts are hidden from users", () => { expect(after.notifications.map((n) => n.id)).toContain(id); }); }); + +describe("platform notification draft admin guards", () => { + postgresTest("drafts stay in the admin list when hiding inactive", async ({ prisma }) => { + const now = new Date(); + // A draft whose placeholder endsAt is already in the past — must NOT be treated as expired. + const draft = await seedNotification(prisma, { + surface: "WEBAPP", + payload: webappCardPayload("draft"), + isDraft: true, + startsAt: now, + endsAt: now, + }); + // A genuinely expired, non-draft notification — must be hidden. + const expired = await seedNotification(prisma, { + surface: "WEBAPP", + payload: webappCardPayload("expired"), + isDraft: false, + startsAt: new Date(now.getTime() - 2 * HOUR_MS), + endsAt: new Date(now.getTime() - HOUR_MS), + }); + + const { notifications } = await getAdminNotificationsList({ hideInactive: true }, prisma); + + const ids = notifications.map((n) => n.id); + expect(ids).toContain(draft.id); + expect(ids).not.toContain(expired.id); + }); + + postgresTest("publishing a non-draft is rejected and leaves it unchanged", async ({ prisma }) => { + const startsAt = new Date(Date.now() - 2 * HOUR_MS); + const endsAt = new Date(Date.now() + HOUR_MS); + const published = await seedNotification(prisma, { + surface: "WEBAPP", + payload: webappCardPayload("already published"), + isDraft: false, + startsAt, + endsAt, + }); + + const result = await publishDraftPlatformNotification( + { + id: published.id, + startsAt: new Date(Date.now() + HOUR_MS).toISOString(), + endsAt: new Date(Date.now() + 48 * HOUR_MS).toISOString(), + }, + prisma + ); + + expect(result.isErr()).toBe(true); + if (result.isErr()) expect(result.error.type).toBe("conflict"); + + // The live notification's schedule is untouched. + const row = await prisma.platformNotification.findFirst({ where: { id: published.id } }); + expect(row?.isDraft).toBe(false); + expect(row?.startsAt.toISOString()).toBe(startsAt.toISOString()); + expect(row?.endsAt.toISOString()).toBe(endsAt.toISOString()); + }); + + postgresTest("editing a non-draft with the draft path is rejected", async ({ prisma }) => { + const published = await seedNotification(prisma, { + surface: "WEBAPP", + payload: webappCardPayload("live"), + isDraft: false, + }); + + const result = await updateDraftPlatformNotification( + { + id: published.id, + title: "hijack attempt", + payload: { version: "1", data: { type: "card", title: "x", description: "y" } }, + surface: "WEBAPP", + scope: "GLOBAL", + }, + prisma + ); + + expect(result.isErr()).toBe(true); + if (result.isErr()) expect(result.error.type).toBe("conflict"); + }); +}); From ecc2aebc59f9ac04310535fcd173eebf08efbfea Mon Sep 17 00:00:00 2001 From: D-K-P <8297864+D-K-P@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:08:01 +0100 Subject: [PATCH 6/6] fix(webapp): split publish-draft dialog form to satisfy set-state-in-effect lint The publish-draft dialog closed itself by calling a state setter inside an effect, which the react/set-state-in-effect rule flags. Move the form into a child component that closes via an onClose prop instead, matching the existing edit form. No behavior change. --- .../webapp/app/routes/admin.notifications.tsx | 111 ++++++++++-------- 1 file changed, 62 insertions(+), 49 deletions(-) diff --git a/apps/webapp/app/routes/admin.notifications.tsx b/apps/webapp/app/routes/admin.notifications.tsx index d7dfa0c2d8c..03631ae9714 100644 --- a/apps/webapp/app/routes/admin.notifications.tsx +++ b/apps/webapp/app/routes/admin.notifications.tsx @@ -787,11 +787,6 @@ function PublishNowButton({ notificationId }: { notificationId: string }) { function PublishDraftButton({ notificationId }: { notificationId: string }) { const [open, setOpen] = useState(false); - const fetcher = useFetcher<{ success?: boolean; error?: string }>(); - - useEffect(() => { - if (fetcher.data?.success) setOpen(false); - }, [fetcher.data]); return ( <> @@ -803,56 +798,74 @@ function PublishDraftButton({ notificationId }: { notificationId: string }) { Publish notification - - - - - Set the schedule for this notification. It becomes visible to users at the start time. - -
-
- - -
-
- - -
-
- - {fetcher.data?.error && ( - {fetcher.data.error} - )} - - - -
+ setOpen(false)} /> ); } +// Split out so the "close on success" effect calls the `onClose` prop rather than a +// local state setter — the latter trips react/set-state-in-effect. +function PublishDraftForm({ + notificationId, + onClose, +}: { + notificationId: string; + onClose: () => void; +}) { + const fetcher = useFetcher<{ success?: boolean; error?: string }>(); + + useEffect(() => { + if (fetcher.data?.success) onClose(); + }, [fetcher.data, onClose]); + + return ( + + + + + Set the schedule for this notification. It becomes visible to users at the start time. + +
+
+ + +
+
+ + +
+
+ + {fetcher.data?.error && {fetcher.data.error}} + + + +
+ ); +} + function DeleteConfirmationButton({ notificationId }: { notificationId: string }) { const [open, setOpen] = useState(false); const fetcher = useFetcher();