@mikstack/notifications 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/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # @mikstack/notifications
2
+
3
+ Code-first notification infrastructure for mikstack projects. Factory function, plugin-based channels, Drizzle table mapping, type-safe notification definitions.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ bun add @mikstack/notifications
9
+ ```
10
+
11
+ Peer dependency: `drizzle-orm` (>=0.38.0)
12
+
13
+ ## Quick Start
14
+
15
+ ### 1. Define your schema (copy to your `schema.ts`)
16
+
17
+ ```ts
18
+ export const notificationDelivery = pgTable("notification_delivery", {
19
+ id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
20
+ userId: text("user_id").notNull().references(() => user.id),
21
+ type: text("type").notNull(),
22
+ channel: text("channel").notNull(),
23
+ status: text("status", { enum: ["pending", "sent", "delivered", "failed"] }).notNull().default("pending"),
24
+ content: jsonb("content"),
25
+ error: text("error"),
26
+ retryOf: text("retry_of"),
27
+ retriesLeft: integer("retries_left").notNull().default(0),
28
+ recipientEmail: text("recipient_email"),
29
+ externalId: text("external_id"),
30
+ createdAt: timestamp("created_at").notNull().defaultNow(),
31
+ updatedAt: timestamp("updated_at").notNull().defaultNow(),
32
+ });
33
+
34
+ export const inAppNotification = pgTable("in_app_notification", { ... });
35
+ export const notificationPreference = pgTable("notification_preference", { ... });
36
+ ```
37
+
38
+ ### 2. Define notifications
39
+
40
+ ```ts
41
+ import { defineNotification } from "@mikstack/notifications";
42
+
43
+ export const notifications = {
44
+ "magic-link": defineNotification({
45
+ key: "magic-link",
46
+ critical: true,
47
+ channels: {
48
+ email: (data: { url: string }) => magicLinkEmail(data.url),
49
+ },
50
+ }),
51
+ "welcome": defineNotification({
52
+ key: "welcome",
53
+ channels: {
54
+ "in-app": (data: { userName: string }) => ({
55
+ title: `Welcome, ${data.userName}!`,
56
+ body: "Get started by creating your first note.",
57
+ }),
58
+ },
59
+ }),
60
+ } as const;
61
+ ```
62
+
63
+ ### 3. Create instance
64
+
65
+ ```ts
66
+ import { createNotifications, emailChannel, inAppChannel } from "@mikstack/notifications";
67
+
68
+ const notif = createNotifications({
69
+ database: { db, schema, provider: "pg" },
70
+ channels: [
71
+ emailChannel({
72
+ sendEmail: async ({ to, subject, html, text }) => { /* your SMTP logic */ },
73
+ }),
74
+ inAppChannel(),
75
+ ],
76
+ notifications,
77
+ });
78
+ ```
79
+
80
+ ### 4. Send notifications
81
+
82
+ ```ts
83
+ await notif.send({
84
+ type: "welcome",
85
+ userId: user.id,
86
+ data: { userName: user.name },
87
+ });
88
+ ```
89
+
90
+ ## API
91
+
92
+ | Method | Purpose |
93
+ |---|---|
94
+ | `notif.send({ type, userId, data })` | Send notification across channels |
95
+ | `notif.list({ userId, limit?, unreadOnly? })` | List in-app notifications |
96
+ | `notif.markRead({ userId, notificationIds? })` | Mark as read |
97
+ | `notif.getPreferences(userId)` | Get user preferences |
98
+ | `notif.updatePreferences(userId, prefs)` | Update preferences |
99
+
100
+ ## Client SDK
101
+
102
+ ```ts
103
+ import { createNotificationClient } from "@mikstack/notifications/client";
104
+
105
+ const client = createNotificationClient({ baseUrl: "/api/notifications" });
106
+ await client.markRead(["notif-id-1"]);
107
+ await client.markAllRead();
108
+ const prefs = await client.getPreferences();
109
+ await client.updatePreferences({ "welcome": { "in-app": false } });
110
+ ```
111
+
112
+ ## Features
113
+
114
+ - **Type-safe**: `send()` autocompletes notification types and infers data shapes
115
+ - **Email retries**: Exponential backoff (default 3 attempts), each attempt tracked as its own delivery row
116
+ - **Preference hierarchy**: Per-type + per-channel > per-type + wildcard > wildcard + per-channel > defaults
117
+ - **Critical notifications**: `critical: true` bypasses user preferences (e.g., auth emails)
118
+ - **In-app via Zero**: `inAppNotification` table syncs to clients via Zero
119
+ - **Schema ownership**: Tables are copy-pasted into your project (like better-auth), fully customizable
@@ -0,0 +1,21 @@
1
+ //#region src/client-impl.d.ts
2
+ interface NotificationClientConfig {
3
+ baseUrl: string;
4
+ fetch?: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
5
+ }
6
+ interface NotificationClient {
7
+ markRead(notificationIds: string[]): Promise<void>;
8
+ markAllRead(): Promise<void>;
9
+ getPreferences(): Promise<PreferencesResponse>;
10
+ updatePreferences(prefs: Record<string, Record<string, boolean>>): Promise<void>;
11
+ }
12
+ interface PreferencesResponse {
13
+ preferences: {
14
+ notificationType: string;
15
+ channel: string;
16
+ enabled: boolean;
17
+ }[];
18
+ }
19
+ declare function createNotificationClient(config: NotificationClientConfig): NotificationClient;
20
+ //#endregion
21
+ export { type NotificationClient, type NotificationClientConfig, createNotificationClient };
package/dist/client.js ADDED
@@ -0,0 +1,47 @@
1
+ //#region src/client-impl.ts
2
+ function createNotificationClient(config) {
3
+ const fetchFn = config.fetch ?? globalThis.fetch;
4
+ const base = config.baseUrl.replace(/\/$/, "");
5
+ async function request(path, options) {
6
+ const res = await fetchFn(`${base}${path}`, {
7
+ headers: { "Content-Type": "application/json" },
8
+ ...options
9
+ });
10
+ if (!res.ok) {
11
+ const text = await res.text().catch(() => "Unknown error");
12
+ throw new Error(`Notification API error (${res.status}): ${text}`);
13
+ }
14
+ return res;
15
+ }
16
+ return {
17
+ async markRead(notificationIds) {
18
+ await request("/mark-read", {
19
+ method: "POST",
20
+ body: JSON.stringify({ notificationIds })
21
+ });
22
+ },
23
+ async markAllRead() {
24
+ await request("/mark-read", {
25
+ method: "POST",
26
+ body: JSON.stringify({ all: true })
27
+ });
28
+ },
29
+ async getPreferences() {
30
+ return (await request("/preferences")).json();
31
+ },
32
+ async updatePreferences(prefs) {
33
+ const updates = Object.entries(prefs).flatMap(([notificationType, channels]) => Object.entries(channels).map(([channel, enabled]) => ({
34
+ notificationType,
35
+ channel,
36
+ enabled
37
+ })));
38
+ await request("/preferences", {
39
+ method: "PUT",
40
+ body: JSON.stringify({ preferences: updates })
41
+ });
42
+ }
43
+ };
44
+ }
45
+
46
+ //#endregion
47
+ export { createNotificationClient };
@@ -0,0 +1,157 @@
1
+ import { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";
2
+
3
+ //#region src/types.d.ts
4
+ interface EmailContent {
5
+ subject: string;
6
+ html: string;
7
+ text: string;
8
+ }
9
+ interface InAppContent {
10
+ title: string;
11
+ body?: string;
12
+ url?: string;
13
+ icon?: string;
14
+ }
15
+ interface ChannelSendParams {
16
+ userId: string;
17
+ type: string;
18
+ content: EmailContent | InAppContent;
19
+ recipientEmail?: string;
20
+ }
21
+ interface ChannelSendResult {
22
+ externalId?: string;
23
+ }
24
+ interface ChannelHandler {
25
+ send(params: ChannelSendParams): Promise<ChannelSendResult>;
26
+ }
27
+ interface ChannelPlugin<TName extends string = string> {
28
+ name: TName;
29
+ retries: number;
30
+ init(ctx: ChannelInitContext): ChannelHandler;
31
+ }
32
+ interface ChannelInitContext {
33
+ db: DatabaseInstance;
34
+ schema: SchemaInstance;
35
+ tableNames: ResolvedTableNames;
36
+ }
37
+ type ChannelContentFn<TData, TContent> = (data: TData) => TContent;
38
+ interface NotificationChannels<TData> {
39
+ email?: ChannelContentFn<TData, EmailContent>;
40
+ "in-app"?: ChannelContentFn<TData, InAppContent>;
41
+ }
42
+ interface NotificationDefinition<TKey extends string = string, TData = unknown> {
43
+ key: TKey;
44
+ description?: string;
45
+ critical?: boolean;
46
+ channels: NotificationChannels<TData>;
47
+ }
48
+ type NotificationDefinitions = Record<string, NotificationDefinition<string, never>>;
49
+ type SendParams<TDefs extends NotificationDefinitions> = { [K in keyof TDefs & string]: TDefs[K] extends NotificationDefinition<string, infer TData> ? TData extends Record<string, never> ? {
50
+ type: K;
51
+ userId: string;
52
+ recipientEmail?: string;
53
+ } : {
54
+ type: K;
55
+ userId: string;
56
+ data: TData;
57
+ recipientEmail?: string;
58
+ } : never }[keyof TDefs & string];
59
+ type DatabaseInstance = Pick<PgDatabase<PgQueryResultHKT>, "select" | "insert" | "update">;
60
+ type SchemaInstance = Record<string, unknown>;
61
+ interface TableNames {
62
+ notificationDelivery?: string;
63
+ inAppNotification?: string;
64
+ notificationPreference?: string;
65
+ }
66
+ interface ResolvedTableNames {
67
+ notificationDelivery: string;
68
+ inAppNotification: string;
69
+ notificationPreference: string;
70
+ }
71
+ interface DefaultPreferences {
72
+ enabledChannels: string[];
73
+ }
74
+ interface DatabaseConfig {
75
+ db: DatabaseInstance;
76
+ schema: SchemaInstance;
77
+ provider: "pg";
78
+ tableNames?: TableNames;
79
+ }
80
+ interface NotificationsConfig<TDefs extends NotificationDefinitions = NotificationDefinitions> {
81
+ database: DatabaseConfig;
82
+ channels: ChannelPlugin[];
83
+ notifications: TDefs;
84
+ defaultPreferences?: DefaultPreferences;
85
+ }
86
+ interface NotificationsInstance<TDefs extends NotificationDefinitions = NotificationDefinitions> {
87
+ send(params: SendParams<TDefs>): Promise<void>;
88
+ list(params: {
89
+ userId: string;
90
+ limit?: number;
91
+ unreadOnly?: boolean;
92
+ }): Promise<InAppNotificationRow[]>;
93
+ markRead(params: {
94
+ userId: string;
95
+ notificationIds?: string[];
96
+ }): Promise<void>;
97
+ getPreferences(userId: string): Promise<PreferenceRow[]>;
98
+ updatePreferences(userId: string, prefs: PreferenceUpdate[]): Promise<void>;
99
+ handler(request: Request, userId: string | null): Promise<Response>;
100
+ $Infer: {
101
+ NotificationTypes: keyof TDefs & string;
102
+ };
103
+ }
104
+ interface InAppNotificationRow {
105
+ id: string;
106
+ userId: string;
107
+ type: string;
108
+ title: string;
109
+ body: string | null;
110
+ url: string | null;
111
+ icon: string | null;
112
+ read: boolean;
113
+ createdAt: Date;
114
+ }
115
+ interface PreferenceRow {
116
+ id: string;
117
+ userId: string;
118
+ notificationType: string;
119
+ channel: string;
120
+ enabled: boolean;
121
+ updatedAt: Date;
122
+ }
123
+ interface PreferenceUpdate {
124
+ notificationType: string;
125
+ channel: string;
126
+ enabled: boolean;
127
+ }
128
+ //#endregion
129
+ //#region src/factory.d.ts
130
+ declare function createNotifications<TDefs extends NotificationDefinitions>(config: NotificationsConfig<TDefs>): NotificationsInstance<TDefs>;
131
+ //#endregion
132
+ //#region src/define.d.ts
133
+ declare function defineNotification<TKey extends string, TData>(def: {
134
+ key: TKey;
135
+ description?: string;
136
+ critical?: boolean;
137
+ channels: NotificationChannels<TData>;
138
+ }): NotificationDefinition<TKey, TData>;
139
+ //#endregion
140
+ //#region src/channels/email.d.ts
141
+ interface EmailChannelConfig {
142
+ sendEmail: (params: {
143
+ to: string;
144
+ subject: string;
145
+ html: string;
146
+ text: string;
147
+ }) => Promise<void | {
148
+ externalId?: string;
149
+ }>;
150
+ retries?: number;
151
+ }
152
+ declare function emailChannel(config: EmailChannelConfig): ChannelPlugin<"email">;
153
+ //#endregion
154
+ //#region src/channels/in-app.d.ts
155
+ declare function inAppChannel(): ChannelPlugin<"in-app">;
156
+ //#endregion
157
+ export { type ChannelHandler, type ChannelPlugin, type ChannelSendParams, type ChannelSendResult, type DefaultPreferences, type EmailContent, type InAppContent, type NotificationDefinition, type NotificationsConfig, type NotificationsInstance, type PreferenceUpdate, type SendParams, createNotifications, defineNotification, emailChannel, inAppChannel };
package/dist/index.js ADDED
@@ -0,0 +1,335 @@
1
+ import { and, desc, eq } from "drizzle-orm";
2
+
3
+ //#region src/internal/table.ts
4
+ /**
5
+ * Get a table from the schema by its configured name, throwing if not found.
6
+ */
7
+ function getTable(schema, tableName, label) {
8
+ const table = schema[tableName];
9
+ if (!table) throw new Error(`Table "${tableName}" not found in schema. Make sure your schema includes the ${label} table and it's passed to createNotifications().`);
10
+ return table;
11
+ }
12
+ /**
13
+ * Get a column from a table, asserting it exists.
14
+ * This is needed because PgTableWithColumns column access returns `T | undefined`
15
+ * with noUncheckedIndexedAccess enabled.
16
+ */
17
+ function col(table, name) {
18
+ const column = table[name];
19
+ if (!column) throw new Error(`Column "${name}" not found on table. Check your schema definition.`);
20
+ return column;
21
+ }
22
+
23
+ //#endregion
24
+ //#region src/internal/errors.ts
25
+ var NotificationError = class extends Error {
26
+ constructor(message, options) {
27
+ super(message, options);
28
+ this.name = "NotificationError";
29
+ }
30
+ };
31
+ var DeliveryError = class extends NotificationError {
32
+ deliveryId;
33
+ constructor(deliveryId, message, options) {
34
+ super(message, options);
35
+ this.name = "DeliveryError";
36
+ this.deliveryId = deliveryId;
37
+ }
38
+ };
39
+
40
+ //#endregion
41
+ //#region src/delivery.ts
42
+ const DEFAULT_BACKOFF_DELAYS = [
43
+ 1e3,
44
+ 5e3,
45
+ 15e3,
46
+ 3e4,
47
+ 6e4
48
+ ];
49
+ function delay(ms) {
50
+ return new Promise((resolve) => setTimeout(resolve, ms));
51
+ }
52
+ async function deliver(ctx, params) {
53
+ const table = getTable(ctx.schema, ctx.tableNames.notificationDelivery, "notificationDelivery");
54
+ const maxAttempts = params.retries + 1;
55
+ let previousDeliveryId = null;
56
+ let lastError;
57
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
58
+ const deliveryId = crypto.randomUUID();
59
+ const retriesLeft = maxAttempts - attempt - 1;
60
+ await ctx.db.insert(table).values({
61
+ id: deliveryId,
62
+ userId: params.userId,
63
+ type: params.type,
64
+ channel: params.channel,
65
+ status: "pending",
66
+ content: params.content,
67
+ retryOf: previousDeliveryId,
68
+ retriesLeft,
69
+ recipientEmail: params.recipientEmail ?? null,
70
+ createdAt: /* @__PURE__ */ new Date(),
71
+ updatedAt: /* @__PURE__ */ new Date()
72
+ });
73
+ try {
74
+ const result = await params.handler.send({
75
+ userId: params.userId,
76
+ type: params.type,
77
+ content: params.content,
78
+ recipientEmail: params.recipientEmail
79
+ });
80
+ await ctx.db.update(table).set({
81
+ status: "sent",
82
+ externalId: result.externalId ?? null,
83
+ updatedAt: /* @__PURE__ */ new Date()
84
+ }).where(eq(col(table, "id"), deliveryId));
85
+ return;
86
+ } catch (err) {
87
+ lastError = err instanceof Error ? err : new Error(String(err));
88
+ await ctx.db.update(table).set({
89
+ status: "failed",
90
+ error: lastError.message,
91
+ updatedAt: /* @__PURE__ */ new Date()
92
+ }).where(eq(col(table, "id"), deliveryId));
93
+ previousDeliveryId = deliveryId;
94
+ if (attempt < maxAttempts - 1) {
95
+ const delays = params.backoffDelays ?? DEFAULT_BACKOFF_DELAYS;
96
+ const backoffMs = delays[Math.min(attempt, delays.length - 1)];
97
+ await delay(backoffMs);
98
+ }
99
+ }
100
+ }
101
+ throw new DeliveryError(previousDeliveryId, `Delivery failed after ${maxAttempts} attempt(s) on channel "${params.channel}": ${lastError?.message}`, { cause: lastError });
102
+ }
103
+
104
+ //#endregion
105
+ //#region src/preferences.ts
106
+ async function getPreferences(ctx, userId) {
107
+ const table = getTable(ctx.schema, ctx.tableNames.notificationPreference, "notificationPreference");
108
+ return ctx.db.select().from(table).where(eq(col(table, "userId"), userId));
109
+ }
110
+ async function updatePreferences(ctx, userId, prefs) {
111
+ const table = getTable(ctx.schema, ctx.tableNames.notificationPreference, "notificationPreference");
112
+ for (const pref of prefs) {
113
+ const existing = await ctx.db.select({ id: col(table, "id") }).from(table).where(and(eq(col(table, "userId"), userId), eq(col(table, "notificationType"), pref.notificationType), eq(col(table, "channel"), pref.channel))).limit(1);
114
+ if (existing.length > 0) await ctx.db.update(table).set({
115
+ enabled: pref.enabled,
116
+ updatedAt: /* @__PURE__ */ new Date()
117
+ }).where(eq(col(table, "id"), existing[0].id));
118
+ else await ctx.db.insert(table).values({
119
+ id: crypto.randomUUID(),
120
+ userId,
121
+ notificationType: pref.notificationType,
122
+ channel: pref.channel,
123
+ enabled: pref.enabled,
124
+ updatedAt: /* @__PURE__ */ new Date()
125
+ });
126
+ }
127
+ }
128
+ /**
129
+ * Resolve whether a channel is enabled for a given notification type and user.
130
+ *
131
+ * Hierarchy (most specific wins):
132
+ * 1. Per-user + per-type + per-channel
133
+ * 2. Per-user + per-type + all-channels ("*")
134
+ * 3. Per-user + all-types ("*") + per-channel
135
+ * 4. System defaults from config
136
+ */
137
+ function resolveChannelEnabled(preferences, defaults, notificationType, channel) {
138
+ const exact = preferences.find((p) => p.notificationType === notificationType && p.channel === channel);
139
+ if (exact) return exact.enabled;
140
+ const typeWild = preferences.find((p) => p.notificationType === notificationType && p.channel === "*");
141
+ if (typeWild) return typeWild.enabled;
142
+ const channelWild = preferences.find((p) => p.notificationType === "*" && p.channel === channel);
143
+ if (channelWild) return channelWild.enabled;
144
+ return defaults.enabledChannels.includes(channel);
145
+ }
146
+
147
+ //#endregion
148
+ //#region src/factory.ts
149
+ const DEFAULT_TABLE_NAMES = {
150
+ notificationDelivery: "notificationDelivery",
151
+ inAppNotification: "inAppNotification",
152
+ notificationPreference: "notificationPreference"
153
+ };
154
+ const DEFAULT_PREFERENCES = { enabledChannels: ["email", "in-app"] };
155
+ function createNotifications(config) {
156
+ const { database, channels, notifications } = config;
157
+ const { db, schema } = database;
158
+ const defaults = config.defaultPreferences ?? DEFAULT_PREFERENCES;
159
+ const tableNames = {
160
+ ...DEFAULT_TABLE_NAMES,
161
+ ...database.tableNames
162
+ };
163
+ const handlers = /* @__PURE__ */ new Map();
164
+ const channelRetries = /* @__PURE__ */ new Map();
165
+ function getHandler(channelName) {
166
+ let handler = handlers.get(channelName);
167
+ if (!handler) {
168
+ const plugin = channels.find((c) => c.name === channelName);
169
+ if (!plugin) throw new NotificationError(`Channel "${channelName}" is not registered. Available channels: ${channels.map((c) => c.name).join(", ")}`);
170
+ handler = plugin.init({
171
+ db,
172
+ schema,
173
+ tableNames
174
+ });
175
+ handlers.set(channelName, handler);
176
+ channelRetries.set(channelName, plugin.retries);
177
+ }
178
+ return handler;
179
+ }
180
+ const instance = {
181
+ async send(params) {
182
+ const def = notifications[params.type];
183
+ if (!def) throw new NotificationError(`Notification type "${params.type}" is not defined.`);
184
+ let userPrefs = [];
185
+ if (!def.critical) userPrefs = await getPreferences({
186
+ db,
187
+ schema,
188
+ tableNames,
189
+ defaults
190
+ }, params.userId);
191
+ const data = "data" in params ? params.data : {};
192
+ const errors = [];
193
+ for (const [channelName, contentFn] of Object.entries(def.channels)) {
194
+ if (!contentFn) continue;
195
+ if (!def.critical && !resolveChannelEnabled(userPrefs, defaults, params.type, channelName)) continue;
196
+ const handler = getHandler(channelName);
197
+ const retries = channelRetries.get(channelName) ?? 0;
198
+ const content = contentFn(data);
199
+ try {
200
+ await deliver({
201
+ db,
202
+ schema,
203
+ tableNames
204
+ }, {
205
+ userId: params.userId,
206
+ type: params.type,
207
+ channel: channelName,
208
+ content,
209
+ recipientEmail: params.recipientEmail,
210
+ retries,
211
+ handler
212
+ });
213
+ } catch (err) {
214
+ errors.push(err instanceof Error ? err : new Error(String(err)));
215
+ }
216
+ }
217
+ if (errors.length > 0) throw new NotificationError(`Failed to deliver notification "${params.type}" on ${errors.length} channel(s)`, { cause: errors.length === 1 ? errors[0] : errors });
218
+ },
219
+ async list({ userId, limit = 50, unreadOnly = false }) {
220
+ const table = getTable(schema, tableNames.inAppNotification, "inAppNotification");
221
+ const conditions = [eq(col(table, "userId"), userId)];
222
+ if (unreadOnly) conditions.push(eq(col(table, "read"), false));
223
+ let query = db.select().from(table).$dynamic();
224
+ if (conditions.length === 1) query = query.where(conditions[0]);
225
+ else query = query.where(and(...conditions));
226
+ return query.orderBy(desc(col(table, "createdAt"))).limit(limit);
227
+ },
228
+ async markRead({ userId, notificationIds }) {
229
+ const table = getTable(schema, tableNames.inAppNotification, "inAppNotification");
230
+ if (notificationIds && notificationIds.length > 0) for (const id of notificationIds) await db.update(table).set({ read: true }).where(and(eq(col(table, "id"), id), eq(col(table, "userId"), userId)));
231
+ else await db.update(table).set({ read: true }).where(and(eq(col(table, "userId"), userId), eq(col(table, "read"), false)));
232
+ },
233
+ async getPreferences(userId) {
234
+ return getPreferences({
235
+ db,
236
+ schema,
237
+ tableNames,
238
+ defaults
239
+ }, userId);
240
+ },
241
+ async updatePreferences(userId, prefs) {
242
+ return updatePreferences({
243
+ db,
244
+ schema,
245
+ tableNames,
246
+ defaults
247
+ }, userId, prefs);
248
+ },
249
+ async handler(request, userId) {
250
+ if (!userId) return Response.json({ error: "Unauthorized" }, { status: 401 });
251
+ const path = new URL(request.url).pathname.replace(/.*\/notifications/, "").replace(/\/$/, "");
252
+ if (path === "/mark-read" && request.method === "POST") {
253
+ const body = await request.json();
254
+ if (body.all) await instance.markRead({ userId });
255
+ else if (Array.isArray(body.notificationIds)) await instance.markRead({
256
+ userId,
257
+ notificationIds: body.notificationIds
258
+ });
259
+ else return Response.json({ error: "Provide notificationIds array or { all: true }" }, { status: 400 });
260
+ return Response.json({ ok: true });
261
+ }
262
+ if (path === "/preferences" && request.method === "GET") {
263
+ const preferences = await instance.getPreferences(userId);
264
+ return Response.json({ preferences });
265
+ }
266
+ if (path === "/preferences" && request.method === "PUT") {
267
+ const body = await request.json();
268
+ if (!Array.isArray(body.preferences)) return Response.json({ error: "Provide a preferences array" }, { status: 400 });
269
+ await instance.updatePreferences(userId, body.preferences);
270
+ return Response.json({ ok: true });
271
+ }
272
+ return Response.json({ error: "Not found" }, { status: 404 });
273
+ },
274
+ $Infer: {}
275
+ };
276
+ return instance;
277
+ }
278
+
279
+ //#endregion
280
+ //#region src/define.ts
281
+ function defineNotification(def) {
282
+ return def;
283
+ }
284
+
285
+ //#endregion
286
+ //#region src/channels/email.ts
287
+ function emailChannel(config) {
288
+ return {
289
+ name: "email",
290
+ retries: config.retries ?? 3,
291
+ init() {
292
+ return { async send(params) {
293
+ const content = params.content;
294
+ if (!params.recipientEmail) throw new Error("recipientEmail is required for email channel. Pass it in send() or ensure it's set on the user.");
295
+ return { externalId: (await config.sendEmail({
296
+ to: params.recipientEmail,
297
+ subject: content.subject,
298
+ html: content.html,
299
+ text: content.text
300
+ }))?.externalId };
301
+ } };
302
+ }
303
+ };
304
+ }
305
+
306
+ //#endregion
307
+ //#region src/channels/in-app.ts
308
+ function inAppChannel() {
309
+ return {
310
+ name: "in-app",
311
+ retries: 0,
312
+ init(ctx) {
313
+ const table = getTable(ctx.schema, ctx.tableNames.inAppNotification, "inAppNotification");
314
+ return { async send(params) {
315
+ const content = params.content;
316
+ const id = crypto.randomUUID();
317
+ await ctx.db.insert(table).values({
318
+ id,
319
+ userId: params.userId,
320
+ type: params.type,
321
+ title: content.title,
322
+ body: content.body ?? null,
323
+ url: content.url ?? null,
324
+ icon: content.icon ?? null,
325
+ read: false,
326
+ createdAt: /* @__PURE__ */ new Date()
327
+ });
328
+ return { externalId: id };
329
+ } };
330
+ }
331
+ };
332
+ }
333
+
334
+ //#endregion
335
+ export { createNotifications, defineNotification, emailChannel, inAppChannel };
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@mikstack/notifications",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/MikaelSiidorow/mikstack.git",
8
+ "directory": "packages/notifications"
9
+ },
10
+ "type": "module",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js"
15
+ },
16
+ "./client": {
17
+ "types": "./dist/client.d.ts",
18
+ "import": "./dist/client.js"
19
+ }
20
+ },
21
+ "files": [
22
+ "dist"
23
+ ],
24
+ "scripts": {
25
+ "build": "tsdown && publint && attw --pack . --profile esm-only",
26
+ "dev": "tsdown --watch",
27
+ "check": "tsc --noEmit",
28
+ "lint": "oxlint --type-aware --type-check && oxfmt --check src tsdown.config.ts",
29
+ "format": "oxfmt src tsdown.config.ts",
30
+ "test": "bun test"
31
+ },
32
+ "peerDependencies": {
33
+ "drizzle-orm": ">=0.38.0"
34
+ },
35
+ "devDependencies": {
36
+ "@arethetypeswrong/cli": "^0.18.1",
37
+ "@types/bun": "^1.3.8",
38
+ "@types/node": "^22.15.0",
39
+ "drizzle-orm": "^0.45.1",
40
+ "oxfmt": "^0.28.0",
41
+ "oxlint": "^1.43.0",
42
+ "oxlint-tsgolint": "^0.11.4",
43
+ "publint": "^0.3.12",
44
+ "tsdown": "^0.12.0",
45
+ "typescript": "^5.9.3"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ }
50
+ }