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 ``. 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; 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; onCreate: Record; }) { 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> = [ { 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 = { 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; // --- 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 ): 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 { await prisma.platformNotification.delete({ where: { id } }); } export async function publishNowPlatformNotification(id: string): Promise { await prisma.platformNotification.update({ where: { id }, data: { startsAt: new Date() }, }); } export async function archivePlatformNotification(id: string): Promise { await prisma.platformNotification.update({ where: { id }, data: { archivedAt: new Date() }, }); }