@checkstack/notification-backend 0.0.2
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/CHANGELOG.md +133 -0
- package/drizzle/0000_tan_stryfe.sql +28 -0
- package/drizzle/0001_futuristic_apocalypse.sql +11 -0
- package/drizzle/0002_early_the_spike.sql +3 -0
- package/drizzle/0003_tiny_wendell_vaughn.sql +1 -0
- package/drizzle/0004_regular_corsair.sql +4 -0
- package/drizzle/meta/0000_snapshot.json +188 -0
- package/drizzle/meta/0001_snapshot.json +260 -0
- package/drizzle/meta/0002_snapshot.json +278 -0
- package/drizzle/meta/0003_snapshot.json +188 -0
- package/drizzle/meta/0004_snapshot.json +188 -0
- package/drizzle/meta/_journal.json +41 -0
- package/drizzle.config.ts +8 -0
- package/package.json +37 -0
- package/src/index.ts +280 -0
- package/src/oauth-callback-handler.ts +209 -0
- package/src/retention-config.ts +30 -0
- package/src/router.test.ts +38 -0
- package/src/router.ts +1090 -0
- package/src/schema.ts +54 -0
- package/src/service.ts +216 -0
- package/src/strategy-service.test.ts +478 -0
- package/src/strategy-service.ts +551 -0
- package/tsconfig.json +6 -0
package/src/schema.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {
|
|
2
|
+
pgTable,
|
|
3
|
+
text,
|
|
4
|
+
boolean,
|
|
5
|
+
uuid,
|
|
6
|
+
timestamp,
|
|
7
|
+
jsonb,
|
|
8
|
+
primaryKey,
|
|
9
|
+
} from "drizzle-orm/pg-core";
|
|
10
|
+
import type { NotificationAction } from "@checkstack/notification-common";
|
|
11
|
+
|
|
12
|
+
// User notifications table
|
|
13
|
+
export const notifications = pgTable("notifications", {
|
|
14
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
15
|
+
userId: text("user_id").notNull(), // No FK - cross-schema limitation
|
|
16
|
+
title: text("title").notNull(),
|
|
17
|
+
/** Notification body content (supports markdown) */
|
|
18
|
+
body: text("body").notNull(),
|
|
19
|
+
/** Single primary action button */
|
|
20
|
+
action: jsonb("action").$type<NotificationAction | null>(),
|
|
21
|
+
importance: text("importance").notNull().default("info"), // 'info' | 'warning' | 'critical'
|
|
22
|
+
isRead: boolean("is_read").notNull().default(false),
|
|
23
|
+
groupId: text("group_id"), // Namespaced: "pluginId.groupName"
|
|
24
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Notification groups (created by plugins)
|
|
28
|
+
// ID is namespaced: "pluginId.groupName"
|
|
29
|
+
export const notificationGroups = pgTable("notification_groups", {
|
|
30
|
+
id: text("id").primaryKey(), // Namespaced: "pluginId.groupName"
|
|
31
|
+
name: text("name").notNull(),
|
|
32
|
+
description: text("description").notNull(),
|
|
33
|
+
ownerPlugin: text("owner_plugin").notNull(),
|
|
34
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// User-group subscriptions
|
|
38
|
+
export const notificationSubscriptions = pgTable(
|
|
39
|
+
"notification_subscriptions",
|
|
40
|
+
{
|
|
41
|
+
userId: text("user_id").notNull(),
|
|
42
|
+
groupId: text("group_id")
|
|
43
|
+
.notNull()
|
|
44
|
+
.references(() => notificationGroups.id, { onDelete: "cascade" }),
|
|
45
|
+
subscribedAt: timestamp("subscribed_at").defaultNow().notNull(),
|
|
46
|
+
},
|
|
47
|
+
(t) => ({
|
|
48
|
+
pk: primaryKey({ columns: [t.userId, t.groupId] }),
|
|
49
|
+
})
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// Note: User notification preferences are now stored via ConfigService
|
|
53
|
+
// using the user-pref.{userId}.{strategyId} pattern for automatic
|
|
54
|
+
// secret encryption of OAuth tokens.
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
2
|
+
import { eq, and, count, desc, lt } from "drizzle-orm";
|
|
3
|
+
import * as schema from "./schema";
|
|
4
|
+
|
|
5
|
+
// --- Internal service functions for router (not namespaced) ---
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Get notifications for a user (for router use)
|
|
9
|
+
*/
|
|
10
|
+
export async function getUserNotifications(
|
|
11
|
+
db: NodePgDatabase<typeof schema>,
|
|
12
|
+
userId: string,
|
|
13
|
+
options: { limit: number; offset: number; unreadOnly: boolean }
|
|
14
|
+
): Promise<{
|
|
15
|
+
notifications: (typeof schema.notifications.$inferSelect)[];
|
|
16
|
+
total: number;
|
|
17
|
+
}> {
|
|
18
|
+
const conditions = [eq(schema.notifications.userId, userId)];
|
|
19
|
+
|
|
20
|
+
if (options.unreadOnly) {
|
|
21
|
+
conditions.push(eq(schema.notifications.isRead, false));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const whereClause = and(...conditions);
|
|
25
|
+
|
|
26
|
+
const [notificationsResult, countResult] = await Promise.all([
|
|
27
|
+
db
|
|
28
|
+
.select()
|
|
29
|
+
.from(schema.notifications)
|
|
30
|
+
.where(whereClause)
|
|
31
|
+
.orderBy(desc(schema.notifications.createdAt))
|
|
32
|
+
.limit(options.limit)
|
|
33
|
+
.offset(options.offset),
|
|
34
|
+
db.select({ count: count() }).from(schema.notifications).where(whereClause),
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
notifications: notificationsResult,
|
|
39
|
+
total: countResult[0]?.count ?? 0,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Get unread count for a user
|
|
45
|
+
*/
|
|
46
|
+
export async function getUnreadCount(
|
|
47
|
+
db: NodePgDatabase<typeof schema>,
|
|
48
|
+
userId: string
|
|
49
|
+
): Promise<number> {
|
|
50
|
+
const result = await db
|
|
51
|
+
.select({ count: count() })
|
|
52
|
+
.from(schema.notifications)
|
|
53
|
+
.where(
|
|
54
|
+
and(
|
|
55
|
+
eq(schema.notifications.userId, userId),
|
|
56
|
+
eq(schema.notifications.isRead, false)
|
|
57
|
+
)
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
return result[0]?.count ?? 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Mark notification(s) as read
|
|
65
|
+
*/
|
|
66
|
+
export async function markAsRead(
|
|
67
|
+
db: NodePgDatabase<typeof schema>,
|
|
68
|
+
userId: string,
|
|
69
|
+
notificationId?: string
|
|
70
|
+
): Promise<void> {
|
|
71
|
+
const whereClause = notificationId
|
|
72
|
+
? and(
|
|
73
|
+
eq(schema.notifications.id, notificationId),
|
|
74
|
+
eq(schema.notifications.userId, userId)
|
|
75
|
+
)
|
|
76
|
+
: eq(schema.notifications.userId, userId);
|
|
77
|
+
|
|
78
|
+
await db
|
|
79
|
+
.update(schema.notifications)
|
|
80
|
+
.set({ isRead: true })
|
|
81
|
+
.where(whereClause);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Delete a notification
|
|
86
|
+
*/
|
|
87
|
+
export async function deleteNotification(
|
|
88
|
+
db: NodePgDatabase<typeof schema>,
|
|
89
|
+
userId: string,
|
|
90
|
+
notificationId: string
|
|
91
|
+
): Promise<void> {
|
|
92
|
+
await db
|
|
93
|
+
.delete(schema.notifications)
|
|
94
|
+
.where(
|
|
95
|
+
and(
|
|
96
|
+
eq(schema.notifications.id, notificationId),
|
|
97
|
+
eq(schema.notifications.userId, userId)
|
|
98
|
+
)
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Get all notification groups
|
|
104
|
+
*/
|
|
105
|
+
export async function getAllGroups(
|
|
106
|
+
db: NodePgDatabase<typeof schema>
|
|
107
|
+
): Promise<(typeof schema.notificationGroups.$inferSelect)[]> {
|
|
108
|
+
return db.select().from(schema.notificationGroups);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Get user's subscriptions with enriched group details
|
|
113
|
+
*/
|
|
114
|
+
export async function getEnrichedUserSubscriptions(
|
|
115
|
+
db: NodePgDatabase<typeof schema>,
|
|
116
|
+
userId: string
|
|
117
|
+
): Promise<
|
|
118
|
+
{
|
|
119
|
+
groupId: string;
|
|
120
|
+
groupName: string;
|
|
121
|
+
groupDescription: string;
|
|
122
|
+
ownerPlugin: string;
|
|
123
|
+
subscribedAt: Date;
|
|
124
|
+
}[]
|
|
125
|
+
> {
|
|
126
|
+
const result = await db
|
|
127
|
+
.select({
|
|
128
|
+
groupId: schema.notificationSubscriptions.groupId,
|
|
129
|
+
groupName: schema.notificationGroups.name,
|
|
130
|
+
groupDescription: schema.notificationGroups.description,
|
|
131
|
+
ownerPlugin: schema.notificationGroups.ownerPlugin,
|
|
132
|
+
subscribedAt: schema.notificationSubscriptions.subscribedAt,
|
|
133
|
+
})
|
|
134
|
+
.from(schema.notificationSubscriptions)
|
|
135
|
+
.innerJoin(
|
|
136
|
+
schema.notificationGroups,
|
|
137
|
+
eq(schema.notificationSubscriptions.groupId, schema.notificationGroups.id)
|
|
138
|
+
)
|
|
139
|
+
.where(eq(schema.notificationSubscriptions.userId, userId));
|
|
140
|
+
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Subscribe user to a group
|
|
146
|
+
*/
|
|
147
|
+
export async function subscribeToGroup(
|
|
148
|
+
db: NodePgDatabase<typeof schema>,
|
|
149
|
+
userId: string,
|
|
150
|
+
groupId: string
|
|
151
|
+
): Promise<void> {
|
|
152
|
+
// First verify the group exists
|
|
153
|
+
const group = await db
|
|
154
|
+
.select({ id: schema.notificationGroups.id })
|
|
155
|
+
.from(schema.notificationGroups)
|
|
156
|
+
.where(eq(schema.notificationGroups.id, groupId))
|
|
157
|
+
.limit(1);
|
|
158
|
+
|
|
159
|
+
if (group.length === 0) {
|
|
160
|
+
throw new Error(`Notification group '${groupId}' does not exist`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
await db
|
|
164
|
+
.insert(schema.notificationSubscriptions)
|
|
165
|
+
.values({
|
|
166
|
+
userId,
|
|
167
|
+
groupId,
|
|
168
|
+
})
|
|
169
|
+
.onConflictDoNothing();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Unsubscribe user from a group
|
|
174
|
+
*/
|
|
175
|
+
export async function unsubscribeFromGroup(
|
|
176
|
+
db: NodePgDatabase<typeof schema>,
|
|
177
|
+
userId: string,
|
|
178
|
+
groupId: string
|
|
179
|
+
): Promise<void> {
|
|
180
|
+
await db
|
|
181
|
+
.delete(schema.notificationSubscriptions)
|
|
182
|
+
.where(
|
|
183
|
+
and(
|
|
184
|
+
eq(schema.notificationSubscriptions.userId, userId),
|
|
185
|
+
eq(schema.notificationSubscriptions.groupId, groupId)
|
|
186
|
+
)
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Purge old notifications based on retention policy.
|
|
192
|
+
* Retention settings should be fetched from ConfigService and passed in.
|
|
193
|
+
*/
|
|
194
|
+
export async function purgeOldNotifications({
|
|
195
|
+
db,
|
|
196
|
+
enabled,
|
|
197
|
+
retentionDays,
|
|
198
|
+
}: {
|
|
199
|
+
db: NodePgDatabase<typeof schema>;
|
|
200
|
+
enabled: boolean;
|
|
201
|
+
retentionDays: number;
|
|
202
|
+
}): Promise<number> {
|
|
203
|
+
if (!enabled) {
|
|
204
|
+
return 0;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const cutoffDate = new Date();
|
|
208
|
+
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
|
|
209
|
+
|
|
210
|
+
const result = await db
|
|
211
|
+
.delete(schema.notifications)
|
|
212
|
+
.where(lt(schema.notifications.createdAt, cutoffDate))
|
|
213
|
+
.returning({ id: schema.notifications.id });
|
|
214
|
+
|
|
215
|
+
return result.length;
|
|
216
|
+
}
|