|
| 1 | +import { z } from "zod"; |
| 2 | +import { normalizeExactSemVer } from "./platformNotificationVersionTargeting"; |
| 3 | + |
| 4 | +const DiscoverySchema = z.object({ |
| 5 | + filePatterns: z.array(z.string().min(1)).min(1), |
| 6 | + contentPattern: z |
| 7 | + .string() |
| 8 | + .max(200) |
| 9 | + .optional() |
| 10 | + .refine( |
| 11 | + (val) => { |
| 12 | + if (!val) return true; |
| 13 | + try { |
| 14 | + new RegExp(val); |
| 15 | + return true; |
| 16 | + } catch { |
| 17 | + return false; |
| 18 | + } |
| 19 | + }, |
| 20 | + { message: "contentPattern must be a valid regular expression" } |
| 21 | + ), |
| 22 | + matchBehavior: z.enum(["show-if-found", "show-if-not-found"]), |
| 23 | +}); |
| 24 | + |
| 25 | +// Constrain URL fields to http/https; `.url()` alone accepts other schemes |
| 26 | +// that would be unsafe to render into an `<a href>`. |
| 27 | +const httpUrl = z |
| 28 | + .string() |
| 29 | + .url() |
| 30 | + .refine( |
| 31 | + (v) => { |
| 32 | + try { |
| 33 | + const proto = new URL(v).protocol; |
| 34 | + return proto === "http:" || proto === "https:"; |
| 35 | + } catch { |
| 36 | + return false; |
| 37 | + } |
| 38 | + }, |
| 39 | + { message: "URL must use http or https" } |
| 40 | + ); |
| 41 | + |
| 42 | +const ExactSemVerSchema = z |
| 43 | + .string() |
| 44 | + .transform((value) => value.trim()) |
| 45 | + .refine((value) => normalizeExactSemVer(value) !== null, { |
| 46 | + message: "minimumCliVersion must be a complete, exact SemVer", |
| 47 | + }) |
| 48 | + .transform((value) => normalizeExactSemVer(value)!); |
| 49 | + |
| 50 | +const CardDataV1Schema = z.object({ |
| 51 | + type: z.enum(["card", "info", "warn", "error", "success", "changelog"]), |
| 52 | + title: z.string(), |
| 53 | + description: z.string(), |
| 54 | + image: httpUrl.optional(), |
| 55 | + actionLabel: z.string().optional(), |
| 56 | + actionUrl: httpUrl.optional(), |
| 57 | + dismissOnAction: z.boolean().optional(), |
| 58 | + discovery: DiscoverySchema.optional(), |
| 59 | + minimumCliVersion: ExactSemVerSchema.optional(), |
| 60 | +}); |
| 61 | + |
| 62 | +export const PayloadV1Schema = z.object({ |
| 63 | + version: z.literal("1"), |
| 64 | + data: CardDataV1Schema, |
| 65 | +}); |
| 66 | + |
| 67 | +export type PayloadV1 = z.infer<typeof PayloadV1Schema>; |
| 68 | + |
| 69 | +const SCOPE_REQUIRED_FK: Record<string, "userId" | "organizationId" | "projectId"> = { |
| 70 | + USER: "userId", |
| 71 | + ORGANIZATION: "organizationId", |
| 72 | + PROJECT: "projectId", |
| 73 | +}; |
| 74 | + |
| 75 | +const ALL_FK_FIELDS = ["userId", "organizationId", "projectId"] as const; |
| 76 | +const CLI_ONLY_FIELDS = ["cliMaxDaysAfterFirstSeen", "cliMaxShowCount", "cliShowEvery"] as const; |
| 77 | + |
| 78 | +const NotificationBaseFields = { |
| 79 | + title: z.string().min(1), |
| 80 | + payload: PayloadV1Schema, |
| 81 | + surface: z.enum(["WEBAPP", "CLI"]), |
| 82 | + scope: z.enum(["USER", "PROJECT", "ORGANIZATION", "GLOBAL"]), |
| 83 | + userId: z.string().optional(), |
| 84 | + organizationId: z.string().optional(), |
| 85 | + projectId: z.string().optional(), |
| 86 | + endsAt: z |
| 87 | + .string() |
| 88 | + .datetime() |
| 89 | + .transform((s) => new Date(s)), |
| 90 | + priority: z.number().int().default(0), |
| 91 | + cliMaxDaysAfterFirstSeen: z.number().int().positive().optional(), |
| 92 | + cliMaxShowCount: z.number().int().positive().optional(), |
| 93 | + cliShowEvery: z.number().int().min(2).optional(), |
| 94 | +}; |
| 95 | + |
| 96 | +export const CreatePlatformNotificationSchema = z |
| 97 | + .object({ |
| 98 | + ...NotificationBaseFields, |
| 99 | + startsAt: z |
| 100 | + .string() |
| 101 | + .datetime() |
| 102 | + .transform((s) => new Date(s)) |
| 103 | + .optional(), |
| 104 | + }) |
| 105 | + .superRefine((data, ctx) => { |
| 106 | + validateScopeForeignKeys(data, ctx); |
| 107 | + validateSurfaceFields(data, ctx); |
| 108 | + validatePayloadTypeForSurface(data, ctx); |
| 109 | + validateStartsAt(data, ctx); |
| 110 | + validateEndsAt(data, ctx); |
| 111 | + }); |
| 112 | + |
| 113 | +function validateScopeForeignKeys( |
| 114 | + data: { scope: string; userId?: string; organizationId?: string; projectId?: string }, |
| 115 | + ctx: z.RefinementCtx |
| 116 | +) { |
| 117 | + const requiredFk = SCOPE_REQUIRED_FK[data.scope]; |
| 118 | + |
| 119 | + if (requiredFk && !data[requiredFk]) { |
| 120 | + ctx.addIssue({ |
| 121 | + code: "custom", |
| 122 | + message: `${requiredFk} is required when scope is ${data.scope}`, |
| 123 | + path: [requiredFk], |
| 124 | + }); |
| 125 | + } |
| 126 | + |
| 127 | + const forbiddenFks = ALL_FK_FIELDS.filter((fk) => fk !== requiredFk); |
| 128 | + for (const fk of forbiddenFks) { |
| 129 | + if (data[fk]) { |
| 130 | + ctx.addIssue({ |
| 131 | + code: "custom", |
| 132 | + message: `${fk} must not be set when scope is ${data.scope}`, |
| 133 | + path: [fk], |
| 134 | + }); |
| 135 | + } |
| 136 | + } |
| 137 | +} |
| 138 | + |
| 139 | +function validateSurfaceFields( |
| 140 | + data: { |
| 141 | + surface: string; |
| 142 | + payload: PayloadV1; |
| 143 | + cliMaxDaysAfterFirstSeen?: number; |
| 144 | + cliMaxShowCount?: number; |
| 145 | + cliShowEvery?: number; |
| 146 | + }, |
| 147 | + ctx: z.RefinementCtx |
| 148 | +) { |
| 149 | + if (data.surface !== "WEBAPP") return; |
| 150 | + |
| 151 | + for (const field of CLI_ONLY_FIELDS) { |
| 152 | + if (data[field] !== undefined) { |
| 153 | + ctx.addIssue({ |
| 154 | + code: "custom", |
| 155 | + message: `${field} is not allowed for WEBAPP surface`, |
| 156 | + path: [field], |
| 157 | + }); |
| 158 | + } |
| 159 | + } |
| 160 | + |
| 161 | + if (data.payload.data.minimumCliVersion !== undefined) { |
| 162 | + ctx.addIssue({ |
| 163 | + code: "custom", |
| 164 | + message: "minimumCliVersion is not allowed for WEBAPP surface", |
| 165 | + path: ["payload", "data", "minimumCliVersion"], |
| 166 | + }); |
| 167 | + } |
| 168 | +} |
| 169 | + |
| 170 | +function validateStartsAt(data: { startsAt?: Date }, ctx: z.RefinementCtx) { |
| 171 | + if (!data.startsAt) return; |
| 172 | + |
| 173 | + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); |
| 174 | + if (data.startsAt < oneHourAgo) { |
| 175 | + ctx.addIssue({ |
| 176 | + code: "custom", |
| 177 | + message: "startsAt must be within the last hour or in the future", |
| 178 | + path: ["startsAt"], |
| 179 | + }); |
| 180 | + } |
| 181 | +} |
| 182 | + |
| 183 | +const CLI_TYPES = new Set(["info", "warn", "error", "success"]); |
| 184 | +const WEBAPP_TYPES = new Set(["card", "changelog"]); |
| 185 | + |
| 186 | +function validatePayloadTypeForSurface( |
| 187 | + data: { surface: string; payload: PayloadV1 }, |
| 188 | + ctx: z.RefinementCtx |
| 189 | +) { |
| 190 | + const allowedTypes = data.surface === "CLI" ? CLI_TYPES : WEBAPP_TYPES; |
| 191 | + if (!allowedTypes.has(data.payload.data.type)) { |
| 192 | + ctx.addIssue({ |
| 193 | + code: "custom", |
| 194 | + message: `payload.data.type "${data.payload.data.type}" is not allowed for ${data.surface} surface`, |
| 195 | + path: ["payload", "data", "type"], |
| 196 | + }); |
| 197 | + } |
| 198 | +} |
| 199 | + |
| 200 | +function validateEndsAt(data: { startsAt?: Date; endsAt: Date }, ctx: z.RefinementCtx) { |
| 201 | + const effectiveStart = data.startsAt ?? new Date(); |
| 202 | + if (data.endsAt <= effectiveStart) { |
| 203 | + ctx.addIssue({ |
| 204 | + code: "custom", |
| 205 | + message: "endsAt must be after startsAt", |
| 206 | + path: ["endsAt"], |
| 207 | + }); |
| 208 | + } |
| 209 | +} |
| 210 | + |
| 211 | +export type CreatePlatformNotificationInput = z.input<typeof CreatePlatformNotificationSchema>; |
| 212 | + |
| 213 | +export const UpdatePlatformNotificationSchema = z |
| 214 | + .object({ |
| 215 | + ...NotificationBaseFields, |
| 216 | + id: z.string().min(1), |
| 217 | + startsAt: z |
| 218 | + .string() |
| 219 | + .datetime() |
| 220 | + .transform((s) => new Date(s)), |
| 221 | + }) |
| 222 | + .superRefine((data, ctx) => { |
| 223 | + validateScopeForeignKeys(data, ctx); |
| 224 | + validateSurfaceFields(data, ctx); |
| 225 | + validatePayloadTypeForSurface(data, ctx); |
| 226 | + // Existing notifications may have a startsAt in the past. |
| 227 | + validateEndsAt(data, ctx); |
| 228 | + }); |
0 commit comments