Skip to content

Commit 38bf82a

Browse files
authored
feat(cli,webapp): target notifications by minimum CLI version (#4407)
1 parent 44eca4d commit 38bf82a

10 files changed

Lines changed: 430 additions & 230 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"trigger.dev": patch
3+
---
4+
5+
Send the running CLI version when checking for platform notifications so notices can be limited to compatible CLI releases.

apps/webapp/app/routes/admin.notifications.tsx

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,8 @@ function parseNotificationFormData(formData: FormData) {
140140
const cliShowEvery = formData.get("cliShowEvery")
141141
? Number(formData.get("cliShowEvery"))
142142
: undefined;
143+
const minimumCliVersion =
144+
(formData.get("minimumCliVersion") as string | null)?.trim() || undefined;
143145

144146
const discoveryFilePatterns = (formData.get("discoveryFilePatterns") as string) || "";
145147
const discoveryContentPattern = (formData.get("discoveryContentPattern") as string) || undefined;
@@ -177,6 +179,7 @@ function parseNotificationFormData(formData: FormData) {
177179
cliMaxShowCount,
178180
cliMaxDaysAfterFirstSeen,
179181
cliShowEvery,
182+
minimumCliVersion,
180183
discovery,
181184
};
182185
}
@@ -192,6 +195,9 @@ function buildPayloadInput(fields: ReturnType<typeof parseNotificationFormData>)
192195
...(fields.image ? { image: fields.image } : {}),
193196
...(fields.dismissOnAction ? { dismissOnAction: true } : {}),
194197
...(fields.discovery ? { discovery: fields.discovery } : {}),
198+
...(fields.surface === "CLI" && fields.minimumCliVersion
199+
? { minimumCliVersion: fields.minimumCliVersion }
200+
: {}),
195201
},
196202
};
197203
}
@@ -669,6 +675,7 @@ type NotificationFormDefaults = {
669675
contentPattern?: string;
670676
matchBehavior: "show-if-found" | "show-if-not-found";
671677
} | null;
678+
payloadMinimumCliVersion?: string | null;
672679
cliMaxShowCount?: number | null;
673680
cliMaxDaysAfterFirstSeen?: number | null;
674681
cliShowEvery?: number | null;
@@ -1024,7 +1031,19 @@ function NotificationForm({
10241031
<>
10251032
<input type="hidden" name="discoveryMatchBehavior" value={discoveryMatchBehavior} />
10261033

1027-
<div className="grid grid-cols-3 gap-3 rounded border border-grid-dimmed bg-background-deep p-3">
1034+
<div className="grid grid-cols-2 gap-3 rounded border border-grid-dimmed bg-background-deep p-3">
1035+
<div>
1036+
<Label variant="small">Minimum CLI version</Label>
1037+
<Input
1038+
name="minimumCliVersion"
1039+
variant="medium"
1040+
fullWidth
1041+
defaultValue={n?.payloadMinimumCliVersion ?? ""}
1042+
placeholder="e.g. 4.5.7"
1043+
className="mt-1"
1044+
/>
1045+
<Hint>Exact SemVer, inclusive</Hint>
1046+
</div>
10281047
<div>
10291048
<Label variant="small">Max show count</Label>
10301049
<Input
@@ -1188,6 +1207,7 @@ function NotificationDetailContent({
11881207
payloadDescription: string | null;
11891208
payloadActionUrl: string | null | undefined;
11901209
payloadImage: string | null | undefined;
1210+
payloadMinimumCliVersion: string | null;
11911211
cliMaxShowCount: number | null;
11921212
cliMaxDaysAfterFirstSeen: number | null;
11931213
cliShowEvery: number | null;
@@ -1239,10 +1259,16 @@ function NotificationDetailContent({
12391259

12401260
{/* CLI settings */}
12411261
{n.surface === "CLI" &&
1242-
(n.cliMaxShowCount || n.cliMaxDaysAfterFirstSeen || n.cliShowEvery) && (
1262+
(n.payloadMinimumCliVersion ||
1263+
n.cliMaxShowCount ||
1264+
n.cliMaxDaysAfterFirstSeen ||
1265+
n.cliShowEvery) && (
12431266
<div>
12441267
<p className="mb-1 text-xs font-medium text-text-dimmed">CLI Settings</p>
12451268
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
1269+
{n.payloadMinimumCliVersion && (
1270+
<DetailRow label="Minimum CLI version" value={n.payloadMinimumCliVersion} />
1271+
)}
12461272
{n.cliMaxShowCount != null && (
12471273
<DetailRow label="Max show count" value={String(n.cliMaxShowCount)} />
12481274
)}

apps/webapp/app/routes/api.v1.platform-notifications.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ export async function loader({ request }: LoaderFunctionArgs) {
1212

1313
const url = new URL(request.url);
1414
const projectRef = url.searchParams.get("projectRef") ?? undefined;
15+
const cliVersion = request.headers.get("x-trigger-cli-version")?.trim() || undefined;
1516

1617
const notification = await getNextCliNotification({
1718
userId: authenticationResult.userId,
1819
projectRef,
20+
cliVersion,
1921
});
2022

2123
return json({ notification });
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
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+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import * as semver from "semver";
2+
3+
export function normalizeExactSemVer(value: string): string | null {
4+
const trimmed = value.trim();
5+
const parsed = semver.parse(trimmed);
6+
7+
if (!parsed) return null;
8+
9+
const normalized = `${parsed.version}${
10+
parsed.build.length > 0 ? `+${parsed.build.join(".")}` : ""
11+
}`;
12+
13+
return normalized === trimmed ? normalized : null;
14+
}
15+
16+
export function isCliVersionEligible(
17+
minimumCliVersion: string | undefined,
18+
cliVersion: string | undefined
19+
): boolean {
20+
if (minimumCliVersion === undefined) return true;
21+
22+
const normalizedMinimum = normalizeExactSemVer(minimumCliVersion);
23+
if (!normalizedMinimum || cliVersion === undefined) return false;
24+
25+
const normalizedCliVersion = normalizeExactSemVer(cliVersion);
26+
if (!normalizedCliVersion) return false;
27+
28+
return semver.gte(normalizedCliVersion, normalizedMinimum);
29+
}

0 commit comments

Comments
 (0)