@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 +119 -0
- package/dist/client.d.ts +21 -0
- package/dist/client.js +47 -0
- package/dist/index.d.ts +157 -0
- package/dist/index.js +335 -0
- package/package.json +50 -0
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
|
package/dist/client.d.ts
ADDED
|
@@ -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 };
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|