feat(webapp,database): save platform notifications as drafts and publish later (#4743)

## Summary

The platform notifications admin page can now save a notification as a
draft without committing to a schedule, then publish it later by
entering start and end dates. Drafts stay hidden from the webapp panel,
the CLI, and the "What's new" changelog until they are published.

## Design

A draft is an `isDraft` flag on `PlatformNotification`, not nullable
dates, so the existing index and every read query stay intact. All three
reader queries filter on the flag, so a draft can never surface
regardless of its placeholder dates. Publishing writes the real start
and end dates and clears the flag; the publish dialog validates the
range and shows inline errors. Editing a draft keeps it a draft, with
the schedule fields hidden until publish.

Also folds in a small tweak: the "Send preview to me" test button now
appears when editing a notification, not just when creating one.
This commit is contained in:
DKP
2026-08-20 22:16:32 +01:00
committed by GitHub
parent 1034b618a4
commit d04467018e
6 changed files with 881 additions and 126 deletions
+346 -65
View File
@@ -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<typeof loader>();
const [showCreate, setShowCreate] = useState(false);
@@ -508,15 +669,19 @@ export default function AdminNotificationsRoute() {
</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover/row:opacity-100">
{status === "draft" && <PublishDraftButton notificationId={n.id} />}
{status === "pending" && <PublishNowButton notificationId={n.id} />}
{(status === "pending" ||
{(status === "draft" ||
status === "pending" ||
status === "releasing" ||
status === "active") && (
<Button variant="tertiary/small" onClick={() => setEditNotification(n)}>
Edit
</Button>
)}
{status !== "archived" && <ArchiveButton notificationId={n.id} />}
{status !== "archived" && status !== "draft" && (
<ArchiveButton notificationId={n.id} />
)}
<DeleteConfirmationButton notificationId={n.id} />
</div>
</TableCell>
@@ -620,6 +785,87 @@ function PublishNowButton({ notificationId }: { notificationId: string }) {
);
}
function PublishDraftButton({ notificationId }: { notificationId: string }) {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="secondary/small" onClick={() => setOpen(true)}>
Publish
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Publish notification</DialogTitle>
</DialogHeader>
<PublishDraftForm notificationId={notificationId} onClose={() => setOpen(false)} />
</DialogContent>
</Dialog>
</>
);
}
// 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 (
<fetcher.Form method="post" className="space-y-3">
<input type="hidden" name="_action" value="publish-draft" />
<input type="hidden" name="notificationId" value={notificationId} />
<Paragraph variant="small" className="text-text-dimmed">
Set the schedule for this notification. It becomes visible to users at the start time.
</Paragraph>
<div className="grid grid-cols-2 gap-3">
<div>
<Label variant="small">
Starts at (UTC) <span className="text-red-400">*</span>
</Label>
<input
name="startsAt"
type="datetime-local"
defaultValue={toDatetimeLocalUTC(new Date())}
className="mt-1 block h-8 w-full rounded border border-background-bright bg-background-hover px-2 text-sm text-text-bright transition hover:border-border-bright hover:bg-secondary"
required
/>
</div>
<div>
<Label variant="small">
Ends at (UTC) <span className="text-red-400">*</span>
</Label>
<input
name="endsAt"
type="datetime-local"
defaultValue={defaultEndsAt()}
className="mt-1 block h-8 w-full rounded border border-background-bright bg-background-hover px-2 text-sm text-text-bright transition hover:border-border-bright hover:bg-secondary"
required
/>
</div>
</div>
<DialogFooter className="items-center">
{fetcher.data?.error && <span className="text-xs text-red-400">{fetcher.data.error}</span>}
<Button type="button" variant="tertiary/medium" onClick={onClose}>
Cancel
</Button>
<Button type="submit" variant="primary/medium" disabled={fetcher.state !== "idle"}>
{fetcher.state !== "idle" ? "Publishing..." : "Publish"}
</Button>
</DialogFooter>
</fetcher.Form>
);
}
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 (
<fetcher.Form method="post" className="space-y-3">
{isEdit && (
<>
<input type="hidden" name="_action" value="edit" />
<input type="hidden" name="notificationId" value={n?.id} />
</>
)}
{isEdit && <input type="hidden" name="notificationId" value={n?.id} />}
<input type="hidden" name="surface" value={surface} />
<input type="hidden" name="payloadType" value={payloadType} />
@@ -1001,34 +1246,40 @@ function NotificationForm({
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<Label variant="small">
Starts at (UTC) {isEdit && <span className="text-red-400">*</span>}
</Label>
<input
name="startsAt"
type="datetime-local"
defaultValue={
n?.startsAt ? toDatetimeLocalUTC(new Date(n.startsAt)) : defaultStartsAt()
}
className="mt-1 block h-8 w-full rounded border border-background-bright bg-background-hover px-2 text-sm text-text-bright transition hover:border-border-bright hover:bg-secondary"
required={isEdit}
/>
{isDraftEdit ? (
<Paragraph variant="small" className="text-text-dimmed">
This is a draft you'll set the start and end dates when you publish it.
</Paragraph>
) : (
<div className="grid grid-cols-2 gap-3">
<div>
<Label variant="small">
Starts at (UTC) {isEdit && <span className="text-red-400">*</span>}
</Label>
<input
name="startsAt"
type="datetime-local"
defaultValue={
n?.startsAt ? toDatetimeLocalUTC(new Date(n.startsAt)) : defaultStartsAt()
}
className="mt-1 block h-8 w-full rounded border border-background-bright bg-background-hover px-2 text-sm text-text-bright transition hover:border-border-bright hover:bg-secondary"
required={isEdit}
/>
</div>
<div>
<Label variant="small">
Ends at (UTC) <span className="text-red-400">*</span>
</Label>
<input
name="endsAt"
type="datetime-local"
defaultValue={n?.endsAt ? toDatetimeLocalUTC(new Date(n.endsAt)) : defaultEndsAt()}
className="mt-1 block h-8 w-full rounded border border-background-bright bg-background-hover px-2 text-sm text-text-bright transition hover:border-border-bright hover:bg-secondary"
required
/>
</div>
</div>
<div>
<Label variant="small">
Ends at (UTC) <span className="text-red-400">*</span>
</Label>
<input
name="endsAt"
type="datetime-local"
defaultValue={n?.endsAt ? toDatetimeLocalUTC(new Date(n.endsAt)) : defaultEndsAt()}
className="mt-1 block h-8 w-full rounded border border-background-bright bg-background-hover px-2 text-sm text-text-bright transition hover:border-border-bright hover:bg-secondary"
required
/>
</div>
</div>
)}
{surface === "CLI" && (
<>
@@ -1152,41 +1403,68 @@ function NotificationForm({
{!isEdit && fetcher.data?.success && !fetcher.data.previewId && (
<span className="text-xs text-green-400">Created successfully</span>
)}
{!isEdit && fetcher.data?.previewId && (
{fetcher.data?.previewId && (
<span className="text-xs text-green-400">
Preview sent (ID: {fetcher.data.previewId})
</span>
)}
</div>
<div className="flex items-center gap-2">
{isEdit ? (
<Button type="submit" variant="primary/medium" disabled={fetcher.state !== "idle"}>
{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.
<div className="flex flex-row-reverse items-center gap-2">
<Button
type="submit"
name="_action"
value={isDraftEdit ? "edit-draft" : "edit"}
variant="primary/medium"
disabled={fetcher.state !== "idle"}
>
{fetcher.state !== "idle" ? "Saving..." : "Save changes"}
</Button>
) : (
<>
<Button
type="submit"
name="_action"
value="create-preview"
variant="tertiary/medium"
disabled={fetcher.state !== "idle"}
>
Send preview to me
</Button>
<Button
type="submit"
name="_action"
value="create"
variant="primary/medium"
disabled={fetcher.state !== "idle"}
>
{fetcher.state !== "idle" ? "Creating..." : "Create"}
</Button>
</>
)}
</div>
<Button
type="submit"
name="_action"
value="create-preview"
variant="tertiary/medium"
disabled={fetcher.state !== "idle"}
>
Send preview to me
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<Button
type="submit"
name="_action"
value="create-preview"
variant="tertiary/medium"
disabled={fetcher.state !== "idle"}
>
Send preview to me
</Button>
<Button
type="submit"
name="_action"
value="create-draft"
variant="secondary/medium"
disabled={fetcher.state !== "idle"}
>
Save as draft
</Button>
<Button
type="submit"
name="_action"
value="create"
variant="primary/medium"
disabled={fetcher.state !== "idle"}
>
{fetcher.state !== "idle" ? "Creating..." : "Create"}
</Button>
</div>
)}
</DialogFooter>
</fetcher.Form>
);
@@ -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<NotificationStatus, string> = {
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",
@@ -75,7 +75,9 @@ const SCOPE_REQUIRED_FK: Record<string, "userId" | "organizationId" | "projectId
const ALL_FK_FIELDS = ["userId", "organizationId", "projectId"] as const;
const CLI_ONLY_FIELDS = ["cliMaxDaysAfterFirstSeen", "cliMaxShowCount", "cliShowEvery"] as const;
const NotificationBaseFields = {
// Fields shared by every notification write, excluding the schedule (startsAt/endsAt).
// Drafts reuse this set without committing to any dates.
const NotificationContentFields = {
title: z.string().min(1),
payload: PayloadV1Schema,
surface: z.enum(["WEBAPP", "CLI"]),
@@ -83,16 +85,20 @@ const NotificationBaseFields = {
userId: z.string().optional(),
organizationId: z.string().optional(),
projectId: z.string().optional(),
endsAt: z
.string()
.datetime()
.transform((s) => 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<typeof CreatePlatformNotificationSchema>;
// 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,
@@ -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<void> {
await prisma.platformNotification.delete({ where: { id } });
}
+252 -1
View File
@@ -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");
});
});
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "PlatformNotification" ADD COLUMN IF NOT EXISTS "isDraft" BOOLEAN NOT NULL DEFAULT false;
@@ -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?