Skip to content
Source
Sign inSign up

scheduledTaskWebhook.ts

134 lines4.3 KB

1import {
2 MAX_WEBHOOK_DELIVERY_AGE_MINUTES,
3 MAX_WEBHOOK_FILTERS,
4 ScheduledTaskWebhookFilter,
5 type ScheduledTaskWebhookFilterOp,
6} from "@t3tools/contracts";
7import * as Option from "effect/Option";
8import * as Schema from "effect/Schema";
9​
10export const DEFAULT_WEBHOOK_PROMPT = "Handle this webhook:\n{{body}}";
11​
12/** Blank means "no limit"; undefined means the input is not a valid limit, which blocks saving. */
13export function parseMaxDeliveryAge(value: string): number | null | undefined {
14 if (value.trim() === "") return null;
15 const minutes = Number(value.trim());
16 return Number.isInteger(minutes) && minutes > 0 && minutes <= MAX_WEBHOOK_DELIVERY_AGE_MINUTES
17 ? minutes
18 : undefined;
20​
21/**
22 * One editable filter row. `in` takes a comma-separated list, which is
23 * lossless because list values can't contain commas; `exists` ignores the value.
24 */
25export interface WebhookFilterDraft {
26 /** Stable row identity for the form; never sent to the server. */
27 readonly id: string;
28 readonly path: string;
29 readonly op: ScheduledTaskWebhookFilterOp;
30 readonly value: string;
32​
33export const WEBHOOK_FILTER_OPS: ReadonlyArray<{
34 readonly op: ScheduledTaskWebhookFilterOp;
35 readonly label: string;
36}> = [
37 { op: "equals", label: "equals" },
38 { op: "notEquals", label: "does not equal" },
39 { op: "in", label: "is one of" },
40 { op: "startsWith", label: "starts with" },
41 { op: "exists", label: "exists" },
42];
43​
44let lastWebhookFilterDraftId = 0;
45const nextWebhookFilterDraftId = () => `filter-${++lastWebhookFilterDraftId}`;
46​
47export function emptyWebhookFilterDraft(): WebhookFilterDraft {
48 return { id: nextWebhookFilterDraftId(), path: "", op: "equals", value: "" };
50​
51/** By id, so a removed row's identity never passes to the row after it. */
52export function removeWebhookFilterDraft(
53 drafts: ReadonlyArray<WebhookFilterDraft>,
54 id: string,
55): ReadonlyArray<WebhookFilterDraft> {
56 return drafts.filter((draft) => draft.id !== id);
58​
59export function webhookFilterDrafts(
60 filters: ReadonlyArray<ScheduledTaskWebhookFilter> | undefined,
61): ReadonlyArray<WebhookFilterDraft> {
62 return (filters ?? []).map((filter) => ({
63 id: nextWebhookFilterDraftId(),
64 path: filter.path,
65 op: filter.op,
66 value:
67 filter.op === "exists" ? "" : filter.op === "in" ? filter.value.join(", ") : filter.value,
68 }));
70​
71const decodeFilter = Schema.decodeUnknownOption(ScheduledTaskWebhookFilter);
72​
73/**
74 * Checks each row with the server's own schema, so a draft that parses here
75 * also saves. The error names the first row that would not.
76 */
77export function parseWebhookFilterDrafts(
78 drafts: ReadonlyArray<WebhookFilterDraft>,
79): { readonly filters: ReadonlyArray<ScheduledTaskWebhookFilter> } | { readonly error: string } {
80 if (drafts.length > MAX_WEBHOOK_FILTERS) {
81 return { error: `Use at most ${MAX_WEBHOOK_FILTERS} filters.` };
82 }
83 const filters: ScheduledTaskWebhookFilter[] = [];
84 for (const [index, draft] of drafts.entries()) {
85 const path = draft.path.trim();
86 const filter = decodeFilter(
87 draft.op === "exists"
88 ? { path, op: draft.op }
89 : draft.op === "in"
90 ? {
91 path,
92 op: draft.op,
93 value: draft.value
94 .split(",")
95 .map((item) => item.trim())
96 .filter((item) => item !== ""),
97 }
98 : { path, op: draft.op, value: draft.value },
99 );
100 if (Option.isNone(filter)) {
101 return {
102 error: `Filter ${index + 1} needs a path under body, headers or query${
103 draft.op === "exists"
104 ? "."
105 : draft.op === "in"
106 ? ", and at least one value."
107 : ", and a value."
108 }`,
109 };
110 }
111 filters.push(filter.value);
112 }
113 return { filters };
115​
116/**
117 * Whether a form's filter rows differ from the ones it opened with. A save
118 * leaves `filters` out unless they did, so editing only the title can't
119 * overwrite filters another device changed in the meantime.
120 */
121export function webhookFilterDraftsChanged(
122 saved: ReadonlyArray<WebhookFilterDraft>,
123 current: ReadonlyArray<WebhookFilterDraft>,
124): boolean {
125 return (
126 saved.length !== current.length ||
127 saved.some(
128 (filter, index) =>
129 filter.path !== current[index]?.path ||
130 filter.op !== current[index]?.op ||
131 (filter.op !== "exists" && filter.value !== current[index]?.value),
132 )
133 );