@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/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
+ }