feat(cli,webapp): target notifications by minimum CLI version (#4407)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Send the running CLI version when checking for platform notifications so notices can be limited to compatible CLI releases.
|
||||
@@ -140,6 +140,8 @@ function parseNotificationFormData(formData: FormData) {
|
||||
const cliShowEvery = formData.get("cliShowEvery")
|
||||
? Number(formData.get("cliShowEvery"))
|
||||
: undefined;
|
||||
const minimumCliVersion =
|
||||
(formData.get("minimumCliVersion") as string | null)?.trim() || undefined;
|
||||
|
||||
const discoveryFilePatterns = (formData.get("discoveryFilePatterns") as string) || "";
|
||||
const discoveryContentPattern = (formData.get("discoveryContentPattern") as string) || undefined;
|
||||
@@ -177,6 +179,7 @@ function parseNotificationFormData(formData: FormData) {
|
||||
cliMaxShowCount,
|
||||
cliMaxDaysAfterFirstSeen,
|
||||
cliShowEvery,
|
||||
minimumCliVersion,
|
||||
discovery,
|
||||
};
|
||||
}
|
||||
@@ -192,6 +195,9 @@ function buildPayloadInput(fields: ReturnType<typeof parseNotificationFormData>)
|
||||
...(fields.image ? { image: fields.image } : {}),
|
||||
...(fields.dismissOnAction ? { dismissOnAction: true } : {}),
|
||||
...(fields.discovery ? { discovery: fields.discovery } : {}),
|
||||
...(fields.surface === "CLI" && fields.minimumCliVersion
|
||||
? { minimumCliVersion: fields.minimumCliVersion }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -669,6 +675,7 @@ type NotificationFormDefaults = {
|
||||
contentPattern?: string;
|
||||
matchBehavior: "show-if-found" | "show-if-not-found";
|
||||
} | null;
|
||||
payloadMinimumCliVersion?: string | null;
|
||||
cliMaxShowCount?: number | null;
|
||||
cliMaxDaysAfterFirstSeen?: number | null;
|
||||
cliShowEvery?: number | null;
|
||||
@@ -1024,7 +1031,19 @@ function NotificationForm({
|
||||
<>
|
||||
<input type="hidden" name="discoveryMatchBehavior" value={discoveryMatchBehavior} />
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 rounded border border-grid-dimmed bg-background-deep p-3">
|
||||
<div className="grid grid-cols-2 gap-3 rounded border border-grid-dimmed bg-background-deep p-3">
|
||||
<div>
|
||||
<Label variant="small">Minimum CLI version</Label>
|
||||
<Input
|
||||
name="minimumCliVersion"
|
||||
variant="medium"
|
||||
fullWidth
|
||||
defaultValue={n?.payloadMinimumCliVersion ?? ""}
|
||||
placeholder="e.g. 4.5.7"
|
||||
className="mt-1"
|
||||
/>
|
||||
<Hint>Exact SemVer, inclusive</Hint>
|
||||
</div>
|
||||
<div>
|
||||
<Label variant="small">Max show count</Label>
|
||||
<Input
|
||||
@@ -1188,6 +1207,7 @@ function NotificationDetailContent({
|
||||
payloadDescription: string | null;
|
||||
payloadActionUrl: string | null | undefined;
|
||||
payloadImage: string | null | undefined;
|
||||
payloadMinimumCliVersion: string | null;
|
||||
cliMaxShowCount: number | null;
|
||||
cliMaxDaysAfterFirstSeen: number | null;
|
||||
cliShowEvery: number | null;
|
||||
@@ -1239,10 +1259,16 @@ function NotificationDetailContent({
|
||||
|
||||
{/* CLI settings */}
|
||||
{n.surface === "CLI" &&
|
||||
(n.cliMaxShowCount || n.cliMaxDaysAfterFirstSeen || n.cliShowEvery) && (
|
||||
(n.payloadMinimumCliVersion ||
|
||||
n.cliMaxShowCount ||
|
||||
n.cliMaxDaysAfterFirstSeen ||
|
||||
n.cliShowEvery) && (
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-text-dimmed">CLI Settings</p>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
|
||||
{n.payloadMinimumCliVersion && (
|
||||
<DetailRow label="Minimum CLI version" value={n.payloadMinimumCliVersion} />
|
||||
)}
|
||||
{n.cliMaxShowCount != null && (
|
||||
<DetailRow label="Max show count" value={String(n.cliMaxShowCount)} />
|
||||
)}
|
||||
|
||||
@@ -12,10 +12,12 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
|
||||
const url = new URL(request.url);
|
||||
const projectRef = url.searchParams.get("projectRef") ?? undefined;
|
||||
const cliVersion = request.headers.get("x-trigger-cli-version")?.trim() || undefined;
|
||||
|
||||
const notification = await getNextCliNotification({
|
||||
userId: authenticationResult.userId,
|
||||
projectRef,
|
||||
cliVersion,
|
||||
});
|
||||
|
||||
return json({ notification });
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { z } from "zod";
|
||||
import { normalizeExactSemVer } from "./platformNotificationVersionTargeting";
|
||||
|
||||
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 ExactSemVerSchema = z
|
||||
.string()
|
||||
.transform((value) => value.trim())
|
||||
.refine((value) => normalizeExactSemVer(value) !== null, {
|
||||
message: "minimumCliVersion must be a complete, exact SemVer",
|
||||
})
|
||||
.transform((value) => normalizeExactSemVer(value)!);
|
||||
|
||||
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(),
|
||||
minimumCliVersion: ExactSemVerSchema.optional(),
|
||||
});
|
||||
|
||||
export const PayloadV1Schema = z.object({
|
||||
version: z.literal("1"),
|
||||
data: CardDataV1Schema,
|
||||
});
|
||||
|
||||
export type PayloadV1 = z.infer<typeof PayloadV1Schema>;
|
||||
|
||||
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;
|
||||
payload: PayloadV1;
|
||||
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],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (data.payload.data.minimumCliVersion !== undefined) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "minimumCliVersion is not allowed for WEBAPP surface",
|
||||
path: ["payload", "data", "minimumCliVersion"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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>;
|
||||
|
||||
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);
|
||||
// Existing notifications may have a startsAt in the past.
|
||||
validateEndsAt(data, ctx);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as semver from "semver";
|
||||
|
||||
export function normalizeExactSemVer(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
const parsed = semver.parse(trimmed);
|
||||
|
||||
if (!parsed) return null;
|
||||
|
||||
const normalized = `${parsed.version}${
|
||||
parsed.build.length > 0 ? `+${parsed.build.join(".")}` : ""
|
||||
}`;
|
||||
|
||||
return normalized === trimmed ? normalized : null;
|
||||
}
|
||||
|
||||
export function isCliVersionEligible(
|
||||
minimumCliVersion: string | undefined,
|
||||
cliVersion: string | undefined
|
||||
): boolean {
|
||||
if (minimumCliVersion === undefined) return true;
|
||||
|
||||
const normalizedMinimum = normalizeExactSemVer(minimumCliVersion);
|
||||
if (!normalizedMinimum || cliVersion === undefined) return false;
|
||||
|
||||
const normalizedCliVersion = normalizeExactSemVer(cliVersion);
|
||||
if (!normalizedCliVersion) return false;
|
||||
|
||||
return semver.gte(normalizedCliVersion, normalizedMinimum);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import type { z } from "zod";
|
||||
import { errAsync, fromPromise, type ResultAsync } from "neverthrow";
|
||||
import { prisma } from "~/db.server";
|
||||
import {
|
||||
@@ -6,64 +6,20 @@ import {
|
||||
type PlatformNotificationSurface,
|
||||
} from "@trigger.dev/database";
|
||||
import { incrementCliRequestCounter } from "./platformNotificationCounter.server";
|
||||
import {
|
||||
CreatePlatformNotificationSchema,
|
||||
type CreatePlatformNotificationInput,
|
||||
type PayloadV1,
|
||||
PayloadV1Schema,
|
||||
UpdatePlatformNotificationSchema,
|
||||
} from "./platformNotificationSchemas";
|
||||
import { isCliVersionEligible } from "./platformNotificationVersionTargeting";
|
||||
|
||||
// --- 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 {
|
||||
CreatePlatformNotificationSchema,
|
||||
UpdatePlatformNotificationSchema,
|
||||
} from "./platformNotificationSchemas";
|
||||
export type { CreatePlatformNotificationInput, PayloadV1 } from "./platformNotificationSchemas";
|
||||
|
||||
export type PlatformNotificationWithPayload = {
|
||||
id: string;
|
||||
@@ -136,6 +92,9 @@ export async function getAdminNotificationsList({
|
||||
? (parsed.data.data.dismissOnAction ?? false)
|
||||
: false,
|
||||
payloadDiscovery: parsed.success ? (parsed.data.data.discovery ?? null) : null,
|
||||
payloadMinimumCliVersion: parsed.success
|
||||
? (parsed.data.data.minimumCliVersion ?? null)
|
||||
: null,
|
||||
cliMaxShowCount: n.cliMaxShowCount,
|
||||
cliMaxDaysAfterFirstSeen: n.cliMaxDaysAfterFirstSeen,
|
||||
cliShowEvery: n.cliShowEvery,
|
||||
@@ -435,9 +394,11 @@ function isCliNotificationExpired(
|
||||
export async function getNextCliNotification({
|
||||
userId,
|
||||
projectRef,
|
||||
cliVersion,
|
||||
}: {
|
||||
userId: string;
|
||||
projectRef?: string;
|
||||
cliVersion?: string;
|
||||
}): Promise<{
|
||||
id: string;
|
||||
payload: PayloadV1;
|
||||
@@ -520,10 +481,11 @@ export async function getNextCliNotification({
|
||||
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;
|
||||
if (!isCliVersionEligible(parsed.data.data.minimumCliVersion, cliVersion)) continue;
|
||||
if (isCliNotificationExpired(interaction, n)) continue;
|
||||
|
||||
// Check cliShowEvery using the global request counter
|
||||
if (n.cliShowEvery !== null && requestCounter % n.cliShowEvery !== 0) {
|
||||
@@ -562,161 +524,7 @@ export async function getNextCliNotification({
|
||||
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);
|
||||
});
|
||||
// --- Create and update: admin endpoint support ---
|
||||
|
||||
type CreateError = { type: "validation"; issues: z.ZodIssue[] } | { type: "db"; message: string };
|
||||
|
||||
|
||||
@@ -196,6 +196,7 @@
|
||||
"remix-auth-google": "^2.0.0",
|
||||
"remix-typedjson": "0.3.1",
|
||||
"remix-utils": "^7.7.0",
|
||||
"semver": "^7.5.0",
|
||||
"simplur": "^3.0.1",
|
||||
"slug": "^6.0.0",
|
||||
"socket.io": "4.7.4",
|
||||
@@ -242,6 +243,7 @@
|
||||
"@types/react": "18.2.69",
|
||||
"@types/react-dom": "18.2.7",
|
||||
"@types/regression": "^2.0.6",
|
||||
"@types/semver": "^7.5.0",
|
||||
"@types/slug": "^5.0.3",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/tar": "^6.1.4",
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CreatePlatformNotificationSchema } from "~/services/platformNotificationSchemas";
|
||||
import { isCliVersionEligible } from "~/services/platformNotificationVersionTargeting";
|
||||
|
||||
function createNotificationInput({
|
||||
surface = "CLI",
|
||||
minimumCliVersion,
|
||||
}: {
|
||||
surface?: "CLI" | "WEBAPP";
|
||||
minimumCliVersion?: string;
|
||||
} = {}) {
|
||||
return {
|
||||
title: "Notification",
|
||||
surface,
|
||||
scope: "GLOBAL" as const,
|
||||
endsAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||
payload: {
|
||||
version: "1" as const,
|
||||
data: {
|
||||
type: surface === "CLI" ? ("info" as const) : ("card" as const),
|
||||
title: "Update available",
|
||||
description: "Update the CLI",
|
||||
...(minimumCliVersion === undefined ? {} : { minimumCliVersion }),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("platform notification minimum CLI version schema", () => {
|
||||
it("keeps existing unrestricted CLI payloads valid", () => {
|
||||
const result = CreatePlatformNotificationSchema.safeParse(createNotificationInput());
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("trims and accepts a complete exact SemVer", () => {
|
||||
const result = CreatePlatformNotificationSchema.parse(
|
||||
createNotificationInput({ minimumCliVersion: " 4.5.7-beta.1+build.2 " })
|
||||
);
|
||||
|
||||
expect(result.payload.data.minimumCliVersion).toBe("4.5.7-beta.1+build.2");
|
||||
});
|
||||
|
||||
it.each(["4.5", ">=4.5.7", "4.5.x", "v4.5.7", "4.5.7 or newer", "01.2.3"])(
|
||||
"rejects non-exact SemVer %s",
|
||||
(minimumCliVersion) => {
|
||||
const result = CreatePlatformNotificationSchema.safeParse(
|
||||
createNotificationInput({ minimumCliVersion })
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it("rejects minimumCliVersion for WEBAPP notifications", () => {
|
||||
const result = CreatePlatformNotificationSchema.safeParse(
|
||||
createNotificationInput({ surface: "WEBAPP", minimumCliVersion: "4.5.7" })
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues).toContainEqual(
|
||||
expect.objectContaining({ path: ["payload", "data", "minimumCliVersion"] })
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI notification version eligibility", () => {
|
||||
it("allows unrestricted notifications without a request version", () => {
|
||||
expect(isCliVersionEligible(undefined, undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([undefined, "", "4.5", "not-a-version"])(
|
||||
"rejects a targeted notification for missing or malformed request version %s",
|
||||
(cliVersion) => {
|
||||
expect(isCliVersionEligible("4.5.7", cliVersion)).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it("fails closed for an invalid stored minimum version", () => {
|
||||
expect(isCliVersionEligible(">=4.5.7", "4.6.0")).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["4.5.6", false],
|
||||
["4.5.7-beta.1", false],
|
||||
["4.5.7", true],
|
||||
["4.5.7+build.9", true],
|
||||
["4.6.0", true],
|
||||
])("compares %s against inclusive minimum 4.5.7", (cliVersion, expected) => {
|
||||
expect(isCliVersionEligible("4.5.7", cliVersion)).toBe(expected);
|
||||
});
|
||||
|
||||
it("uses SemVer precedence for prerelease minimums", () => {
|
||||
expect(isCliVersionEligible("4.5.7-beta.2", "4.5.7-beta.1")).toBe(false);
|
||||
expect(isCliVersionEligible("4.5.7-beta.2", "4.5.7-beta.2+build.1")).toBe(true);
|
||||
expect(isCliVersionEligible("4.5.7-beta.2", "4.5.7")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -606,6 +606,7 @@ export class CliApiClient {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-trigger-cli-version": VERSION,
|
||||
},
|
||||
signal,
|
||||
});
|
||||
|
||||
Generated
+12
-13
@@ -716,6 +716,9 @@ importers:
|
||||
remix-utils:
|
||||
specifier: ^7.7.0
|
||||
version: 7.7.0(@remix-run/node@2.17.5(typescript@6.0.3))(@remix-run/react@2.17.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.3))(@remix-run/router@1.23.3(patch_hash=33e2966e9ef36aa09955cec9922c5063227b83429157a71ba226369a08627ac9))(crypto-js@4.2.0)(intl-parse-accept-language@1.0.0)(react@18.3.1)(zod@3.25.76)
|
||||
semver:
|
||||
specifier: ^7.5.0
|
||||
version: 7.8.1
|
||||
simplur:
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1
|
||||
@@ -849,6 +852,9 @@ importers:
|
||||
'@types/regression':
|
||||
specifier: ^2.0.6
|
||||
version: 2.0.6
|
||||
'@types/semver':
|
||||
specifier: ^7.5.0
|
||||
version: 7.5.1
|
||||
'@types/slug':
|
||||
specifier: ^5.0.3
|
||||
version: 5.0.3
|
||||
@@ -13958,11 +13964,6 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
semver@7.7.3:
|
||||
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
semver@7.8.1:
|
||||
resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -17525,7 +17526,7 @@ snapshots:
|
||||
outdent: 0.5.0
|
||||
prettier: 2.8.8
|
||||
resolve-from: 5.0.0
|
||||
semver: 7.7.3
|
||||
semver: 7.8.1
|
||||
|
||||
'@changesets/assemble-release-plan@5.2.4(patch_hash=a7a12643e8d89a00d5322f750c292e8567b5ee0fc9c613a238a1184c25533e4b)':
|
||||
dependencies:
|
||||
@@ -17534,7 +17535,7 @@ snapshots:
|
||||
'@changesets/get-dependents-graph': 1.3.6
|
||||
'@changesets/types': 5.2.1
|
||||
'@manypkg/get-packages': 1.1.3
|
||||
semver: 7.7.3
|
||||
semver: 7.8.1
|
||||
|
||||
'@changesets/changelog-git@0.1.14':
|
||||
dependencies:
|
||||
@@ -17596,7 +17597,7 @@ snapshots:
|
||||
'@manypkg/get-packages': 1.1.3
|
||||
chalk: 2.4.2
|
||||
fs-extra: 7.0.1
|
||||
semver: 7.7.3
|
||||
semver: 7.8.1
|
||||
|
||||
'@changesets/get-github-info@0.5.2(encoding@0.1.13)':
|
||||
dependencies:
|
||||
@@ -24149,7 +24150,7 @@ snapshots:
|
||||
postcss-modules-scope: 3.1.1(postcss@8.5.10)
|
||||
postcss-modules-values: 4.0.0(postcss@8.5.10)
|
||||
postcss-value-parser: 4.2.0
|
||||
semver: 7.6.3
|
||||
semver: 7.8.1
|
||||
optionalDependencies:
|
||||
webpack: 5.102.1(@swc/core@1.3.26)(esbuild@0.15.18)
|
||||
|
||||
@@ -25570,7 +25571,7 @@ snapshots:
|
||||
process-warning: 5.0.0
|
||||
rfdc: 1.4.1
|
||||
secure-json-parse: 4.0.0
|
||||
semver: 7.7.3
|
||||
semver: 7.8.1
|
||||
toad-cache: 3.7.0
|
||||
|
||||
fastq@1.15.0:
|
||||
@@ -28690,7 +28691,7 @@ snapshots:
|
||||
cosmiconfig: 9.0.0(typescript@6.0.3)
|
||||
jiti: 1.21.0
|
||||
postcss: 8.5.15
|
||||
semver: 7.6.3
|
||||
semver: 7.8.1
|
||||
optionalDependencies:
|
||||
webpack: 5.102.1(@swc/core@1.3.26)(esbuild@0.15.18)
|
||||
transitivePeerDependencies:
|
||||
@@ -29763,8 +29764,6 @@ snapshots:
|
||||
|
||||
semver@7.6.3: {}
|
||||
|
||||
semver@7.7.3: {}
|
||||
|
||||
semver@7.8.1: {}
|
||||
|
||||
send@0.18.0:
|
||||
|
||||
Reference in New Issue
Block a user