diff --git a/apps/webapp/app/routes/admin.notifications.tsx b/apps/webapp/app/routes/admin.notifications.tsx index b93a5e8f5b3..03631ae9714 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,42 @@ 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 } + ); + } + if (err.type === "conflict") { + return typedjson({ error: err.message }, { status: 409 }); + } + 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 +484,64 @@ 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 } + ); + } + if (err.type === "conflict") { + return typedjson({ error: err.message }, { status: 409 }); + } + 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); @@ -508,15 +669,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" && ( + + )}
@@ -620,6 +785,87 @@ function PublishNowButton({ notificationId }: { notificationId: string }) { ); } +function PublishDraftButton({ notificationId }: { notificationId: string }) { + const [open, setOpen] = useState(false); + + return ( + <> + + + + + Publish notification + + 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(); @@ -658,6 +904,7 @@ function DeleteConfirmationButton({ notificationId }: { notificationId: string } type NotificationFormDefaults = { id?: string; + isDraft?: boolean; title?: string; surface?: string; scope?: string; @@ -724,15 +971,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 && } @@ -1001,34 +1246,40 @@ function NotificationForm({ )} -
-
- - -
-
- - + {isDraftEdit ? ( + + This is a draft — you'll set the start and end dates when you publish it. + + ) : ( +
+
+ + +
+
+ + +
-
+ )} {surface === "CLI" && ( <> @@ -1152,41 +1403,68 @@ function NotificationForm({ {!isEdit && fetcher.data?.success && !fetcher.data.previewId && ( Created successfully )} - {!isEdit && fetcher.data?.previewId && ( + {fetcher.data?.previewId && ( Preview sent (ID: {fetcher.data.previewId}) )}
-
- {isEdit ? ( - - ) : ( - <> - - - - )} -
+ + + ) : ( +
+ + + +
+ )}
); @@ -1439,15 +1717,17 @@ function defaultEndsAt(): string { return toDatetimeLocalUTC(new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)); } -type NotificationStatus = "active" | "pending" | "releasing" | "expired" | "archived"; +type NotificationStatus = "draft" | "active" | "pending" | "releasing" | "expired" | "archived"; const FIVE_MINUTES_MS = 5 * 60 * 1000; function getNotificationStatus(n: { + isDraft?: boolean; archivedAt: string | Date | null; startsAt: string | Date; endsAt: string | Date; }): NotificationStatus { + if (n.isDraft) return "draft"; if (n.archivedAt) return "archived"; const now = new Date(); const starts = typeof n.startsAt === "string" ? new Date(n.startsAt) : n.startsAt; @@ -1461,6 +1741,7 @@ function getNotificationStatus(n: { function StatusBadge({ status }: { status: NotificationStatus }) { const styles: Record = { + draft: "bg-purple-500/20 text-purple-400", active: "bg-green-500/20 text-green-400", pending: "bg-blue-500/20 text-blue-400", releasing: "bg-amber-500/20 text-amber-400", 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 0684dda7efa..6186f168c50 100644 --- a/apps/webapp/app/services/platformNotifications.server.ts +++ b/apps/webapp/app/services/platformNotifications.server.ts @@ -1,16 +1,23 @@ import type { z } from "zod"; -import { errAsync, fromPromise, type ResultAsync } from "neverthrow"; +import { errAsync, fromPromise, okAsync, type ResultAsync } from "neverthrow"; import { Prisma, prisma, sqlDatabaseSchema } 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"; @@ -29,32 +36,39 @@ 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, take: pageSize, }), - prisma.platformNotification.count({ where }), + db.platformNotification.count({ where }), ]); const notificationIds = notifications.map((n) => n.id); const interactionStats = notificationIds.length > 0 - ? await prisma.$queryRaw< + ? await db.$queryRaw< { notificationId: string; seen: bigint; @@ -93,6 +107,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, @@ -126,21 +141,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: [ @@ -310,25 +329,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: [ @@ -364,7 +388,8 @@ function isCliNotificationExpired( id: string; cliMaxDaysAfterFirstSeen: number | null; cliMaxShowCount: number | null; - } + }, + db: PrismaClientOrTransaction = prisma ): boolean { if (!interaction) return false; @@ -388,7 +413,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, @@ -402,15 +427,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; @@ -423,7 +451,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, @@ -443,7 +471,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 }, }); @@ -465,10 +493,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 }], @@ -496,7 +525,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) { @@ -509,7 +538,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 }, @@ -537,7 +566,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 @@ -615,6 +647,131 @@ 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 }, 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. + // `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.updateMany({ + where: { id: data.id, isDraft: true }, + 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, + }, + }), + (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 }, CreateError> { + const parseResult = PublishDraftPlatformNotificationSchema.safeParse(input); + + if (!parseResult.success) { + return errAsync({ type: "validation", issues: parseResult.error.issues }); + } + + 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.updateMany({ + where: { id: data.id, isDraft: true }, + data: { startsAt: data.startsAt, endsAt: data.endsAt, isDraft: false }, + }), + (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 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..d8c3ad20ce5 100644 --- a/apps/webapp/test/platformNotifications.test.ts +++ b/apps/webapp/test/platformNotifications.test.ts @@ -1,7 +1,21 @@ -import { describe, expect, it } from "vitest"; +import { postgresTest } from "@internal/testcontainers"; +import { type Prisma, type PrismaClient } from "@trigger.dev/database"; +import { describe, expect, it, vi } from "vitest"; +import { + createDraftPlatformNotification, + getActivePlatformNotifications, + getAdminNotificationsList, + getNextCliNotification, + getRecentChangelogs, + publishDraftPlatformNotification, + updateDraftPlatformNotification, +} from "~/services/platformNotifications.server"; import { CreatePlatformNotificationSchema } from "~/services/platformNotificationSchemas"; 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 +112,240 @@ 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); + }); +}); + +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"); + }); +}); 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 76092217dbe..470f2c251c4 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -3182,6 +3182,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?