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:
@@ -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 } });
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
+2
@@ -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?
|
||||
|
||||
|
||||
Reference in New Issue
Block a user