chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,815 @@
|
||||
import { z } from "zod";
|
||||
import { errAsync, fromPromise, type ResultAsync } from "neverthrow";
|
||||
import { prisma } from "~/db.server";
|
||||
import {
|
||||
type PlatformNotificationScope,
|
||||
type PlatformNotificationSurface,
|
||||
} from "@trigger.dev/database";
|
||||
import { incrementCliRequestCounter } from "./platformNotificationCounter.server";
|
||||
|
||||
// --- Payload schema (spec v1) ---
|
||||
|
||||
const DiscoverySchema = z.object({
|
||||
filePatterns: z.array(z.string().min(1)).min(1),
|
||||
contentPattern: z
|
||||
.string()
|
||||
.max(200)
|
||||
.optional()
|
||||
.refine(
|
||||
(val) => {
|
||||
if (!val) return true;
|
||||
try {
|
||||
new RegExp(val);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ message: "contentPattern must be a valid regular expression" }
|
||||
),
|
||||
matchBehavior: z.enum(["show-if-found", "show-if-not-found"]),
|
||||
});
|
||||
|
||||
// Constrain URL fields to http/https; `.url()` alone accepts other schemes
|
||||
// that would be unsafe to render into an `<a href>`.
|
||||
const httpUrl = z
|
||||
.string()
|
||||
.url()
|
||||
.refine(
|
||||
(v) => {
|
||||
try {
|
||||
const proto = new URL(v).protocol;
|
||||
return proto === "http:" || proto === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ message: "URL must use http or https" }
|
||||
);
|
||||
|
||||
const CardDataV1Schema = z.object({
|
||||
type: z.enum(["card", "info", "warn", "error", "success", "changelog"]),
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
image: httpUrl.optional(),
|
||||
actionLabel: z.string().optional(),
|
||||
actionUrl: httpUrl.optional(),
|
||||
dismissOnAction: z.boolean().optional(),
|
||||
discovery: DiscoverySchema.optional(),
|
||||
});
|
||||
|
||||
const PayloadV1Schema = z.object({
|
||||
version: z.literal("1"),
|
||||
data: CardDataV1Schema,
|
||||
});
|
||||
|
||||
export type PayloadV1 = z.infer<typeof PayloadV1Schema>;
|
||||
|
||||
export type PlatformNotificationWithPayload = {
|
||||
id: string;
|
||||
friendlyId: string;
|
||||
scope: string;
|
||||
priority: number;
|
||||
payload: PayloadV1;
|
||||
isRead: boolean;
|
||||
};
|
||||
|
||||
// --- 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() } } : {};
|
||||
|
||||
const [notifications, total] = await Promise.all([
|
||||
prisma.platformNotification.findMany({
|
||||
where,
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: {
|
||||
_count: {
|
||||
select: { interactions: true },
|
||||
},
|
||||
interactions: {
|
||||
select: {
|
||||
webappDismissedAt: true,
|
||||
webappClickedAt: true,
|
||||
cliDismissedAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.platformNotification.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
notifications: notifications.map((n) => {
|
||||
const parsed = PayloadV1Schema.safeParse(n.payload);
|
||||
return {
|
||||
id: n.id,
|
||||
friendlyId: n.friendlyId,
|
||||
title: n.title,
|
||||
surface: n.surface,
|
||||
scope: n.scope,
|
||||
userId: n.userId,
|
||||
organizationId: n.organizationId,
|
||||
projectId: n.projectId,
|
||||
priority: n.priority,
|
||||
startsAt: n.startsAt,
|
||||
endsAt: n.endsAt,
|
||||
archivedAt: n.archivedAt,
|
||||
createdAt: n.createdAt,
|
||||
payload: n.payload,
|
||||
payloadTitle: parsed.success ? parsed.data.data.title : null,
|
||||
payloadType: parsed.success ? parsed.data.data.type : null,
|
||||
payloadDescription: parsed.success ? parsed.data.data.description : null,
|
||||
payloadActionUrl: parsed.success ? parsed.data.data.actionUrl : null,
|
||||
payloadImage: parsed.success ? parsed.data.data.image : null,
|
||||
payloadDismissOnAction: parsed.success
|
||||
? (parsed.data.data.dismissOnAction ?? false)
|
||||
: false,
|
||||
payloadDiscovery: parsed.success ? (parsed.data.data.discovery ?? null) : null,
|
||||
cliMaxShowCount: n.cliMaxShowCount,
|
||||
cliMaxDaysAfterFirstSeen: n.cliMaxDaysAfterFirstSeen,
|
||||
cliShowEvery: n.cliShowEvery,
|
||||
stats: {
|
||||
seen: n._count.interactions,
|
||||
clicked: n.interactions.filter((i) => i.webappClickedAt !== null).length,
|
||||
dismissed: n.interactions.filter(
|
||||
(i) => i.webappDismissedAt !== null || i.cliDismissedAt !== null
|
||||
).length,
|
||||
},
|
||||
};
|
||||
}),
|
||||
total,
|
||||
page,
|
||||
pageCount: Math.ceil(total / pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
// --- Read: active notifications for webapp ---
|
||||
|
||||
export async function getActivePlatformNotifications({
|
||||
userId,
|
||||
organizationId,
|
||||
projectId,
|
||||
}: {
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
projectId?: string;
|
||||
}) {
|
||||
const now = new Date();
|
||||
|
||||
const notifications = await prisma.platformNotification.findMany({
|
||||
where: {
|
||||
surface: "WEBAPP",
|
||||
archivedAt: null,
|
||||
startsAt: { lte: now },
|
||||
endsAt: { gt: now },
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{ scope: "GLOBAL" },
|
||||
{ scope: "ORGANIZATION", organizationId },
|
||||
...(projectId ? [{ scope: "PROJECT" as const, projectId }] : []),
|
||||
{ scope: "USER", userId },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
interactions: {
|
||||
where: { userId },
|
||||
},
|
||||
},
|
||||
orderBy: [{ priority: "desc" }, { createdAt: "desc" }],
|
||||
});
|
||||
|
||||
type InternalNotification = PlatformNotificationWithPayload & { createdAt: Date };
|
||||
const result: InternalNotification[] = [];
|
||||
|
||||
for (const n of notifications) {
|
||||
const interaction = n.interactions[0] ?? null;
|
||||
|
||||
if (interaction?.webappDismissedAt) continue;
|
||||
|
||||
const parsed = PayloadV1Schema.safeParse(n.payload);
|
||||
if (!parsed.success) continue;
|
||||
|
||||
result.push({
|
||||
id: n.id,
|
||||
friendlyId: n.friendlyId,
|
||||
scope: n.scope,
|
||||
priority: n.priority,
|
||||
createdAt: n.createdAt,
|
||||
payload: parsed.data,
|
||||
isRead: !!interaction,
|
||||
});
|
||||
}
|
||||
|
||||
result.sort(compareNotifications);
|
||||
|
||||
const unreadCount = result.filter((n) => !n.isRead).length;
|
||||
const notifications_out: PlatformNotificationWithPayload[] = result.map(
|
||||
({ createdAt: _, ...rest }) => rest
|
||||
);
|
||||
|
||||
return { notifications: notifications_out, unreadCount };
|
||||
}
|
||||
|
||||
function compareNotifications(
|
||||
a: { priority: number; createdAt: Date },
|
||||
b: { priority: number; createdAt: Date }
|
||||
) {
|
||||
const priorityDiff = b.priority - a.priority;
|
||||
if (priorityDiff !== 0) return priorityDiff;
|
||||
|
||||
return b.createdAt.getTime() - a.createdAt.getTime();
|
||||
}
|
||||
|
||||
// --- Write: upsert interaction ---
|
||||
|
||||
async function upsertInteraction({
|
||||
notificationId,
|
||||
userId,
|
||||
onUpdate,
|
||||
onCreate,
|
||||
}: {
|
||||
notificationId: string;
|
||||
userId: string;
|
||||
onUpdate: Record<string, unknown>;
|
||||
onCreate: Record<string, unknown>;
|
||||
}) {
|
||||
await prisma.platformNotificationInteraction.upsert({
|
||||
where: { notificationId_userId: { notificationId, userId } },
|
||||
update: onUpdate,
|
||||
create: {
|
||||
notificationId,
|
||||
userId,
|
||||
firstSeenAt: new Date(),
|
||||
showCount: 1,
|
||||
...onCreate,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function recordNotificationSeen({
|
||||
notificationId,
|
||||
userId,
|
||||
}: {
|
||||
notificationId: string;
|
||||
userId: string;
|
||||
}) {
|
||||
return upsertInteraction({
|
||||
notificationId,
|
||||
userId,
|
||||
onUpdate: { showCount: { increment: 1 } },
|
||||
onCreate: {},
|
||||
});
|
||||
}
|
||||
|
||||
export async function dismissNotification({
|
||||
notificationId,
|
||||
userId,
|
||||
}: {
|
||||
notificationId: string;
|
||||
userId: string;
|
||||
}) {
|
||||
const now = new Date();
|
||||
return upsertInteraction({
|
||||
notificationId,
|
||||
userId,
|
||||
onUpdate: { webappDismissedAt: now },
|
||||
onCreate: { webappDismissedAt: now },
|
||||
});
|
||||
}
|
||||
|
||||
export async function recordNotificationClicked({
|
||||
notificationId,
|
||||
userId,
|
||||
}: {
|
||||
notificationId: string;
|
||||
userId: string;
|
||||
}) {
|
||||
const now = new Date();
|
||||
return upsertInteraction({
|
||||
notificationId,
|
||||
userId,
|
||||
onUpdate: { webappClickedAt: now },
|
||||
onCreate: { webappClickedAt: now },
|
||||
});
|
||||
}
|
||||
|
||||
// --- Membership verification ---
|
||||
|
||||
export async function verifyOrgMembership({
|
||||
userId,
|
||||
organizationId,
|
||||
projectId,
|
||||
}: {
|
||||
userId: string;
|
||||
organizationId?: string;
|
||||
projectId?: string;
|
||||
}): Promise<{ organizationId?: string; projectId?: string }> {
|
||||
if (!organizationId) return {};
|
||||
|
||||
const membership = await prisma.orgMember.findFirst({
|
||||
where: { userId, organizationId },
|
||||
select: { organizationId: true },
|
||||
});
|
||||
|
||||
if (!membership) return {};
|
||||
|
||||
if (projectId) {
|
||||
const project = await prisma.project.findFirst({
|
||||
where: { id: projectId, organizationId, deletedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!project) return { organizationId };
|
||||
}
|
||||
|
||||
return { organizationId, projectId };
|
||||
}
|
||||
|
||||
// --- Read: recent changelogs (for Help & Feedback) ---
|
||||
|
||||
export async function getRecentChangelogs({
|
||||
userId,
|
||||
organizationId,
|
||||
projectId,
|
||||
limit = 2,
|
||||
}: {
|
||||
userId: string;
|
||||
organizationId?: string;
|
||||
projectId?: string;
|
||||
limit?: number;
|
||||
}) {
|
||||
// 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({
|
||||
where: {
|
||||
surface: "WEBAPP",
|
||||
payload: { path: ["data", "type"], equals: "changelog" },
|
||||
startsAt: { lte: new Date() },
|
||||
OR: [
|
||||
{ scope: "GLOBAL" },
|
||||
{ scope: "USER", userId },
|
||||
...(organizationId ? [{ scope: "ORGANIZATION" as const, organizationId }] : []),
|
||||
...(projectId ? [{ scope: "PROJECT" as const, projectId }] : []),
|
||||
],
|
||||
},
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return notifications
|
||||
.map((n) => {
|
||||
const parsed = PayloadV1Schema.safeParse(n.payload);
|
||||
if (!parsed.success) return null;
|
||||
return { id: n.id, title: parsed.data.data.title, actionUrl: parsed.data.data.actionUrl };
|
||||
})
|
||||
.filter(Boolean) as Array<{ id: string; title: string; actionUrl?: string }>;
|
||||
}
|
||||
|
||||
// --- CLI: next notification for CLI surface ---
|
||||
|
||||
function isCliNotificationExpired(
|
||||
interaction: {
|
||||
userId: string;
|
||||
firstSeenAt: Date;
|
||||
showCount: number;
|
||||
cliDismissedAt: Date | null;
|
||||
} | null,
|
||||
notification: {
|
||||
id: string;
|
||||
cliMaxDaysAfterFirstSeen: number | null;
|
||||
cliMaxShowCount: number | null;
|
||||
}
|
||||
): boolean {
|
||||
if (!interaction) return false;
|
||||
|
||||
let expired = false;
|
||||
|
||||
if (
|
||||
notification.cliMaxShowCount !== null &&
|
||||
interaction.showCount >= notification.cliMaxShowCount
|
||||
) {
|
||||
expired = true;
|
||||
}
|
||||
|
||||
if (!expired && notification.cliMaxDaysAfterFirstSeen !== null) {
|
||||
const daysSinceFirstSeen =
|
||||
(Date.now() - interaction.firstSeenAt.getTime()) / (1000 * 60 * 60 * 24);
|
||||
if (daysSinceFirstSeen > notification.cliMaxDaysAfterFirstSeen) {
|
||||
expired = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 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({
|
||||
where: {
|
||||
notificationId_userId: {
|
||||
notificationId: notification.id,
|
||||
userId: interaction.userId,
|
||||
},
|
||||
},
|
||||
data: { cliDismissedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
return expired;
|
||||
}
|
||||
|
||||
export async function getNextCliNotification({
|
||||
userId,
|
||||
projectRef,
|
||||
}: {
|
||||
userId: string;
|
||||
projectRef?: string;
|
||||
}): Promise<{
|
||||
id: string;
|
||||
payload: PayloadV1;
|
||||
showCount: number;
|
||||
firstSeenAt: string;
|
||||
} | null> {
|
||||
const now = new Date();
|
||||
|
||||
// Resolve organizationId and projectId from projectRef if provided
|
||||
let organizationId: string | undefined;
|
||||
let projectId: string | undefined;
|
||||
|
||||
if (projectRef) {
|
||||
const project = await prisma.project.findFirst({
|
||||
where: {
|
||||
externalRef: projectRef,
|
||||
deletedAt: null,
|
||||
organization: {
|
||||
deletedAt: null,
|
||||
members: { some: { userId } },
|
||||
},
|
||||
},
|
||||
select: { id: true, organizationId: true },
|
||||
});
|
||||
|
||||
if (project) {
|
||||
projectId = project.id;
|
||||
organizationId = project.organizationId;
|
||||
}
|
||||
}
|
||||
|
||||
// If no projectRef or project not found, get org from membership
|
||||
if (!organizationId) {
|
||||
const membership = await prisma.orgMember.findFirst({
|
||||
where: { userId },
|
||||
select: { organizationId: true },
|
||||
});
|
||||
if (membership) {
|
||||
organizationId = membership.organizationId;
|
||||
}
|
||||
}
|
||||
|
||||
const scopeFilter: Array<Record<string, unknown>> = [
|
||||
{ scope: "GLOBAL" },
|
||||
{ scope: "USER", userId },
|
||||
];
|
||||
|
||||
if (organizationId) {
|
||||
scopeFilter.push({ scope: "ORGANIZATION", organizationId });
|
||||
}
|
||||
|
||||
if (projectId) {
|
||||
scopeFilter.push({ scope: "PROJECT", projectId });
|
||||
}
|
||||
|
||||
const notifications = await prisma.platformNotification.findMany({
|
||||
where: {
|
||||
surface: "CLI",
|
||||
archivedAt: null,
|
||||
startsAt: { lte: now },
|
||||
endsAt: { gt: now },
|
||||
AND: [{ OR: scopeFilter }],
|
||||
},
|
||||
include: {
|
||||
interactions: {
|
||||
where: { userId },
|
||||
},
|
||||
},
|
||||
orderBy: [{ priority: "desc" }, { createdAt: "desc" }],
|
||||
});
|
||||
|
||||
const sorted = [...notifications].sort(compareNotifications);
|
||||
|
||||
// Global per-user request counter stored in Redis, used for cliShowEvery modulo.
|
||||
// This is independent of per-notification showCount so that cliMaxShowCount
|
||||
// correctly tracks actual displays, not API encounters.
|
||||
const requestCounter = await incrementCliRequestCounter(userId);
|
||||
|
||||
for (const n of sorted) {
|
||||
const interaction = n.interactions[0] ?? null;
|
||||
|
||||
if (interaction?.cliDismissedAt) continue;
|
||||
if (isCliNotificationExpired(interaction, n)) continue;
|
||||
|
||||
const parsed = PayloadV1Schema.safeParse(n.payload);
|
||||
if (!parsed.success) continue;
|
||||
|
||||
// Check cliShowEvery using the global request counter
|
||||
if (n.cliShowEvery !== null && requestCounter % n.cliShowEvery !== 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only increment showCount when the notification will actually be displayed.
|
||||
// If this display reaches cliMaxShowCount, also set cliDismissedAt now
|
||||
// so it's recorded immediately rather than waiting for a future request.
|
||||
const reachedMaxShows =
|
||||
n.cliMaxShowCount !== null && (interaction?.showCount ?? 0) + 1 >= n.cliMaxShowCount;
|
||||
|
||||
const updated = await prisma.platformNotificationInteraction.upsert({
|
||||
where: { notificationId_userId: { notificationId: n.id, userId } },
|
||||
update: {
|
||||
showCount: { increment: 1 },
|
||||
...(reachedMaxShows ? { cliDismissedAt: now } : {}),
|
||||
},
|
||||
create: {
|
||||
notificationId: n.id,
|
||||
userId,
|
||||
firstSeenAt: now,
|
||||
showCount: 1,
|
||||
...(reachedMaxShows ? { cliDismissedAt: now } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: n.id,
|
||||
payload: parsed.data,
|
||||
showCount: updated.showCount,
|
||||
firstSeenAt: updated.firstSeenAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- Create: admin endpoint support ---
|
||||
|
||||
const SCOPE_REQUIRED_FK: Record<string, "userId" | "organizationId" | "projectId"> = {
|
||||
USER: "userId",
|
||||
ORGANIZATION: "organizationId",
|
||||
PROJECT: "projectId",
|
||||
};
|
||||
|
||||
const ALL_FK_FIELDS = ["userId", "organizationId", "projectId"] as const;
|
||||
const CLI_ONLY_FIELDS = ["cliMaxDaysAfterFirstSeen", "cliMaxShowCount", "cliShowEvery"] as const;
|
||||
|
||||
const NotificationBaseFields = {
|
||||
title: z.string().min(1),
|
||||
payload: PayloadV1Schema,
|
||||
surface: z.enum(["WEBAPP", "CLI"]),
|
||||
scope: z.enum(["USER", "PROJECT", "ORGANIZATION", "GLOBAL"]),
|
||||
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(),
|
||||
};
|
||||
|
||||
export const CreatePlatformNotificationSchema = z
|
||||
.object({
|
||||
...NotificationBaseFields,
|
||||
startsAt: z
|
||||
.string()
|
||||
.datetime()
|
||||
.transform((s) => new Date(s))
|
||||
.optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
validateScopeForeignKeys(data, ctx);
|
||||
validateSurfaceFields(data, ctx);
|
||||
validatePayloadTypeForSurface(data, ctx);
|
||||
validateStartsAt(data, ctx);
|
||||
validateEndsAt(data, ctx);
|
||||
});
|
||||
|
||||
function validateScopeForeignKeys(
|
||||
data: { scope: string; userId?: string; organizationId?: string; projectId?: string },
|
||||
ctx: z.RefinementCtx
|
||||
) {
|
||||
const requiredFk = SCOPE_REQUIRED_FK[data.scope];
|
||||
|
||||
if (requiredFk && !data[requiredFk]) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: `${requiredFk} is required when scope is ${data.scope}`,
|
||||
path: [requiredFk],
|
||||
});
|
||||
}
|
||||
|
||||
const forbiddenFks = ALL_FK_FIELDS.filter((fk) => fk !== requiredFk);
|
||||
for (const fk of forbiddenFks) {
|
||||
if (data[fk]) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: `${fk} must not be set when scope is ${data.scope}`,
|
||||
path: [fk],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateSurfaceFields(
|
||||
data: {
|
||||
surface: string;
|
||||
cliMaxDaysAfterFirstSeen?: number;
|
||||
cliMaxShowCount?: number;
|
||||
cliShowEvery?: number;
|
||||
},
|
||||
ctx: z.RefinementCtx
|
||||
) {
|
||||
if (data.surface !== "WEBAPP") return;
|
||||
|
||||
for (const field of CLI_ONLY_FIELDS) {
|
||||
if (data[field] !== undefined) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: `${field} is not allowed for WEBAPP surface`,
|
||||
path: [field],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateStartsAt(data: { startsAt?: Date }, ctx: z.RefinementCtx) {
|
||||
if (!data.startsAt) return;
|
||||
|
||||
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
|
||||
if (data.startsAt < oneHourAgo) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "startsAt must be within the last hour or in the future",
|
||||
path: ["startsAt"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const CLI_TYPES = new Set(["info", "warn", "error", "success"]);
|
||||
const WEBAPP_TYPES = new Set(["card", "changelog"]);
|
||||
|
||||
function validatePayloadTypeForSurface(
|
||||
data: { surface: string; payload: PayloadV1 },
|
||||
ctx: z.RefinementCtx
|
||||
) {
|
||||
const allowedTypes = data.surface === "CLI" ? CLI_TYPES : WEBAPP_TYPES;
|
||||
if (!allowedTypes.has(data.payload.data.type)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: `payload.data.type "${data.payload.data.type}" is not allowed for ${data.surface} surface`,
|
||||
path: ["payload", "data", "type"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function validateEndsAt(data: { startsAt?: Date; endsAt: Date }, ctx: z.RefinementCtx) {
|
||||
const effectiveStart = data.startsAt ?? new Date();
|
||||
if (data.endsAt <= effectiveStart) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "endsAt must be after startsAt",
|
||||
path: ["endsAt"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export type CreatePlatformNotificationInput = z.input<typeof CreatePlatformNotificationSchema>;
|
||||
|
||||
// --- Update: admin endpoint support ---
|
||||
|
||||
export const UpdatePlatformNotificationSchema = z
|
||||
.object({
|
||||
...NotificationBaseFields,
|
||||
id: z.string().min(1),
|
||||
startsAt: z
|
||||
.string()
|
||||
.datetime()
|
||||
.transform((s) => new Date(s)),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
validateScopeForeignKeys(data, ctx);
|
||||
validateSurfaceFields(data, ctx);
|
||||
validatePayloadTypeForSurface(data, ctx);
|
||||
// NOTE: No validateStartsAt — existing notifications may have past startsAt
|
||||
validateEndsAt(data, ctx);
|
||||
});
|
||||
|
||||
type CreateError = { type: "validation"; issues: z.ZodIssue[] } | { type: "db"; message: string };
|
||||
|
||||
export function createPlatformNotification(
|
||||
input: CreatePlatformNotificationInput
|
||||
): ResultAsync<{ id: string; friendlyId: string }, CreateError> {
|
||||
const parseResult = CreatePlatformNotificationSchema.safeParse(input);
|
||||
|
||||
if (!parseResult.success) {
|
||||
return errAsync({ type: "validation", issues: parseResult.error.issues });
|
||||
}
|
||||
|
||||
const data = parseResult.data;
|
||||
|
||||
return fromPromise(
|
||||
prisma.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: data.startsAt ?? new Date(),
|
||||
endsAt: data.endsAt,
|
||||
priority: data.priority,
|
||||
cliMaxDaysAfterFirstSeen: data.cliMaxDaysAfterFirstSeen,
|
||||
cliMaxShowCount: data.cliMaxShowCount,
|
||||
cliShowEvery: data.cliShowEvery,
|
||||
},
|
||||
select: { id: true, friendlyId: true },
|
||||
}),
|
||||
(e): CreateError => ({
|
||||
type: "db",
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function updatePlatformNotification(
|
||||
input: z.input<typeof UpdatePlatformNotificationSchema>
|
||||
): ResultAsync<{ id: string; friendlyId: string }, CreateError> {
|
||||
const parseResult = UpdatePlatformNotificationSchema.safeParse(input);
|
||||
|
||||
if (!parseResult.success) {
|
||||
return errAsync({ type: "validation", issues: parseResult.error.issues });
|
||||
}
|
||||
|
||||
const data = parseResult.data;
|
||||
|
||||
return fromPromise(
|
||||
prisma.platformNotification.update({
|
||||
where: { id: data.id },
|
||||
data: {
|
||||
title: data.title,
|
||||
payload: data.payload,
|
||||
surface: data.surface as PlatformNotificationSurface,
|
||||
scope: data.scope as PlatformNotificationScope,
|
||||
userId: data.scope === "USER" ? data.userId : null,
|
||||
organizationId: data.scope === "ORGANIZATION" ? data.organizationId : null,
|
||||
projectId: data.scope === "PROJECT" ? data.projectId : null,
|
||||
startsAt: data.startsAt,
|
||||
endsAt: data.endsAt,
|
||||
priority: data.priority,
|
||||
cliMaxDaysAfterFirstSeen:
|
||||
data.surface === "CLI" ? (data.cliMaxDaysAfterFirstSeen ?? null) : null,
|
||||
cliMaxShowCount: data.surface === "CLI" ? (data.cliMaxShowCount ?? null) : null,
|
||||
cliShowEvery: data.surface === "CLI" ? (data.cliShowEvery ?? null) : null,
|
||||
},
|
||||
select: { id: true, friendlyId: true },
|
||||
}),
|
||||
(e): CreateError => ({
|
||||
type: "db",
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function deletePlatformNotification(id: string): Promise<void> {
|
||||
await prisma.platformNotification.delete({ where: { id } });
|
||||
}
|
||||
|
||||
export async function publishNowPlatformNotification(id: string): Promise<void> {
|
||||
await prisma.platformNotification.update({
|
||||
where: { id },
|
||||
data: { startsAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
export async function archivePlatformNotification(id: string): Promise<void> {
|
||||
await prisma.platformNotification.update({
|
||||
where: { id },
|
||||
data: { archivedAt: new Date() },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user