easy-ping 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +374 -0
- package/dist/adapter-f0OR2DhY.d.cts +200 -0
- package/dist/adapter-f0OR2DhY.d.ts +200 -0
- package/dist/adapters/drizzle.cjs +440 -0
- package/dist/adapters/drizzle.cjs.map +1 -0
- package/dist/adapters/drizzle.d.cts +991 -0
- package/dist/adapters/drizzle.d.ts +991 -0
- package/dist/adapters/drizzle.js +409 -0
- package/dist/adapters/drizzle.js.map +1 -0
- package/dist/adapters/mongodb.cjs +458 -0
- package/dist/adapters/mongodb.cjs.map +1 -0
- package/dist/adapters/mongodb.d.cts +32 -0
- package/dist/adapters/mongodb.d.ts +32 -0
- package/dist/adapters/mongodb.js +376 -0
- package/dist/adapters/mongodb.js.map +1 -0
- package/dist/browser.cjs +96 -0
- package/dist/browser.cjs.map +1 -0
- package/dist/browser.d.cts +45 -0
- package/dist/browser.d.ts +45 -0
- package/dist/browser.js +89 -0
- package/dist/browser.js.map +1 -0
- package/dist/chunk-BV3U542P.js +11 -0
- package/dist/chunk-BV3U542P.js.map +1 -0
- package/dist/chunk-NYDVNMKG.js +62 -0
- package/dist/chunk-NYDVNMKG.js.map +1 -0
- package/dist/chunk-OZSOIX23.js +120 -0
- package/dist/chunk-OZSOIX23.js.map +1 -0
- package/dist/chunk-PTGHMCCG.js +23 -0
- package/dist/chunk-PTGHMCCG.js.map +1 -0
- package/dist/chunk-W3BMEPPQ.js +50 -0
- package/dist/chunk-W3BMEPPQ.js.map +1 -0
- package/dist/client.cjs +160 -0
- package/dist/client.cjs.map +1 -0
- package/dist/client.d.cts +42 -0
- package/dist/client.d.ts +42 -0
- package/dist/client.js +158 -0
- package/dist/client.js.map +1 -0
- package/dist/index.cjs +920 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +203 -0
- package/dist/index.d.ts +203 -0
- package/dist/index.js +723 -0
- package/dist/index.js.map +1 -0
- package/dist/node.cjs +49 -0
- package/dist/node.cjs.map +1 -0
- package/dist/node.d.cts +8 -0
- package/dist/node.d.ts +8 -0
- package/dist/node.js +46 -0
- package/dist/node.js.map +1 -0
- package/dist/plugin-Bqj9xAOS.d.cts +217 -0
- package/dist/plugin-bTJKmnlq.d.ts +217 -0
- package/dist/plugins/digests.cjs +206 -0
- package/dist/plugins/digests.cjs.map +1 -0
- package/dist/plugins/digests.d.cts +68 -0
- package/dist/plugins/digests.d.ts +68 -0
- package/dist/plugins/digests.js +201 -0
- package/dist/plugins/digests.js.map +1 -0
- package/dist/plugins/preferences-client.cjs +23 -0
- package/dist/plugins/preferences-client.cjs.map +1 -0
- package/dist/plugins/preferences-client.d.cts +29 -0
- package/dist/plugins/preferences-client.d.ts +29 -0
- package/dist/plugins/preferences-client.js +18 -0
- package/dist/plugins/preferences-client.js.map +1 -0
- package/dist/plugins/preferences-react.cjs +125 -0
- package/dist/plugins/preferences-react.cjs.map +1 -0
- package/dist/plugins/preferences-react.d.cts +40 -0
- package/dist/plugins/preferences-react.d.ts +40 -0
- package/dist/plugins/preferences-react.js +101 -0
- package/dist/plugins/preferences-react.js.map +1 -0
- package/dist/plugins/preferences.cjs +147 -0
- package/dist/plugins/preferences.cjs.map +1 -0
- package/dist/plugins/preferences.d.cts +22 -0
- package/dist/plugins/preferences.d.ts +22 -0
- package/dist/plugins/preferences.js +119 -0
- package/dist/plugins/preferences.js.map +1 -0
- package/dist/plugins/push.cjs +146 -0
- package/dist/plugins/push.cjs.map +1 -0
- package/dist/plugins/push.d.cts +60 -0
- package/dist/plugins/push.d.ts +60 -0
- package/dist/plugins/push.js +144 -0
- package/dist/plugins/push.js.map +1 -0
- package/dist/provider-CA45WVqt.d.cts +23 -0
- package/dist/provider-CA45WVqt.d.ts +23 -0
- package/dist/providers/resend.cjs +69 -0
- package/dist/providers/resend.cjs.map +1 -0
- package/dist/providers/resend.d.cts +20 -0
- package/dist/providers/resend.d.ts +20 -0
- package/dist/providers/resend.js +66 -0
- package/dist/providers/resend.js.map +1 -0
- package/dist/providers/web-push.cjs +211 -0
- package/dist/providers/web-push.cjs.map +1 -0
- package/dist/providers/web-push.d.cts +33 -0
- package/dist/providers/web-push.d.ts +33 -0
- package/dist/providers/web-push.js +193 -0
- package/dist/providers/web-push.js.map +1 -0
- package/dist/react.cjs +218 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +57 -0
- package/dist/react.d.ts +57 -0
- package/dist/react.js +193 -0
- package/dist/react.js.map +1 -0
- package/dist/schema.cjs +263 -0
- package/dist/schema.cjs.map +1 -0
- package/dist/schema.d.cts +180 -0
- package/dist/schema.d.ts +180 -0
- package/dist/schema.js +200 -0
- package/dist/schema.js.map +1 -0
- package/dist/testing.cjs +342 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +38 -0
- package/dist/testing.d.ts +38 -0
- package/dist/testing.js +340 -0
- package/dist/testing.js.map +1 -0
- package/package.json +168 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// src/plugins/digests/schedule.ts
|
|
2
|
+
var WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
3
|
+
function localMoment(timezone, now) {
|
|
4
|
+
const formatter = new Intl.DateTimeFormat("en-CA", {
|
|
5
|
+
timeZone: timezone,
|
|
6
|
+
year: "numeric",
|
|
7
|
+
month: "2-digit",
|
|
8
|
+
day: "2-digit",
|
|
9
|
+
hour: "2-digit",
|
|
10
|
+
hourCycle: "h23",
|
|
11
|
+
weekday: "short"
|
|
12
|
+
});
|
|
13
|
+
const parts = {};
|
|
14
|
+
for (const part of formatter.formatToParts(now)) parts[part.type] = part.value;
|
|
15
|
+
return {
|
|
16
|
+
date: `${parts.year}-${parts.month}-${parts.day}`,
|
|
17
|
+
hour: Number(parts.hour ?? 0),
|
|
18
|
+
weekday: Math.max(0, WEEKDAYS.indexOf(parts.weekday ?? "Sun"))
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
var shiftDays = (date, days) => {
|
|
22
|
+
const shifted = /* @__PURE__ */ new Date(`${date}T00:00:00Z`);
|
|
23
|
+
shifted.setUTCDate(shifted.getUTCDate() - days);
|
|
24
|
+
return shifted.toISOString().slice(0, 10);
|
|
25
|
+
};
|
|
26
|
+
function periodKey(window, moment, sendWeekday) {
|
|
27
|
+
if (window === "daily") return moment.date;
|
|
28
|
+
const since = (moment.weekday - sendWeekday + 7) % 7;
|
|
29
|
+
return shiftDays(moment.date, since);
|
|
30
|
+
}
|
|
31
|
+
function isDue(window, moment, sendHour, sendWeekday) {
|
|
32
|
+
if (window === "daily") return moment.hour >= sendHour;
|
|
33
|
+
const period = periodKey("weekly", moment, sendWeekday);
|
|
34
|
+
return moment.date > period || moment.hour >= sendHour;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// src/plugins/digests/index.ts
|
|
38
|
+
var ENTRY = "notification_digest_entry";
|
|
39
|
+
var STATE = "notification_digest_state";
|
|
40
|
+
var isDigestWindow = (frequency) => frequency === "daily" || frequency === "weekly";
|
|
41
|
+
function digests(options = {}) {
|
|
42
|
+
const digestType = options.digestType ?? "digest";
|
|
43
|
+
const sendHour = options.sendHour ?? 9;
|
|
44
|
+
const sendWeekday = options.sendWeekday ?? 1;
|
|
45
|
+
const channel = options.channel ?? "email";
|
|
46
|
+
const maxUsersPerRun = options.maxUsersPerRun ?? 500;
|
|
47
|
+
let ctx;
|
|
48
|
+
async function windowFor(userIds, type) {
|
|
49
|
+
const rows = await ctx.store.find("notification_preference", { userId: { in: [...userIds] }, type, channel });
|
|
50
|
+
const windows = /* @__PURE__ */ new Map();
|
|
51
|
+
for (const row of rows) {
|
|
52
|
+
if (row.enabled && isDigestWindow(row.frequency)) windows.set(row.userId, row.frequency);
|
|
53
|
+
}
|
|
54
|
+
return windows;
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
id: "digests",
|
|
58
|
+
dependsOn: ["preferences"],
|
|
59
|
+
init: (context) => {
|
|
60
|
+
ctx = context;
|
|
61
|
+
},
|
|
62
|
+
schema: {
|
|
63
|
+
digestEntry: {
|
|
64
|
+
tableName: ENTRY,
|
|
65
|
+
fields: {
|
|
66
|
+
id: { type: "string", required: true },
|
|
67
|
+
userId: { type: "string", required: true },
|
|
68
|
+
type: { type: "string", required: true },
|
|
69
|
+
notificationId: { type: "string", required: true },
|
|
70
|
+
payload: { type: "json" },
|
|
71
|
+
window: { type: "string", required: true },
|
|
72
|
+
createdAt: { type: "date", required: true, defaultNow: true }
|
|
73
|
+
},
|
|
74
|
+
primaryKey: ["id"],
|
|
75
|
+
indexes: [{ on: ["userId", "window"], name: "digest_entry_user_idx" }]
|
|
76
|
+
},
|
|
77
|
+
digestState: {
|
|
78
|
+
tableName: STATE,
|
|
79
|
+
fields: {
|
|
80
|
+
userId: { type: "string", required: true },
|
|
81
|
+
window: { type: "string", required: true },
|
|
82
|
+
/** Last period actually sent. The idempotency key for the schedule. */
|
|
83
|
+
lastPeriod: { type: "string", required: true },
|
|
84
|
+
updatedAt: { type: "date", required: true, defaultNow: true }
|
|
85
|
+
},
|
|
86
|
+
primaryKey: ["userId", "window"]
|
|
87
|
+
},
|
|
88
|
+
// Read-only here; owned by the preferences plugin.
|
|
89
|
+
notificationPreference: {
|
|
90
|
+
tableName: "notification_preference",
|
|
91
|
+
fields: {
|
|
92
|
+
userId: { type: "string", required: true },
|
|
93
|
+
type: { type: "string", required: true },
|
|
94
|
+
channel: { type: "string", required: true },
|
|
95
|
+
enabled: { type: "boolean", required: true },
|
|
96
|
+
frequency: { type: "string", required: true }
|
|
97
|
+
},
|
|
98
|
+
primaryKey: ["userId", "type", "channel"]
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
hooks: {
|
|
102
|
+
prepare: async (context) => {
|
|
103
|
+
if (context.type === digestType) return { deferred: /* @__PURE__ */ new Map() };
|
|
104
|
+
return {
|
|
105
|
+
deferred: await windowFor(
|
|
106
|
+
context.recipients.map((recipient) => recipient.userId),
|
|
107
|
+
context.type
|
|
108
|
+
)
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
resolveChannels: (context) => {
|
|
112
|
+
const prepared = context.prepared;
|
|
113
|
+
if (!prepared?.deferred.has(context.recipient.userId)) return context.decisions;
|
|
114
|
+
return context.decisions.map(
|
|
115
|
+
(decision) => decision.channel === channel ? { ...decision, action: "skip" } : decision
|
|
116
|
+
);
|
|
117
|
+
},
|
|
118
|
+
// The only hook that sees notification ids.
|
|
119
|
+
afterSend: async (context) => {
|
|
120
|
+
const prepared = context.prepared;
|
|
121
|
+
if (!prepared || prepared.deferred.size === 0) return;
|
|
122
|
+
const entries = context.notifications.filter((notification) => prepared.deferred.has(notification.userId)).map((notification) => ({
|
|
123
|
+
id: crypto.randomUUID(),
|
|
124
|
+
userId: notification.userId,
|
|
125
|
+
type: context.type,
|
|
126
|
+
notificationId: notification.id,
|
|
127
|
+
payload: context.payload,
|
|
128
|
+
window: prepared.deferred.get(notification.userId),
|
|
129
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
130
|
+
}));
|
|
131
|
+
if (entries.length > 0) await ctx.store.insert(ENTRY, entries);
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
routes: [
|
|
135
|
+
{
|
|
136
|
+
/**
|
|
137
|
+
* Point an hourly cron here. Hourly rather than daily because "9am"
|
|
138
|
+
* is 24 different UTC moments, and which ones shift with DST.
|
|
139
|
+
*/
|
|
140
|
+
path: "/digests/cron",
|
|
141
|
+
method: "POST",
|
|
142
|
+
scope: { type: "machine" },
|
|
143
|
+
handler: async () => {
|
|
144
|
+
const now = /* @__PURE__ */ new Date();
|
|
145
|
+
const pending = await ctx.store.find(ENTRY, {}, { limit: 1e4 });
|
|
146
|
+
if (pending.length === 0) return Response.json({ sent: 0, users: 0 });
|
|
147
|
+
const byUser = /* @__PURE__ */ new Map();
|
|
148
|
+
for (const entry of pending) {
|
|
149
|
+
const bucket = byUser.get(entry.userId);
|
|
150
|
+
if (bucket) bucket.push(entry);
|
|
151
|
+
else byUser.set(entry.userId, [entry]);
|
|
152
|
+
}
|
|
153
|
+
const userIds = [...byUser.keys()].slice(0, maxUsersPerRun);
|
|
154
|
+
const recipients = await ctx.getRecipients(userIds);
|
|
155
|
+
const zones = new Map(recipients.map((r) => [r.userId, r.timezone]));
|
|
156
|
+
const states = await ctx.store.find(STATE, { userId: { in: userIds } });
|
|
157
|
+
const lastPeriods = new Map(states.map((s) => [`${s.userId} ${s.window}`, s.lastPeriod]));
|
|
158
|
+
let sent = 0;
|
|
159
|
+
for (const userId of userIds) {
|
|
160
|
+
const entries = byUser.get(userId) ?? [];
|
|
161
|
+
const moment = localMoment(zones.get(userId) ?? "UTC", now);
|
|
162
|
+
for (const window of ["daily", "weekly"]) {
|
|
163
|
+
const forWindow = entries.filter((entry) => entry.window === window);
|
|
164
|
+
if (forWindow.length === 0) continue;
|
|
165
|
+
const period = periodKey(window, moment, sendWeekday);
|
|
166
|
+
if (lastPeriods.get(`${userId} ${window}`) === period) continue;
|
|
167
|
+
if (!isDue(window, moment, sendHour, sendWeekday)) continue;
|
|
168
|
+
await ctx.send(digestType, {
|
|
169
|
+
to: userId,
|
|
170
|
+
payload: {
|
|
171
|
+
window,
|
|
172
|
+
period,
|
|
173
|
+
items: forWindow.map((entry) => ({
|
|
174
|
+
type: entry.type,
|
|
175
|
+
payload: entry.payload,
|
|
176
|
+
createdAt: entry.createdAt
|
|
177
|
+
}))
|
|
178
|
+
},
|
|
179
|
+
dedupeKey: `digest:${window}:${period}:${userId}`
|
|
180
|
+
});
|
|
181
|
+
await ctx.store.upsert(
|
|
182
|
+
STATE,
|
|
183
|
+
[{ userId, window, lastPeriod: period, updatedAt: /* @__PURE__ */ new Date() }],
|
|
184
|
+
{ onConflict: ["userId", "window"] }
|
|
185
|
+
);
|
|
186
|
+
await ctx.store.remove(ENTRY, {
|
|
187
|
+
id: { in: forWindow.map((entry) => entry.id) }
|
|
188
|
+
});
|
|
189
|
+
sent += 1;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return Response.json({ sent, users: userIds.length });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
]
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export { digests, isDue, localMoment, periodKey };
|
|
200
|
+
//# sourceMappingURL=digests.js.map
|
|
201
|
+
//# sourceMappingURL=digests.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/plugins/digests/schedule.ts","../../src/plugins/digests/index.ts"],"names":[],"mappings":";AAUA,IAAM,QAAA,GAAW,CAAC,KAAA,EAAO,KAAA,EAAO,OAAO,KAAA,EAAO,KAAA,EAAO,OAAO,KAAK,CAAA;AAS1D,SAAS,WAAA,CAAY,UAAkB,GAAA,EAAwB;AACpE,EAAA,MAAM,SAAA,GAAY,IAAI,IAAA,CAAK,cAAA,CAAe,OAAA,EAAS;AAAA,IACjD,QAAA,EAAU,QAAA;AAAA,IACV,IAAA,EAAM,SAAA;AAAA,IACN,KAAA,EAAO,SAAA;AAAA,IACP,GAAA,EAAK,SAAA;AAAA,IACL,IAAA,EAAM,SAAA;AAAA,IACN,SAAA,EAAW,KAAA;AAAA,IACX,OAAA,EAAS;AAAA,GACV,CAAA;AAED,EAAA,MAAM,QAAgC,EAAC;AACvC,EAAA,KAAA,MAAW,IAAA,IAAQ,UAAU,aAAA,CAAc,GAAG,GAAG,KAAA,CAAM,IAAA,CAAK,IAAI,CAAA,GAAI,IAAA,CAAK,KAAA;AAEzE,EAAA,OAAO;AAAA,IACL,IAAA,EAAM,GAAG,KAAA,CAAM,IAAI,IAAI,KAAA,CAAM,KAAK,CAAA,CAAA,EAAI,KAAA,CAAM,GAAG,CAAA,CAAA;AAAA,IAC/C,IAAA,EAAM,MAAA,CAAO,KAAA,CAAM,IAAA,IAAQ,CAAC,CAAA;AAAA,IAC5B,OAAA,EAAS,KAAK,GAAA,CAAI,CAAA,EAAG,SAAS,OAAA,CAAQ,KAAA,CAAM,OAAA,IAAW,KAAK,CAAC;AAAA,GAC/D;AACF;AAEA,IAAM,SAAA,GAAY,CAAC,IAAA,EAAc,IAAA,KAAyB;AACxD,EAAA,MAAM,OAAA,mBAAU,IAAI,IAAA,CAAK,CAAA,EAAG,IAAI,CAAA,UAAA,CAAY,CAAA;AAC5C,EAAA,OAAA,CAAQ,UAAA,CAAW,OAAA,CAAQ,UAAA,EAAW,GAAI,IAAI,CAAA;AAC9C,EAAA,OAAO,OAAA,CAAQ,WAAA,EAAY,CAAE,KAAA,CAAM,GAAG,EAAE,CAAA;AAC1C,CAAA;AAUO,SAAS,SAAA,CAAU,MAAA,EAAsB,MAAA,EAAqB,WAAA,EAA6B;AAChG,EAAA,IAAI,MAAA,KAAW,OAAA,EAAS,OAAO,MAAA,CAAO,IAAA;AAEtC,EAAA,MAAM,KAAA,GAAA,CAAS,MAAA,CAAO,OAAA,GAAU,WAAA,GAAc,CAAA,IAAK,CAAA;AACnD,EAAA,OAAO,SAAA,CAAU,MAAA,CAAO,IAAA,EAAM,KAAK,CAAA;AACrC;AAGO,SAAS,KAAA,CACd,MAAA,EACA,MAAA,EACA,QAAA,EACA,WAAA,EACS;AACT,EAAA,IAAI,MAAA,KAAW,OAAA,EAAS,OAAO,MAAA,CAAO,IAAA,IAAQ,QAAA;AAE9C,EAAA,MAAM,MAAA,GAAS,SAAA,CAAU,QAAA,EAAU,MAAA,EAAQ,WAAW,CAAA;AAEtD,EAAA,OAAO,MAAA,CAAO,IAAA,GAAO,MAAA,IAAU,MAAA,CAAO,IAAA,IAAQ,QAAA;AAChD;;;AC/DA,IAAM,KAAA,GAAQ,2BAAA;AACd,IAAM,KAAA,GAAQ,2BAAA;AAiCd,IAAM,cAAA,GAAiB,CAAC,SAAA,KACtB,SAAA,KAAc,WAAW,SAAA,KAAc,QAAA;AAYlC,SAAS,OAAA,CAAQ,OAAA,GAA0B,EAAC,EAA8B;AAC/E,EAAA,MAAM,UAAA,GAAa,QAAQ,UAAA,IAAc,QAAA;AACzC,EAAA,MAAM,QAAA,GAAW,QAAQ,QAAA,IAAY,CAAA;AACrC,EAAA,MAAM,WAAA,GAAc,QAAQ,WAAA,IAAe,CAAA;AAC3C,EAAA,MAAM,OAAA,GAAU,QAAQ,OAAA,IAAW,OAAA;AACnC,EAAA,MAAM,cAAA,GAAiB,QAAQ,cAAA,IAAkB,GAAA;AAEjD,EAAA,IAAI,GAAA;AAGJ,EAAA,eAAe,SAAA,CACb,SACA,IAAA,EACoC;AACpC,IAAA,MAAM,OAAO,MAAM,GAAA,CAAI,KAAA,CAAM,IAAA,CAM1B,2BAA2B,EAAE,MAAA,EAAQ,EAAE,EAAA,EAAI,CAAC,GAAG,OAAO,GAAE,EAAG,IAAA,EAAM,SAAS,CAAA;AAE7E,IAAA,MAAM,OAAA,uBAAc,GAAA,EAA0B;AAC9C,IAAA,KAAA,MAAW,OAAO,IAAA,EAAM;AACtB,MAAA,IAAI,GAAA,CAAI,OAAA,IAAW,cAAA,CAAe,GAAA,CAAI,SAAS,CAAA,EAAG,OAAA,CAAQ,GAAA,CAAI,GAAA,CAAI,MAAA,EAAQ,GAAA,CAAI,SAAS,CAAA;AAAA,IACzF;AACA,IAAA,OAAO,OAAA;AAAA,EACT;AAEA,EAAA,OAAO;AAAA,IACL,EAAA,EAAI,SAAA;AAAA,IACJ,SAAA,EAAW,CAAC,aAAa,CAAA;AAAA,IAEzB,IAAA,EAAM,CAAC,OAAA,KAAY;AACjB,MAAA,GAAA,GAAM,OAAA;AAAA,IACR,CAAA;AAAA,IAEA,MAAA,EAAQ;AAAA,MACN,WAAA,EAAa;AAAA,QACX,SAAA,EAAW,KAAA;AAAA,QACX,MAAA,EAAQ;AAAA,UACN,EAAA,EAAI,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA,EAAK;AAAA,UACrC,MAAA,EAAQ,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA,EAAK;AAAA,UACzC,IAAA,EAAM,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA,EAAK;AAAA,UACvC,cAAA,EAAgB,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA,EAAK;AAAA,UACjD,OAAA,EAAS,EAAE,IAAA,EAAM,MAAA,EAAO;AAAA,UACxB,MAAA,EAAQ,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA,EAAK;AAAA,UACzC,WAAW,EAAE,IAAA,EAAM,QAAQ,QAAA,EAAU,IAAA,EAAM,YAAY,IAAA;AAAK,SAC9D;AAAA,QACA,UAAA,EAAY,CAAC,IAAI,CAAA;AAAA,QACjB,OAAA,EAAS,CAAC,EAAE,EAAA,EAAI,CAAC,UAAU,QAAQ,CAAA,EAAG,IAAA,EAAM,uBAAA,EAAyB;AAAA,OACvE;AAAA,MAEA,WAAA,EAAa;AAAA,QACX,SAAA,EAAW,KAAA;AAAA,QACX,MAAA,EAAQ;AAAA,UACN,MAAA,EAAQ,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA,EAAK;AAAA,UACzC,MAAA,EAAQ,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA,EAAK;AAAA;AAAA,UAEzC,UAAA,EAAY,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA,EAAK;AAAA,UAC7C,WAAW,EAAE,IAAA,EAAM,QAAQ,QAAA,EAAU,IAAA,EAAM,YAAY,IAAA;AAAK,SAC9D;AAAA,QACA,UAAA,EAAY,CAAC,QAAA,EAAU,QAAQ;AAAA,OACjC;AAAA;AAAA,MAGA,sBAAA,EAAwB;AAAA,QACtB,SAAA,EAAW,yBAAA;AAAA,QACX,MAAA,EAAQ;AAAA,UACN,MAAA,EAAQ,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA,EAAK;AAAA,UACzC,IAAA,EAAM,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA,EAAK;AAAA,UACvC,OAAA,EAAS,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA,EAAK;AAAA,UAC1C,OAAA,EAAS,EAAE,IAAA,EAAM,SAAA,EAAW,UAAU,IAAA,EAAK;AAAA,UAC3C,SAAA,EAAW,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA;AAAK,SAC9C;AAAA,QACA,UAAA,EAAY,CAAC,QAAA,EAAU,MAAA,EAAQ,SAAS;AAAA;AAC1C,KACF;AAAA,IAEA,KAAA,EAAO;AAAA,MACL,OAAA,EAAS,OAAO,OAAA,KAA+C;AAC7D,QAAA,IAAI,OAAA,CAAQ,SAAS,UAAA,EAAY,OAAO,EAAE,QAAA,kBAAU,IAAI,KAAI,EAAE;AAE9D,QAAA,OAAO;AAAA,UACL,UAAU,MAAM,SAAA;AAAA,YACd,QAAQ,UAAA,CAAW,GAAA,CAAI,CAAC,SAAA,KAAc,UAAU,MAAM,CAAA;AAAA,YACtD,OAAA,CAAQ;AAAA;AACV,SACF;AAAA,MACF,CAAA;AAAA,MAEA,eAAA,EAAiB,CAAC,OAAA,KAAoC;AACpD,QAAA,MAAM,WAAW,OAAA,CAAQ,QAAA;AACzB,QAAA,IAAI,CAAC,UAAU,QAAA,CAAS,GAAA,CAAI,QAAQ,SAAA,CAAU,MAAM,CAAA,EAAG,OAAO,OAAA,CAAQ,SAAA;AAEtE,QAAA,OAAO,QAAQ,SAAA,CAAU,GAAA;AAAA,UAAI,CAAC,QAAA,KAC5B,QAAA,CAAS,OAAA,KAAY,OAAA,GAAU,EAAE,GAAG,QAAA,EAAU,MAAA,EAAQ,MAAA,EAAgB,GAAI;AAAA,SAC5E;AAAA,MACF,CAAA;AAAA;AAAA,MAGA,SAAA,EAAW,OAAO,OAAA,KAA8B;AAC9C,QAAA,MAAM,WAAW,OAAA,CAAQ,QAAA;AACzB,QAAA,IAAI,CAAC,QAAA,IAAY,QAAA,CAAS,QAAA,CAAS,SAAS,CAAA,EAAG;AAE/C,QAAA,MAAM,OAAA,GAAU,OAAA,CAAQ,aAAA,CACrB,MAAA,CAAO,CAAC,YAAA,KAAiB,QAAA,CAAS,QAAA,CAAS,GAAA,CAAI,aAAa,MAAM,CAAC,CAAA,CACnE,GAAA,CAAI,CAAC,YAAA,MAAkB;AAAA,UACtB,EAAA,EAAI,OAAO,UAAA,EAAW;AAAA,UACtB,QAAQ,YAAA,CAAa,MAAA;AAAA,UACrB,MAAM,OAAA,CAAQ,IAAA;AAAA,UACd,gBAAgB,YAAA,CAAa,EAAA;AAAA,UAC7B,SAAS,OAAA,CAAQ,OAAA;AAAA,UACjB,MAAA,EAAQ,QAAA,CAAS,QAAA,CAAS,GAAA,CAAI,aAAa,MAAM,CAAA;AAAA,UACjD,SAAA,sBAAe,IAAA;AAAK,SACtB,CAAE,CAAA;AAEJ,QAAA,IAAI,OAAA,CAAQ,SAAS,CAAA,EAAG,MAAM,IAAI,KAAA,CAAM,MAAA,CAAO,OAAO,OAAO,CAAA;AAAA,MAC/D;AAAA,KACF;AAAA,IAEA,MAAA,EAAQ;AAAA,MACN;AAAA;AAAA;AAAA;AAAA;AAAA,QAKE,IAAA,EAAM,eAAA;AAAA,QACN,MAAA,EAAQ,MAAA;AAAA,QACR,KAAA,EAAO,EAAE,IAAA,EAAM,SAAA,EAAU;AAAA,QACzB,SAAS,YAAY;AACnB,UAAA,MAAM,GAAA,uBAAU,IAAA,EAAK;AAErB,UAAA,MAAM,OAAA,GAAU,MAAM,GAAA,CAAI,KAAA,CAAM,IAAA,CAAkB,KAAA,EAAO,EAAC,EAAG,EAAE,KAAA,EAAO,GAAA,EAAQ,CAAA;AAC9E,UAAA,IAAI,OAAA,CAAQ,MAAA,KAAW,CAAA,EAAG,OAAO,QAAA,CAAS,IAAA,CAAK,EAAE,IAAA,EAAM,CAAA,EAAG,KAAA,EAAO,CAAA,EAAG,CAAA;AAEpE,UAAA,MAAM,MAAA,uBAAa,GAAA,EAA2B;AAC9C,UAAA,KAAA,MAAW,SAAS,OAAA,EAAS;AAC3B,YAAA,MAAM,MAAA,GAAS,MAAA,CAAO,GAAA,CAAI,KAAA,CAAM,MAAM,CAAA;AACtC,YAAA,IAAI,MAAA,EAAQ,MAAA,CAAO,IAAA,CAAK,KAAK,CAAA;AAAA,wBACjB,GAAA,CAAI,KAAA,CAAM,MAAA,EAAQ,CAAC,KAAK,CAAC,CAAA;AAAA,UACvC;AAEA,UAAA,MAAM,OAAA,GAAU,CAAC,GAAG,MAAA,CAAO,MAAM,CAAA,CAAE,KAAA,CAAM,CAAA,EAAG,cAAc,CAAA;AAC1D,UAAA,MAAM,UAAA,GAAa,MAAM,GAAA,CAAI,aAAA,CAAc,OAAO,CAAA;AAClD,UAAA,MAAM,KAAA,GAAQ,IAAI,GAAA,CAAI,UAAA,CAAW,GAAA,CAAI,CAAC,CAAA,KAAM,CAAC,CAAA,CAAE,MAAA,EAAQ,CAAA,CAAE,QAAQ,CAAC,CAAC,CAAA;AAEnE,UAAA,MAAM,MAAA,GAAS,MAAM,GAAA,CAAI,KAAA,CAAM,IAAA,CAI5B,KAAA,EAAO,EAAE,MAAA,EAAQ,EAAE,EAAA,EAAI,OAAA,EAAQ,EAAG,CAAA;AAErC,UAAA,MAAM,cAAc,IAAI,GAAA,CAAI,OAAO,GAAA,CAAI,CAAC,MAAM,CAAC,CAAA,EAAG,CAAA,CAAE,MAAM,IAAI,CAAA,CAAE,MAAM,IAAI,CAAA,CAAE,UAAU,CAAC,CAAC,CAAA;AAExF,UAAA,IAAI,IAAA,GAAO,CAAA;AAEX,UAAA,KAAA,MAAW,UAAU,OAAA,EAAS;AAC5B,YAAA,MAAM,OAAA,GAAU,MAAA,CAAO,GAAA,CAAI,MAAM,KAAK,EAAC;AACvC,YAAA,MAAM,SAAS,WAAA,CAAY,KAAA,CAAM,IAAI,MAAM,CAAA,IAAK,OAAO,GAAG,CAAA;AAE1D,YAAA,KAAA,MAAW,MAAA,IAAU,CAAC,OAAA,EAAS,QAAQ,CAAA,EAAY;AACjD,cAAA,MAAM,YAAY,OAAA,CAAQ,MAAA,CAAO,CAAC,KAAA,KAAU,KAAA,CAAM,WAAW,MAAM,CAAA;AACnE,cAAA,IAAI,SAAA,CAAU,WAAW,CAAA,EAAG;AAE5B,cAAA,MAAM,MAAA,GAAS,SAAA,CAAU,MAAA,EAAQ,MAAA,EAAQ,WAAW,CAAA;AAGpD,cAAA,IAAI,WAAA,CAAY,IAAI,CAAA,EAAG,MAAM,IAAI,MAAM,CAAA,CAAE,MAAM,MAAA,EAAQ;AACvD,cAAA,IAAI,CAAC,KAAA,CAAM,MAAA,EAAQ,MAAA,EAAQ,QAAA,EAAU,WAAW,CAAA,EAAG;AAEnD,cAAA,MAAM,GAAA,CAAI,KAAK,UAAA,EAAY;AAAA,gBACzB,EAAA,EAAI,MAAA;AAAA,gBACJ,OAAA,EAAS;AAAA,kBACP,MAAA;AAAA,kBACA,MAAA;AAAA,kBACA,KAAA,EAAO,SAAA,CAAU,GAAA,CAAI,CAAC,KAAA,MAAW;AAAA,oBAC/B,MAAM,KAAA,CAAM,IAAA;AAAA,oBACZ,SAAS,KAAA,CAAM,OAAA;AAAA,oBACf,WAAW,KAAA,CAAM;AAAA,mBACnB,CAAE;AAAA,iBACJ;AAAA,gBACA,WAAW,CAAA,OAAA,EAAU,MAAM,CAAA,CAAA,EAAI,MAAM,IAAI,MAAM,CAAA;AAAA,eAChD,CAAA;AAED,cAAA,MAAM,IAAI,KAAA,CAAM,MAAA;AAAA,gBACd,KAAA;AAAA,gBACA,CAAC,EAAE,MAAA,EAAQ,MAAA,EAAQ,UAAA,EAAY,QAAQ,SAAA,kBAAW,IAAI,IAAA,EAAK,EAAG,CAAA;AAAA,gBAC9D,EAAE,UAAA,EAAY,CAAC,QAAA,EAAU,QAAQ,CAAA;AAAE,eACrC;AAIA,cAAA,MAAM,GAAA,CAAI,KAAA,CAAM,MAAA,CAAO,KAAA,EAAO;AAAA,gBAC5B,EAAA,EAAI,EAAE,EAAA,EAAI,SAAA,CAAU,IAAI,CAAC,KAAA,KAAU,KAAA,CAAM,EAAE,CAAA;AAAE,eAC9C,CAAA;AAED,cAAA,IAAA,IAAQ,CAAA;AAAA,YACV;AAAA,UACF;AAEA,UAAA,OAAO,SAAS,IAAA,CAAK,EAAE,MAAM,KAAA,EAAO,OAAA,CAAQ,QAAQ,CAAA;AAAA,QACtD;AAAA;AACF;AACF,GACF;AACF","file":"digests.js","sourcesContent":["export type DigestWindow = \"daily\" | \"weekly\";\n\nexport type LocalMoment = {\n /** YYYY-MM-DD in the recipient's zone. */\n date: string;\n hour: number;\n /** 0 = Sunday. */\n weekday: number;\n};\n\nconst WEEKDAYS = [\"Sun\", \"Mon\", \"Tue\", \"Wed\", \"Thu\", \"Fri\", \"Sat\"];\n\n/**\n * Reads the wall clock in a recipient's zone.\n *\n * hourCycle h23 rather than hour12:false — the latter renders midnight as \"24\"\n * in several locales, which silently makes the send-hour comparison wrong for\n * one hour a day.\n */\nexport function localMoment(timezone: string, now: Date): LocalMoment {\n const formatter = new Intl.DateTimeFormat(\"en-CA\", {\n timeZone: timezone,\n year: \"numeric\",\n month: \"2-digit\",\n day: \"2-digit\",\n hour: \"2-digit\",\n hourCycle: \"h23\",\n weekday: \"short\",\n });\n\n const parts: Record<string, string> = {};\n for (const part of formatter.formatToParts(now)) parts[part.type] = part.value;\n\n return {\n date: `${parts.year}-${parts.month}-${parts.day}`,\n hour: Number(parts.hour ?? 0),\n weekday: Math.max(0, WEEKDAYS.indexOf(parts.weekday ?? \"Sun\")),\n };\n}\n\nconst shiftDays = (date: string, days: number): string => {\n const shifted = new Date(`${date}T00:00:00Z`);\n shifted.setUTCDate(shifted.getUTCDate() - days);\n return shifted.toISOString().slice(0, 10);\n};\n\n/**\n * Identifies the digest period a moment falls in.\n *\n * Scheduling keys off this string rather than bucketing users by UTC offset.\n * Offsets shift twice a year, and getting that wrong sends someone two digests\n * or none. A period key cannot: it is derived from the local calendar, so DST\n * is already accounted for and a missed cron run simply catches up.\n */\nexport function periodKey(window: DigestWindow, moment: LocalMoment, sendWeekday: number): string {\n if (window === \"daily\") return moment.date;\n\n const since = (moment.weekday - sendWeekday + 7) % 7;\n return shiftDays(moment.date, since);\n}\n\n/** Whether the send moment for the current period has arrived. */\nexport function isDue(\n window: DigestWindow,\n moment: LocalMoment,\n sendHour: number,\n sendWeekday: number,\n): boolean {\n if (window === \"daily\") return moment.hour >= sendHour;\n\n const period = periodKey(\"weekly\", moment, sendWeekday);\n // Past the send day means a run was missed; catch up rather than skip a week.\n return moment.date > period || moment.hour >= sendHour;\n}\n","import type {\n AfterSendContext,\n EasyPingPlugin,\n PluginInitContext,\n PrepareContext,\n ResolveChannelsContext,\n} from \"../../core/plugin\";\nimport type { Frequency } from \"../../core/types\";\nimport { type DigestWindow, isDue, localMoment, periodKey } from \"./schedule\";\n\nconst ENTRY = \"notification_digest_entry\";\nconst STATE = \"notification_digest_state\";\n\nexport type DigestEntry = {\n id: string;\n userId: string;\n type: string;\n notificationId: string;\n payload: unknown;\n window: DigestWindow;\n createdAt: Date;\n};\n\nexport type DigestsOptions = {\n /**\n * The notification type the composed digest is sent as. The app defines it\n * with an email template taking `{ items }`.\n */\n digestType?: string;\n /** Local hour at which a digest becomes due. */\n sendHour?: number;\n /** Weekly digests only; 0 = Sunday. */\n sendWeekday?: number;\n /** Channels a digest defers. Only email makes sense today. */\n channel?: \"email\";\n /** Safety valve on one cron run. */\n maxUsersPerRun?: number;\n};\n\ntype Prepared = {\n /** userId -> window, for recipients whose preference defers this type. */\n deferred: Map<string, DigestWindow>;\n};\n\nconst isDigestWindow = (frequency: Frequency): frequency is DigestWindow =>\n frequency === \"daily\" || frequency === \"weekly\";\n\n/**\n * Collects notifications a user has chosen to receive on a schedule, then\n * sends them as one message.\n *\n * Deliberately not implemented with `defer`: deferring N emails to 9am sends N\n * emails at 9am. A digest is one email containing N items, so the channel is\n * skipped and the item is bucketed instead.\n *\n * Depends on `preferences` for the per-type frequency it reads.\n */\nexport function digests(options: DigestsOptions = {}): EasyPingPlugin<\"digests\"> {\n const digestType = options.digestType ?? \"digest\";\n const sendHour = options.sendHour ?? 9;\n const sendWeekday = options.sendWeekday ?? 1;\n const channel = options.channel ?? \"email\";\n const maxUsersPerRun = options.maxUsersPerRun ?? 500;\n\n let ctx: PluginInitContext;\n\n /** Reads the frequency the preferences plugin stored for this type. */\n async function windowFor(\n userIds: readonly string[],\n type: string,\n ): Promise<Map<string, DigestWindow>> {\n const rows = await ctx.store.find<{\n userId: string;\n type: string;\n channel: string;\n frequency: Frequency;\n enabled: boolean;\n }>(\"notification_preference\", { userId: { in: [...userIds] }, type, channel });\n\n const windows = new Map<string, DigestWindow>();\n for (const row of rows) {\n if (row.enabled && isDigestWindow(row.frequency)) windows.set(row.userId, row.frequency);\n }\n return windows;\n }\n\n return {\n id: \"digests\",\n dependsOn: [\"preferences\"],\n\n init: (context) => {\n ctx = context;\n },\n\n schema: {\n digestEntry: {\n tableName: ENTRY,\n fields: {\n id: { type: \"string\", required: true },\n userId: { type: \"string\", required: true },\n type: { type: \"string\", required: true },\n notificationId: { type: \"string\", required: true },\n payload: { type: \"json\" },\n window: { type: \"string\", required: true },\n createdAt: { type: \"date\", required: true, defaultNow: true },\n },\n primaryKey: [\"id\"],\n indexes: [{ on: [\"userId\", \"window\"], name: \"digest_entry_user_idx\" }],\n },\n\n digestState: {\n tableName: STATE,\n fields: {\n userId: { type: \"string\", required: true },\n window: { type: \"string\", required: true },\n /** Last period actually sent. The idempotency key for the schedule. */\n lastPeriod: { type: \"string\", required: true },\n updatedAt: { type: \"date\", required: true, defaultNow: true },\n },\n primaryKey: [\"userId\", \"window\"],\n },\n\n // Read-only here; owned by the preferences plugin.\n notificationPreference: {\n tableName: \"notification_preference\",\n fields: {\n userId: { type: \"string\", required: true },\n type: { type: \"string\", required: true },\n channel: { type: \"string\", required: true },\n enabled: { type: \"boolean\", required: true },\n frequency: { type: \"string\", required: true },\n },\n primaryKey: [\"userId\", \"type\", \"channel\"],\n },\n },\n\n hooks: {\n prepare: async (context: PrepareContext): Promise<Prepared> => {\n if (context.type === digestType) return { deferred: new Map() };\n\n return {\n deferred: await windowFor(\n context.recipients.map((recipient) => recipient.userId),\n context.type,\n ),\n };\n },\n\n resolveChannels: (context: ResolveChannelsContext) => {\n const prepared = context.prepared as Prepared | undefined;\n if (!prepared?.deferred.has(context.recipient.userId)) return context.decisions;\n\n return context.decisions.map((decision) =>\n decision.channel === channel ? { ...decision, action: \"skip\" as const } : decision,\n );\n },\n\n // The only hook that sees notification ids.\n afterSend: async (context: AfterSendContext) => {\n const prepared = context.prepared as Prepared | undefined;\n if (!prepared || prepared.deferred.size === 0) return;\n\n const entries = context.notifications\n .filter((notification) => prepared.deferred.has(notification.userId))\n .map((notification) => ({\n id: crypto.randomUUID(),\n userId: notification.userId,\n type: context.type,\n notificationId: notification.id,\n payload: context.payload,\n window: prepared.deferred.get(notification.userId) as DigestWindow,\n createdAt: new Date(),\n }));\n\n if (entries.length > 0) await ctx.store.insert(ENTRY, entries);\n },\n },\n\n routes: [\n {\n /**\n * Point an hourly cron here. Hourly rather than daily because \"9am\"\n * is 24 different UTC moments, and which ones shift with DST.\n */\n path: \"/digests/cron\",\n method: \"POST\",\n scope: { type: \"machine\" },\n handler: async () => {\n const now = new Date();\n\n const pending = await ctx.store.find<DigestEntry>(ENTRY, {}, { limit: 10_000 });\n if (pending.length === 0) return Response.json({ sent: 0, users: 0 });\n\n const byUser = new Map<string, DigestEntry[]>();\n for (const entry of pending) {\n const bucket = byUser.get(entry.userId);\n if (bucket) bucket.push(entry);\n else byUser.set(entry.userId, [entry]);\n }\n\n const userIds = [...byUser.keys()].slice(0, maxUsersPerRun);\n const recipients = await ctx.getRecipients(userIds);\n const zones = new Map(recipients.map((r) => [r.userId, r.timezone]));\n\n const states = await ctx.store.find<{\n userId: string;\n window: DigestWindow;\n lastPeriod: string;\n }>(STATE, { userId: { in: userIds } });\n\n const lastPeriods = new Map(states.map((s) => [`${s.userId} ${s.window}`, s.lastPeriod]));\n\n let sent = 0;\n\n for (const userId of userIds) {\n const entries = byUser.get(userId) ?? [];\n const moment = localMoment(zones.get(userId) ?? \"UTC\", now);\n\n for (const window of [\"daily\", \"weekly\"] as const) {\n const forWindow = entries.filter((entry) => entry.window === window);\n if (forWindow.length === 0) continue;\n\n const period = periodKey(window, moment, sendWeekday);\n // The schedule is idempotent on this comparison, not on timing:\n // a cron that runs twice in an hour cannot send twice.\n if (lastPeriods.get(`${userId} ${window}`) === period) continue;\n if (!isDue(window, moment, sendHour, sendWeekday)) continue;\n\n await ctx.send(digestType, {\n to: userId,\n payload: {\n window,\n period,\n items: forWindow.map((entry) => ({\n type: entry.type,\n payload: entry.payload,\n createdAt: entry.createdAt,\n })),\n },\n dedupeKey: `digest:${window}:${period}:${userId}`,\n });\n\n await ctx.store.upsert(\n STATE,\n [{ userId, window, lastPeriod: period, updatedAt: new Date() }],\n { onConflict: [\"userId\", \"window\"] },\n );\n\n // Only after the send is recorded, so a crash re-sends rather\n // than dropping the batch.\n await ctx.store.remove(ENTRY, {\n id: { in: forWindow.map((entry) => entry.id) },\n });\n\n sent += 1;\n }\n }\n\n return Response.json({ sent, users: userIds.length });\n },\n },\n ],\n };\n}\n\nexport type { DigestWindow, LocalMoment } from \"./schedule\";\nexport { isDue, localMoment, periodKey } from \"./schedule\";\n"]}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/core/plugin.ts
|
|
4
|
+
function defineClientPlugin(plugin) {
|
|
5
|
+
return plugin;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// src/plugins/preferences/client.ts
|
|
9
|
+
var preferencesClient = () => defineClientPlugin({
|
|
10
|
+
id: "preferences",
|
|
11
|
+
$InferServerPlugin: {},
|
|
12
|
+
actions: (fetch) => ({
|
|
13
|
+
listPreferences: async () => await fetch("/preferences"),
|
|
14
|
+
updatePreference: async (input) => await fetch("/preferences", {
|
|
15
|
+
method: "POST",
|
|
16
|
+
body: JSON.stringify(input)
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
exports.preferencesClient = preferencesClient;
|
|
22
|
+
//# sourceMappingURL=preferences-client.cjs.map
|
|
23
|
+
//# sourceMappingURL=preferences-client.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/core/plugin.ts","../../src/plugins/preferences/client.ts"],"names":[],"mappings":";;;AAoMO,SAAS,mBACd,MAAA,EAC6B;AAC7B,EAAA,OAAO,MAAA;AACT;;;ACpLO,IAAM,iBAAA,GAAoB,MAC/B,kBAAA,CAAmB;AAAA,EACjB,EAAA,EAAI,aAAA;AAAA,EACJ,oBAAoB,EAAC;AAAA,EAErB,OAAA,EAAS,CAAC,KAAA,MAAW;AAAA,IACnB,eAAA,EAAiB,YAId,MAAM,KAAA,CAAM,cAAc,CAAA;AAAA,IAK7B,gBAAA,EAAkB,OAAO,KAAA,KACtB,MAAM,MAAM,cAAA,EAAgB;AAAA,MAC3B,MAAA,EAAQ,MAAA;AAAA,MACR,IAAA,EAAM,IAAA,CAAK,SAAA,CAAU,KAAK;AAAA,KAC3B;AAAA,GACL;AACF,CAAC","file":"preferences-client.cjs","sourcesContent":["import type { DeliveryOutcome } from \"./adapter\";\nimport type { Logger } from \"./errors\";\nimport type { PluginStore } from \"./store\";\nimport type { Channel, Recipient } from \"./types\";\n\nexport type Promisable<T> = T | Promise<T>;\n\nexport type PrepareContext = {\n type: string;\n recipients: readonly Recipient[];\n};\n\nexport type BeforeSendContext = {\n type: string;\n recipient: Recipient;\n payload: unknown;\n actorId: string | null;\n dedupeKey: string | null;\n /** Whatever this plugin's own prepare() returned for this send. */\n prepared: unknown;\n};\n\nexport type BeforeSendResult =\n | { action: \"continue\"; payload?: unknown; dedupeKey?: string }\n | { action: \"cancel\"; reason: string };\n\nexport type ChannelDecision = {\n channel: Channel;\n action: \"send\" | \"skip\" | \"defer\";\n /** defer only. Written to notification_delivery.not_before. */\n notBefore?: Date | undefined;\n};\n\nexport type ResolveChannelsContext = {\n type: string;\n recipient: Recipient;\n payload: unknown;\n decisions: readonly ChannelDecision[];\n /** Whatever this plugin's own prepare() returned for this send. */\n prepared: unknown;\n};\n\nexport type DeliverContext = {\n channel: Channel;\n deliveryId: string;\n attempt: number;\n recipient: Recipient;\n notification: { id: string; userId: string; type: string; payload: unknown };\n /** Aborted on the runner's timeout. */\n signal: AbortSignal;\n};\n\nexport type AfterSendContext = {\n type: string;\n payload: unknown;\n /** Committed rows. Ids exist only at this point. */\n notifications: readonly { id: string; userId: string }[];\n /** Whatever this plugin's own prepare() returned for this send. */\n prepared: unknown;\n};\n\nexport type AfterDeliverContext = {\n deliveryId: string;\n notificationId: string;\n userId: string;\n channel: Channel;\n outcome: DeliveryOutcome;\n attempt: number;\n isTerminal: boolean;\n};\n\n/** beforeSend and resolveChannels fail closed; afterDeliver fails open. */\nexport type PluginHooks = {\n /** Once per send, for bulk loading — the per-recipient hooks would be an N+1. */\n prepare?: (ctx: PrepareContext) => Promisable<unknown>;\n beforeSend?: (ctx: BeforeSendContext) => Promisable<BeforeSendResult>;\n resolveChannels?: (ctx: ResolveChannelsContext) => Promisable<readonly ChannelDecision[]>;\n /**\n * Once per send, after the rows are committed. The only hook that sees\n * notification ids, which digests needs to bucket an entry against one.\n * Fails open: the notification is already written.\n */\n afterSend?: (ctx: AfterSendContext) => Promisable<void>;\n /**\n * Delivers one of the plugin's declared `channels`. Core carries inApp and\n * email; everything else arrives here, which is how push, sms and slack are\n * added without core learning about them.\n */\n deliver?: (ctx: DeliverContext) => Promisable<DeliveryOutcome>;\n afterDeliver?: (ctx: AfterDeliverContext) => Promisable<void>;\n};\n\nexport type FieldType = \"string\" | \"number\" | \"boolean\" | \"date\" | \"json\";\n\nexport type FieldDeclaration = {\n type: FieldType;\n required?: boolean;\n default?: string | number | boolean;\n /** date fields only. Renders as defaultNow() / CURRENT_TIMESTAMP. */\n defaultNow?: boolean;\n};\n\nexport type IndexDeclaration = {\n on: readonly string[];\n unique?: boolean;\n name?: string;\n};\n\nexport type TableDeclaration = {\n tableName: string;\n fields: Record<string, FieldDeclaration>;\n primaryKey?: readonly string[];\n indexes?: readonly IndexDeclaration[];\n};\n\nexport type SchemaDeclaration = Record<string, TableDeclaration>;\n\nexport type RouteScope =\n | { type: \"user\" }\n | { type: \"machine\" }\n | { type: \"signed\"; purpose: string }\n | { type: \"custom\"; justification: string };\n\nexport type RouteContext = {\n request: Request;\n /** Resolved for user-scoped routes, so a handler cannot forget to scope. */\n userId: string | null;\n /** Verified before dispatch, so a handler cannot forget to check. */\n claims: { uid: string; purpose: string; exp: number; data?: Record<string, string> } | null;\n params: Record<string, string>;\n};\n\nexport type RouteDefinition = {\n path: string;\n method: \"GET\" | \"POST\";\n scope: RouteScope;\n handler: (ctx: RouteContext) => Promise<Response>;\n};\n\n/** Handed to a plugin at startup so it need not be given the database twice. */\nexport type PluginInitContext = {\n /** Scoped to the tables this plugin declares in schema(). */\n store: PluginStore;\n /** The top-level signing secret, for plugins issuing signed links. */\n secret: string;\n logger: Logger;\n /** The app's resolver, for plugins that need timezones or addresses. */\n getRecipients: (userIds: readonly string[]) => Promise<readonly Recipient[]>;\n /**\n * The instance's own send(). A plugin composing a message (a digest, say)\n * routes it back through the pipeline rather than reaching for a provider,\n * so it inherits retry, backoff and idempotency for free.\n */\n send: (\n type: string,\n args: { to: string | readonly string[]; payload: unknown; dedupeKey?: string },\n ) => Promise<unknown>;\n};\n\nexport type EasyPingPlugin<\n TId extends string = string,\n TSchema extends SchemaDeclaration = SchemaDeclaration,\n> = {\n id: TId;\n dependsOn?: readonly string[];\n schema?: TSchema;\n routes?: readonly RouteDefinition[];\n hooks?: PluginHooks;\n /** Channels this plugin delivers. Makes them usable in a notification. */\n channels?: readonly Channel[];\n /** Called once by easyPing() before any hook or route can run. */\n init?: (ctx: PluginInitContext) => void;\n};\n\nexport type AnyPlugin = EasyPingPlugin<string, SchemaDeclaration>;\n\nexport function definePlugin<TId extends string, TSchema extends SchemaDeclaration>(\n plugin: EasyPingPlugin<TId, TSchema>,\n): EasyPingPlugin<TId, TSchema> {\n return plugin;\n}\n\nexport type ClientFetch = (path: string, init?: RequestInit) => Promise<unknown>;\n\nexport type ClientPlugin<\n TId extends string = string,\n TActions extends Record<string, unknown> = Record<string, unknown>,\n> = {\n id: TId;\n /** Phantom. Links client types to the server plugin so a mismatch is a type error. */\n $InferServerPlugin?: AnyPlugin;\n actions: (fetch: ClientFetch) => TActions;\n};\n\nexport type AnyClientPlugin = ClientPlugin<string, Record<string, unknown>>;\n\nexport function defineClientPlugin<TId extends string, TActions extends Record<string, unknown>>(\n plugin: ClientPlugin<TId, TActions>,\n): ClientPlugin<TId, TActions> {\n return plugin;\n}\n\nexport type UnionToIntersection<U> = (U extends unknown ? (k: U) => void : never) extends (\n k: infer I,\n) => void\n ? I\n : never;\n\n/** Naked type parameter, so the conditional distributes over the union. */\ntype ExtractActions<TPlugin> = TPlugin extends { actions: (fetch: never) => infer TActions }\n ? TActions\n : never;\n\n/** Indexes the tuple rather than recursing: a fold blows the instantiation depth limit. */\nexport type ActionsOf<TPlugins extends readonly AnyClientPlugin[]> = UnionToIntersection<\n ExtractActions<TPlugins[number]>\n>;\n","import { defineClientPlugin } from \"../../core/plugin\";\nimport type { Channel, Frequency } from \"../../core/types\";\nimport type { preferences } from \"./index\";\n\nexport type PreferenceView = {\n userId: string;\n type: string;\n channel: Channel;\n enabled: boolean;\n frequency: Frequency;\n};\n\nexport type UpdatePreferenceInput = {\n type: string;\n channel: Channel;\n enabled?: boolean;\n frequency?: Frequency;\n};\n\n/** Paired by phantom type: importing the server plugin's value would bundle the DB. */\nexport const preferencesClient = () =>\n defineClientPlugin({\n id: \"preferences\",\n $InferServerPlugin: {} as ReturnType<typeof preferences>,\n\n actions: (fetch) => ({\n listPreferences: async (): Promise<{\n preferences: PreferenceView[];\n defaultEnabled: boolean;\n }> =>\n (await fetch(\"/preferences\")) as {\n preferences: PreferenceView[];\n defaultEnabled: boolean;\n },\n\n updatePreference: async (input: UpdatePreferenceInput): Promise<{ ok: boolean }> =>\n (await fetch(\"/preferences\", {\n method: \"POST\",\n body: JSON.stringify(input),\n })) as { ok: boolean },\n }),\n });\n\nexport type PreferencesClient = ReturnType<typeof preferencesClient>;\n"]}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { f as ClientPlugin } from '../plugin-Bqj9xAOS.cjs';
|
|
2
|
+
import { C as Channel, F as Frequency } from '../adapter-f0OR2DhY.cjs';
|
|
3
|
+
|
|
4
|
+
type PreferenceView = {
|
|
5
|
+
userId: string;
|
|
6
|
+
type: string;
|
|
7
|
+
channel: Channel;
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
frequency: Frequency;
|
|
10
|
+
};
|
|
11
|
+
type UpdatePreferenceInput = {
|
|
12
|
+
type: string;
|
|
13
|
+
channel: Channel;
|
|
14
|
+
enabled?: boolean;
|
|
15
|
+
frequency?: Frequency;
|
|
16
|
+
};
|
|
17
|
+
/** Paired by phantom type: importing the server plugin's value would bundle the DB. */
|
|
18
|
+
declare const preferencesClient: () => ClientPlugin<"preferences", {
|
|
19
|
+
listPreferences: () => Promise<{
|
|
20
|
+
preferences: PreferenceView[];
|
|
21
|
+
defaultEnabled: boolean;
|
|
22
|
+
}>;
|
|
23
|
+
updatePreference: (input: UpdatePreferenceInput) => Promise<{
|
|
24
|
+
ok: boolean;
|
|
25
|
+
}>;
|
|
26
|
+
}>;
|
|
27
|
+
type PreferencesClient = ReturnType<typeof preferencesClient>;
|
|
28
|
+
|
|
29
|
+
export { type PreferenceView, type PreferencesClient, type UpdatePreferenceInput, preferencesClient };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { f as ClientPlugin } from '../plugin-bTJKmnlq.js';
|
|
2
|
+
import { C as Channel, F as Frequency } from '../adapter-f0OR2DhY.js';
|
|
3
|
+
|
|
4
|
+
type PreferenceView = {
|
|
5
|
+
userId: string;
|
|
6
|
+
type: string;
|
|
7
|
+
channel: Channel;
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
frequency: Frequency;
|
|
10
|
+
};
|
|
11
|
+
type UpdatePreferenceInput = {
|
|
12
|
+
type: string;
|
|
13
|
+
channel: Channel;
|
|
14
|
+
enabled?: boolean;
|
|
15
|
+
frequency?: Frequency;
|
|
16
|
+
};
|
|
17
|
+
/** Paired by phantom type: importing the server plugin's value would bundle the DB. */
|
|
18
|
+
declare const preferencesClient: () => ClientPlugin<"preferences", {
|
|
19
|
+
listPreferences: () => Promise<{
|
|
20
|
+
preferences: PreferenceView[];
|
|
21
|
+
defaultEnabled: boolean;
|
|
22
|
+
}>;
|
|
23
|
+
updatePreference: (input: UpdatePreferenceInput) => Promise<{
|
|
24
|
+
ok: boolean;
|
|
25
|
+
}>;
|
|
26
|
+
}>;
|
|
27
|
+
type PreferencesClient = ReturnType<typeof preferencesClient>;
|
|
28
|
+
|
|
29
|
+
export { type PreferenceView, type PreferencesClient, type UpdatePreferenceInput, preferencesClient };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineClientPlugin } from '../chunk-BV3U542P.js';
|
|
2
|
+
|
|
3
|
+
// src/plugins/preferences/client.ts
|
|
4
|
+
var preferencesClient = () => defineClientPlugin({
|
|
5
|
+
id: "preferences",
|
|
6
|
+
$InferServerPlugin: {},
|
|
7
|
+
actions: (fetch) => ({
|
|
8
|
+
listPreferences: async () => await fetch("/preferences"),
|
|
9
|
+
updatePreference: async (input) => await fetch("/preferences", {
|
|
10
|
+
method: "POST",
|
|
11
|
+
body: JSON.stringify(input)
|
|
12
|
+
})
|
|
13
|
+
})
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export { preferencesClient };
|
|
17
|
+
//# sourceMappingURL=preferences-client.js.map
|
|
18
|
+
//# sourceMappingURL=preferences-client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/plugins/preferences/client.ts"],"names":[],"mappings":";;;AAoBO,IAAM,iBAAA,GAAoB,MAC/B,kBAAA,CAAmB;AAAA,EACjB,EAAA,EAAI,aAAA;AAAA,EACJ,oBAAoB,EAAC;AAAA,EAErB,OAAA,EAAS,CAAC,KAAA,MAAW;AAAA,IACnB,eAAA,EAAiB,YAId,MAAM,KAAA,CAAM,cAAc,CAAA;AAAA,IAK7B,gBAAA,EAAkB,OAAO,KAAA,KACtB,MAAM,MAAM,cAAA,EAAgB;AAAA,MAC3B,MAAA,EAAQ,MAAA;AAAA,MACR,IAAA,EAAM,IAAA,CAAK,SAAA,CAAU,KAAK;AAAA,KAC3B;AAAA,GACL;AACF,CAAC","file":"preferences-client.js","sourcesContent":["import { defineClientPlugin } from \"../../core/plugin\";\nimport type { Channel, Frequency } from \"../../core/types\";\nimport type { preferences } from \"./index\";\n\nexport type PreferenceView = {\n userId: string;\n type: string;\n channel: Channel;\n enabled: boolean;\n frequency: Frequency;\n};\n\nexport type UpdatePreferenceInput = {\n type: string;\n channel: Channel;\n enabled?: boolean;\n frequency?: Frequency;\n};\n\n/** Paired by phantom type: importing the server plugin's value would bundle the DB. */\nexport const preferencesClient = () =>\n defineClientPlugin({\n id: \"preferences\",\n $InferServerPlugin: {} as ReturnType<typeof preferences>,\n\n actions: (fetch) => ({\n listPreferences: async (): Promise<{\n preferences: PreferenceView[];\n defaultEnabled: boolean;\n }> =>\n (await fetch(\"/preferences\")) as {\n preferences: PreferenceView[];\n defaultEnabled: boolean;\n },\n\n updatePreference: async (input: UpdatePreferenceInput): Promise<{ ok: boolean }> =>\n (await fetch(\"/preferences\", {\n method: \"POST\",\n body: JSON.stringify(input),\n })) as { ok: boolean },\n }),\n });\n\nexport type PreferencesClient = ReturnType<typeof preferencesClient>;\n"]}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use strict";
|
|
3
|
+
"use client";
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
21
|
+
|
|
22
|
+
// src/plugins/preferences/react.ts
|
|
23
|
+
var react_exports = {};
|
|
24
|
+
__export(react_exports, {
|
|
25
|
+
usePreferences: () => usePreferences
|
|
26
|
+
});
|
|
27
|
+
module.exports = __toCommonJS(react_exports);
|
|
28
|
+
var import_react = require("react");
|
|
29
|
+
var matches = (row, key) => row.type === key.type && row.channel === key.channel;
|
|
30
|
+
function usePreferences(options = {}) {
|
|
31
|
+
const { baseUrl = "/api/notifications", fetch: providedFetch } = options;
|
|
32
|
+
const doFetch = (0, import_react.useMemo)(
|
|
33
|
+
() => providedFetch ?? ((...args) => globalThis.fetch(...args)),
|
|
34
|
+
[providedFetch]
|
|
35
|
+
);
|
|
36
|
+
const [preferences, setPreferences] = (0, import_react.useState)([]);
|
|
37
|
+
const [defaultEnabled, setDefaultEnabled] = (0, import_react.useState)(true);
|
|
38
|
+
const [isLoading, setIsLoading] = (0, import_react.useState)(true);
|
|
39
|
+
const [error, setError] = (0, import_react.useState)(null);
|
|
40
|
+
const refresh = (0, import_react.useCallback)(async () => {
|
|
41
|
+
try {
|
|
42
|
+
const response = await doFetch(`${baseUrl}/preferences`);
|
|
43
|
+
if (!response.ok) throw new Error(`GET /preferences failed: ${response.status}`);
|
|
44
|
+
const body = await response.json();
|
|
45
|
+
setPreferences(body.preferences);
|
|
46
|
+
setDefaultEnabled(body.defaultEnabled);
|
|
47
|
+
setError(null);
|
|
48
|
+
} catch (caught) {
|
|
49
|
+
setError(caught instanceof Error ? caught : new Error(String(caught)));
|
|
50
|
+
} finally {
|
|
51
|
+
setIsLoading(false);
|
|
52
|
+
}
|
|
53
|
+
}, [baseUrl, doFetch]);
|
|
54
|
+
(0, import_react.useEffect)(() => {
|
|
55
|
+
void refresh();
|
|
56
|
+
}, [refresh]);
|
|
57
|
+
const persist = (0, import_react.useCallback)(
|
|
58
|
+
async (input, optimistic) => {
|
|
59
|
+
const previous = preferences;
|
|
60
|
+
setPreferences((rows) => {
|
|
61
|
+
const existing = rows.find((row) => matches(row, input));
|
|
62
|
+
if (existing) {
|
|
63
|
+
return rows.map((row) => matches(row, input) ? { ...row, ...optimistic } : row);
|
|
64
|
+
}
|
|
65
|
+
return [
|
|
66
|
+
...rows,
|
|
67
|
+
{
|
|
68
|
+
userId: "",
|
|
69
|
+
type: input.type,
|
|
70
|
+
channel: input.channel,
|
|
71
|
+
enabled: true,
|
|
72
|
+
frequency: "instant",
|
|
73
|
+
...optimistic
|
|
74
|
+
}
|
|
75
|
+
];
|
|
76
|
+
});
|
|
77
|
+
try {
|
|
78
|
+
const response = await doFetch(`${baseUrl}/preferences`, {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: { "content-type": "application/json" },
|
|
81
|
+
body: JSON.stringify(input)
|
|
82
|
+
});
|
|
83
|
+
if (!response.ok) throw new Error(`POST /preferences failed: ${response.status}`);
|
|
84
|
+
} catch (caught) {
|
|
85
|
+
setPreferences(previous);
|
|
86
|
+
setError(caught instanceof Error ? caught : new Error(String(caught)));
|
|
87
|
+
throw caught;
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
[baseUrl, doFetch, preferences]
|
|
91
|
+
);
|
|
92
|
+
return {
|
|
93
|
+
preferences,
|
|
94
|
+
defaultEnabled,
|
|
95
|
+
isLoading,
|
|
96
|
+
error,
|
|
97
|
+
isEnabled: (0, import_react.useCallback)(
|
|
98
|
+
(key) => {
|
|
99
|
+
const row = preferences.find((candidate) => matches(candidate, key));
|
|
100
|
+
return row ? row.enabled && row.frequency !== "off" : defaultEnabled;
|
|
101
|
+
},
|
|
102
|
+
[preferences, defaultEnabled]
|
|
103
|
+
),
|
|
104
|
+
setEnabled: (0, import_react.useCallback)(
|
|
105
|
+
(key, enabled) => persist(
|
|
106
|
+
{ ...key, enabled, frequency: enabled ? "instant" : "off" },
|
|
107
|
+
{ enabled, frequency: enabled ? "instant" : "off" }
|
|
108
|
+
),
|
|
109
|
+
[persist]
|
|
110
|
+
),
|
|
111
|
+
setFrequency: (0, import_react.useCallback)(
|
|
112
|
+
(key, frequency) => persist(
|
|
113
|
+
{ ...key, enabled: frequency !== "off", frequency },
|
|
114
|
+
{ enabled: frequency !== "off", frequency }
|
|
115
|
+
),
|
|
116
|
+
[persist]
|
|
117
|
+
),
|
|
118
|
+
refresh
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
122
|
+
0 && (module.exports = {
|
|
123
|
+
usePreferences
|
|
124
|
+
});
|
|
125
|
+
//# sourceMappingURL=preferences-react.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/plugins/preferences/react.ts"],"sourcesContent":["\"use client\";\n\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\nimport type { Channel, Frequency } from \"../../core/types\";\nimport type { PreferenceView, UpdatePreferenceInput } from \"./client\";\n\nexport type UsePreferencesOptions = {\n baseUrl?: string | undefined;\n fetch?: typeof globalThis.fetch | undefined;\n};\n\nexport type PreferenceKey = { type: string; channel: Channel };\n\nexport type UsePreferencesResult = {\n preferences: readonly PreferenceView[];\n defaultEnabled: boolean;\n isLoading: boolean;\n error: Error | null;\n /** Resolved value for a cell, falling back to the configured default. */\n isEnabled: (key: PreferenceKey) => boolean;\n setEnabled: (key: PreferenceKey, enabled: boolean) => Promise<void>;\n setFrequency: (key: PreferenceKey, frequency: Frequency) => Promise<void>;\n refresh: () => Promise<void>;\n};\n\nconst matches = (row: PreferenceView, key: PreferenceKey) =>\n row.type === key.type && row.channel === key.channel;\n\n/** Headless: state and persistence only, no markup. */\nexport function usePreferences(options: UsePreferencesOptions = {}): UsePreferencesResult {\n const { baseUrl = \"/api/notifications\", fetch: providedFetch } = options;\n\n const doFetch = useMemo(\n () =>\n providedFetch ??\n ((...args: Parameters<typeof globalThis.fetch>) => globalThis.fetch(...args)),\n [providedFetch],\n );\n\n const [preferences, setPreferences] = useState<readonly PreferenceView[]>([]);\n const [defaultEnabled, setDefaultEnabled] = useState(true);\n const [isLoading, setIsLoading] = useState(true);\n const [error, setError] = useState<Error | null>(null);\n\n const refresh = useCallback(async () => {\n try {\n const response = await doFetch(`${baseUrl}/preferences`);\n if (!response.ok) throw new Error(`GET /preferences failed: ${response.status}`);\n\n const body = (await response.json()) as {\n preferences: PreferenceView[];\n defaultEnabled: boolean;\n };\n\n setPreferences(body.preferences);\n setDefaultEnabled(body.defaultEnabled);\n setError(null);\n } catch (caught) {\n setError(caught instanceof Error ? caught : new Error(String(caught)));\n } finally {\n setIsLoading(false);\n }\n }, [baseUrl, doFetch]);\n\n useEffect(() => {\n void refresh();\n }, [refresh]);\n\n const persist = useCallback(\n async (input: UpdatePreferenceInput, optimistic: Partial<PreferenceView>) => {\n const previous = preferences;\n\n // A preference toggle that lags behind the network feels broken, so\n // apply immediately and revert if the write fails.\n setPreferences((rows) => {\n const existing = rows.find((row) => matches(row, input));\n if (existing) {\n return rows.map((row) => (matches(row, input) ? { ...row, ...optimistic } : row));\n }\n return [\n ...rows,\n {\n userId: \"\",\n type: input.type,\n channel: input.channel,\n enabled: true,\n frequency: \"instant\" as Frequency,\n ...optimistic,\n },\n ];\n });\n\n try {\n const response = await doFetch(`${baseUrl}/preferences`, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify(input),\n });\n if (!response.ok) throw new Error(`POST /preferences failed: ${response.status}`);\n } catch (caught) {\n setPreferences(previous);\n setError(caught instanceof Error ? caught : new Error(String(caught)));\n throw caught;\n }\n },\n [baseUrl, doFetch, preferences],\n );\n\n return {\n preferences,\n defaultEnabled,\n isLoading,\n error,\n\n isEnabled: useCallback(\n (key: PreferenceKey) => {\n const row = preferences.find((candidate) => matches(candidate, key));\n return row ? row.enabled && row.frequency !== \"off\" : defaultEnabled;\n },\n [preferences, defaultEnabled],\n ),\n\n setEnabled: useCallback(\n (key: PreferenceKey, enabled: boolean) =>\n persist(\n { ...key, enabled, frequency: enabled ? \"instant\" : \"off\" },\n { enabled, frequency: enabled ? \"instant\" : \"off\" },\n ),\n [persist],\n ),\n\n setFrequency: useCallback(\n (key: PreferenceKey, frequency: Frequency) =>\n persist(\n { ...key, enabled: frequency !== \"off\", frequency },\n { enabled: frequency !== \"off\", frequency },\n ),\n [persist],\n ),\n\n refresh,\n };\n}\n\nexport type { PreferenceView, UpdatePreferenceInput } from \"./client\";\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
type Channel = "inApp" | "email" | "push" | "sms" | "slack";
|
|
2
|
+
type Frequency = "instant" | "daily" | "weekly" | "off";
|
|
3
|
+
|
|
4
|
+
type PreferenceView = {
|
|
5
|
+
userId: string;
|
|
6
|
+
type: string;
|
|
7
|
+
channel: Channel;
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
frequency: Frequency;
|
|
10
|
+
};
|
|
11
|
+
type UpdatePreferenceInput = {
|
|
12
|
+
type: string;
|
|
13
|
+
channel: Channel;
|
|
14
|
+
enabled?: boolean;
|
|
15
|
+
frequency?: Frequency;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type UsePreferencesOptions = {
|
|
19
|
+
baseUrl?: string | undefined;
|
|
20
|
+
fetch?: typeof globalThis.fetch | undefined;
|
|
21
|
+
};
|
|
22
|
+
type PreferenceKey = {
|
|
23
|
+
type: string;
|
|
24
|
+
channel: Channel;
|
|
25
|
+
};
|
|
26
|
+
type UsePreferencesResult = {
|
|
27
|
+
preferences: readonly PreferenceView[];
|
|
28
|
+
defaultEnabled: boolean;
|
|
29
|
+
isLoading: boolean;
|
|
30
|
+
error: Error | null;
|
|
31
|
+
/** Resolved value for a cell, falling back to the configured default. */
|
|
32
|
+
isEnabled: (key: PreferenceKey) => boolean;
|
|
33
|
+
setEnabled: (key: PreferenceKey, enabled: boolean) => Promise<void>;
|
|
34
|
+
setFrequency: (key: PreferenceKey, frequency: Frequency) => Promise<void>;
|
|
35
|
+
refresh: () => Promise<void>;
|
|
36
|
+
};
|
|
37
|
+
/** Headless: state and persistence only, no markup. */
|
|
38
|
+
declare function usePreferences(options?: UsePreferencesOptions): UsePreferencesResult;
|
|
39
|
+
|
|
40
|
+
export { type PreferenceKey, type PreferenceView, type UpdatePreferenceInput, type UsePreferencesOptions, type UsePreferencesResult, usePreferences };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
type Channel = "inApp" | "email" | "push" | "sms" | "slack";
|
|
2
|
+
type Frequency = "instant" | "daily" | "weekly" | "off";
|
|
3
|
+
|
|
4
|
+
type PreferenceView = {
|
|
5
|
+
userId: string;
|
|
6
|
+
type: string;
|
|
7
|
+
channel: Channel;
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
frequency: Frequency;
|
|
10
|
+
};
|
|
11
|
+
type UpdatePreferenceInput = {
|
|
12
|
+
type: string;
|
|
13
|
+
channel: Channel;
|
|
14
|
+
enabled?: boolean;
|
|
15
|
+
frequency?: Frequency;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type UsePreferencesOptions = {
|
|
19
|
+
baseUrl?: string | undefined;
|
|
20
|
+
fetch?: typeof globalThis.fetch | undefined;
|
|
21
|
+
};
|
|
22
|
+
type PreferenceKey = {
|
|
23
|
+
type: string;
|
|
24
|
+
channel: Channel;
|
|
25
|
+
};
|
|
26
|
+
type UsePreferencesResult = {
|
|
27
|
+
preferences: readonly PreferenceView[];
|
|
28
|
+
defaultEnabled: boolean;
|
|
29
|
+
isLoading: boolean;
|
|
30
|
+
error: Error | null;
|
|
31
|
+
/** Resolved value for a cell, falling back to the configured default. */
|
|
32
|
+
isEnabled: (key: PreferenceKey) => boolean;
|
|
33
|
+
setEnabled: (key: PreferenceKey, enabled: boolean) => Promise<void>;
|
|
34
|
+
setFrequency: (key: PreferenceKey, frequency: Frequency) => Promise<void>;
|
|
35
|
+
refresh: () => Promise<void>;
|
|
36
|
+
};
|
|
37
|
+
/** Headless: state and persistence only, no markup. */
|
|
38
|
+
declare function usePreferences(options?: UsePreferencesOptions): UsePreferencesResult;
|
|
39
|
+
|
|
40
|
+
export { type PreferenceKey, type PreferenceView, type UpdatePreferenceInput, type UsePreferencesOptions, type UsePreferencesResult, usePreferences };
|