@checkstack/announcement-backend 0.2.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/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
1
+ # @checkstack/announcement-backend
2
+
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - dee86ec: feat: add portal announcement system
8
+
9
+ Introduces a complete announcement system for communicating with portal users:
10
+
11
+ - **announcement-common**: Zod schemas for announcements (severity, visibility, display mode), oRPC contract with 6 procedures (public retrieval, user dismissal, admin CRUD), access rules, and `ANNOUNCEMENT_UPDATED` signal definition
12
+ - **announcement-backend**: Drizzle schema with `announcements` and `announcement_dismissals` tables, router with temporal filtering, visibility control, per-user dismissal persistence, user cleanup hook, real-time signal broadcasting on create/update/delete, and command palette registration ("Create Announcement", "Manage Announcements" with `⇧⌘A` shortcut)
13
+ - **announcement-frontend**: Admin management page with create/edit dialog, global banner component above the navbar (severity-colored, expandable markdown), dashboard cards with compact expand/collapse, admin menu link, and real-time WebSocket signal subscription for instant UI updates
14
+ - **frontend**: Integrates AnnouncementBanner into App.tsx for global visibility
15
+
16
+ ### Patch Changes
17
+
18
+ - Updated dependencies [dee86ec]
19
+ - @checkstack/announcement-common@0.2.0
@@ -0,0 +1,23 @@
1
+ CREATE TABLE "announcement_dismissals" (
2
+ "announcement_id" text NOT NULL,
3
+ "user_id" text NOT NULL,
4
+ "dismissed_at" timestamp DEFAULT now() NOT NULL,
5
+ CONSTRAINT "announcement_dismissals_announcement_id_user_id_pk" PRIMARY KEY("announcement_id","user_id")
6
+ );
7
+ --> statement-breakpoint
8
+ CREATE TABLE "announcements" (
9
+ "id" text PRIMARY KEY NOT NULL,
10
+ "title" text NOT NULL,
11
+ "message" text NOT NULL,
12
+ "severity" text DEFAULT 'info' NOT NULL,
13
+ "visibility" text DEFAULT 'all' NOT NULL,
14
+ "display_mode" text DEFAULT 'both' NOT NULL,
15
+ "active" boolean DEFAULT true NOT NULL,
16
+ "starts_at" timestamp,
17
+ "expires_at" timestamp,
18
+ "created_by" text NOT NULL,
19
+ "created_at" timestamp DEFAULT now() NOT NULL,
20
+ "updated_at" timestamp DEFAULT now() NOT NULL
21
+ );
22
+ --> statement-breakpoint
23
+ ALTER TABLE "announcement_dismissals" ADD CONSTRAINT "announcement_dismissals_announcement_id_announcements_id_fk" FOREIGN KEY ("announcement_id") REFERENCES "announcements"("id") ON DELETE cascade ON UPDATE no action;
@@ -0,0 +1,164 @@
1
+ {
2
+ "id": "fb3688d6-20bb-4c60-8f3b-c0cd4674e705",
3
+ "prevId": "00000000-0000-0000-0000-000000000000",
4
+ "version": "7",
5
+ "dialect": "postgresql",
6
+ "tables": {
7
+ "public.announcement_dismissals": {
8
+ "name": "announcement_dismissals",
9
+ "schema": "",
10
+ "columns": {
11
+ "announcement_id": {
12
+ "name": "announcement_id",
13
+ "type": "text",
14
+ "primaryKey": false,
15
+ "notNull": true
16
+ },
17
+ "user_id": {
18
+ "name": "user_id",
19
+ "type": "text",
20
+ "primaryKey": false,
21
+ "notNull": true
22
+ },
23
+ "dismissed_at": {
24
+ "name": "dismissed_at",
25
+ "type": "timestamp",
26
+ "primaryKey": false,
27
+ "notNull": true,
28
+ "default": "now()"
29
+ }
30
+ },
31
+ "indexes": {},
32
+ "foreignKeys": {
33
+ "announcement_dismissals_announcement_id_announcements_id_fk": {
34
+ "name": "announcement_dismissals_announcement_id_announcements_id_fk",
35
+ "tableFrom": "announcement_dismissals",
36
+ "tableTo": "announcements",
37
+ "columnsFrom": [
38
+ "announcement_id"
39
+ ],
40
+ "columnsTo": [
41
+ "id"
42
+ ],
43
+ "onDelete": "cascade",
44
+ "onUpdate": "no action"
45
+ }
46
+ },
47
+ "compositePrimaryKeys": {
48
+ "announcement_dismissals_announcement_id_user_id_pk": {
49
+ "name": "announcement_dismissals_announcement_id_user_id_pk",
50
+ "columns": [
51
+ "announcement_id",
52
+ "user_id"
53
+ ]
54
+ }
55
+ },
56
+ "uniqueConstraints": {},
57
+ "policies": {},
58
+ "checkConstraints": {},
59
+ "isRLSEnabled": false
60
+ },
61
+ "public.announcements": {
62
+ "name": "announcements",
63
+ "schema": "",
64
+ "columns": {
65
+ "id": {
66
+ "name": "id",
67
+ "type": "text",
68
+ "primaryKey": true,
69
+ "notNull": true
70
+ },
71
+ "title": {
72
+ "name": "title",
73
+ "type": "text",
74
+ "primaryKey": false,
75
+ "notNull": true
76
+ },
77
+ "message": {
78
+ "name": "message",
79
+ "type": "text",
80
+ "primaryKey": false,
81
+ "notNull": true
82
+ },
83
+ "severity": {
84
+ "name": "severity",
85
+ "type": "text",
86
+ "primaryKey": false,
87
+ "notNull": true,
88
+ "default": "'info'"
89
+ },
90
+ "visibility": {
91
+ "name": "visibility",
92
+ "type": "text",
93
+ "primaryKey": false,
94
+ "notNull": true,
95
+ "default": "'all'"
96
+ },
97
+ "display_mode": {
98
+ "name": "display_mode",
99
+ "type": "text",
100
+ "primaryKey": false,
101
+ "notNull": true,
102
+ "default": "'both'"
103
+ },
104
+ "active": {
105
+ "name": "active",
106
+ "type": "boolean",
107
+ "primaryKey": false,
108
+ "notNull": true,
109
+ "default": true
110
+ },
111
+ "starts_at": {
112
+ "name": "starts_at",
113
+ "type": "timestamp",
114
+ "primaryKey": false,
115
+ "notNull": false
116
+ },
117
+ "expires_at": {
118
+ "name": "expires_at",
119
+ "type": "timestamp",
120
+ "primaryKey": false,
121
+ "notNull": false
122
+ },
123
+ "created_by": {
124
+ "name": "created_by",
125
+ "type": "text",
126
+ "primaryKey": false,
127
+ "notNull": true
128
+ },
129
+ "created_at": {
130
+ "name": "created_at",
131
+ "type": "timestamp",
132
+ "primaryKey": false,
133
+ "notNull": true,
134
+ "default": "now()"
135
+ },
136
+ "updated_at": {
137
+ "name": "updated_at",
138
+ "type": "timestamp",
139
+ "primaryKey": false,
140
+ "notNull": true,
141
+ "default": "now()"
142
+ }
143
+ },
144
+ "indexes": {},
145
+ "foreignKeys": {},
146
+ "compositePrimaryKeys": {},
147
+ "uniqueConstraints": {},
148
+ "policies": {},
149
+ "checkConstraints": {},
150
+ "isRLSEnabled": false
151
+ }
152
+ },
153
+ "enums": {},
154
+ "schemas": {},
155
+ "sequences": {},
156
+ "roles": {},
157
+ "policies": {},
158
+ "views": {},
159
+ "_meta": {
160
+ "columns": {},
161
+ "schemas": {},
162
+ "tables": {}
163
+ }
164
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "postgresql",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "7",
8
+ "when": 1776417116365,
9
+ "tag": "0000_cooing_onslaught",
10
+ "breakpoints": true
11
+ }
12
+ ]
13
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from "drizzle-kit";
2
+
3
+ export default defineConfig({
4
+ schema: "./src/schema.ts",
5
+ out: "./drizzle",
6
+ dialect: "postgresql",
7
+ });
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@checkstack/announcement-backend",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "checkstack": {
7
+ "type": "backend"
8
+ },
9
+ "scripts": {
10
+ "typecheck": "tsc --noEmit",
11
+ "generate": "drizzle-kit generate",
12
+ "lint": "bun run lint:code",
13
+ "lint:code": "eslint . --max-warnings 0"
14
+ },
15
+ "dependencies": {
16
+ "@checkstack/backend-api": "0.10.0",
17
+ "@checkstack/announcement-common": "0.1.0",
18
+ "@checkstack/auth-backend": "0.4.14",
19
+ "@checkstack/command-backend": "0.1.15",
20
+ "@checkstack/common": "0.6.4",
21
+ "@checkstack/signal-common": "0.1.8",
22
+ "drizzle-orm": "^0.45.0",
23
+ "zod": "^4.2.1",
24
+ "@orpc/server": "^1.13.2"
25
+ },
26
+ "devDependencies": {
27
+ "@checkstack/drizzle-helper": "0.0.4",
28
+ "@checkstack/scripts": "0.1.2",
29
+ "@checkstack/test-utils-backend": "0.1.15",
30
+ "@checkstack/tsconfig": "0.0.4",
31
+ "@types/bun": "^1.0.0",
32
+ "typescript": "^5.0.0"
33
+ }
34
+ }
package/src/index.ts ADDED
@@ -0,0 +1,88 @@
1
+ import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
2
+ import {
3
+ pluginMetadata,
4
+ announcementContract,
5
+ announcementAccessRules,
6
+ announcementAccess,
7
+ announcementRoutes,
8
+ } from "@checkstack/announcement-common";
9
+ import { resolveRoute } from "@checkstack/common";
10
+ import { eq } from "drizzle-orm";
11
+ import * as schema from "./schema";
12
+ import { createAnnouncementRouter } from "./router";
13
+ import { authHooks } from "@checkstack/auth-backend";
14
+ import { registerSearchProvider } from "@checkstack/command-backend";
15
+ import type { SafeDatabase } from "@checkstack/backend-api";
16
+
17
+ export default createBackendPlugin({
18
+ metadata: pluginMetadata,
19
+
20
+ register(env) {
21
+ // Register access rules
22
+ env.registerAccessRules(announcementAccessRules);
23
+
24
+ env.registerInit({
25
+ schema,
26
+ deps: {
27
+ rpc: coreServices.rpc,
28
+ logger: coreServices.logger,
29
+ signalService: coreServices.signalService,
30
+ },
31
+ init: async ({ database, rpc, signalService }) => {
32
+ const db = database as SafeDatabase<typeof schema>;
33
+
34
+ // Create and register the announcement router
35
+ const router = createAnnouncementRouter(db, signalService);
36
+ rpc.registerRouter(router, announcementContract);
37
+
38
+ // Register commands in the command palette
39
+ registerSearchProvider({
40
+ pluginMetadata,
41
+ commands: [
42
+ {
43
+ id: "create",
44
+ title: "Create Announcement",
45
+ subtitle: "Create a new portal announcement",
46
+ iconName: "Megaphone",
47
+ route:
48
+ resolveRoute(announcementRoutes.routes.manage) +
49
+ "?action=create",
50
+ requiredAccessRules: [announcementAccess.manage],
51
+ },
52
+ {
53
+ id: "manage",
54
+ title: "Manage Announcements",
55
+ subtitle: "View and manage portal announcements",
56
+ iconName: "Megaphone",
57
+ shortcuts: ["meta+shift+a", "ctrl+shift+a"],
58
+ route: resolveRoute(announcementRoutes.routes.manage),
59
+ requiredAccessRules: [announcementAccess.manage],
60
+ },
61
+ ],
62
+ });
63
+ },
64
+ afterPluginsReady: async ({ database, logger, onHook }) => {
65
+ const db = database as SafeDatabase<typeof schema>;
66
+
67
+ // Clean up dismissals when a user is deleted
68
+ onHook(
69
+ authHooks.userDeleted,
70
+ async ({ userId }) => {
71
+ logger.debug(
72
+ `Cleaning up announcement dismissals for deleted user: ${userId}`,
73
+ );
74
+ await db
75
+ .delete(schema.announcementDismissals)
76
+ .where(eq(schema.announcementDismissals.userId, userId));
77
+ logger.debug(
78
+ `Cleaned up announcement dismissals for user: ${userId}`,
79
+ );
80
+ },
81
+ { mode: "work-queue", workerGroup: "user-cleanup" },
82
+ );
83
+
84
+ logger.debug("✅ Announcement Backend afterPluginsReady complete.");
85
+ },
86
+ });
87
+ },
88
+ });
@@ -0,0 +1,403 @@
1
+ import { describe, it, expect, mock, beforeEach } from "bun:test";
2
+ import { createAnnouncementRouter } from "./router";
3
+ import { createMockRpcContext } from "@checkstack/backend-api";
4
+ import { createMockDb } from "@checkstack/test-utils-backend";
5
+ import { ANNOUNCEMENT_UPDATED } from "@checkstack/announcement-common";
6
+ import { call } from "@orpc/server";
7
+
8
+ describe("Announcement Router", () => {
9
+ const adminUser = {
10
+ type: "user" as const,
11
+ id: "admin-user-1",
12
+ accessRules: ["*"],
13
+ roles: [],
14
+ };
15
+
16
+ const regularUser = {
17
+ type: "user" as const,
18
+ id: "regular-user-1",
19
+ accessRules: ["announcement.announcement.read"],
20
+ roles: [],
21
+ };
22
+
23
+ let mockDb: ReturnType<typeof createMockDb>;
24
+ let router: ReturnType<typeof createAnnouncementRouter>;
25
+
26
+ const mockSignalService = {
27
+ broadcast: mock(() => Promise.resolve()),
28
+ sendToUser: mock(() => Promise.resolve()),
29
+ sendToUsers: mock(() => Promise.resolve()),
30
+ sendToAuthorizedUsers: mock(() => Promise.resolve()),
31
+ };
32
+
33
+ const sampleAnnouncement = {
34
+ id: "ann-1",
35
+ title: "Test Announcement",
36
+ message: "This is a **test** announcement.",
37
+ severity: "info",
38
+ visibility: "all",
39
+ displayMode: "both",
40
+ active: true,
41
+ startsAt: undefined,
42
+ expiresAt: undefined,
43
+ createdBy: "admin-user-1",
44
+ createdAt: new Date("2026-04-17T10:00:00Z"),
45
+ updatedAt: new Date("2026-04-17T10:00:00Z"),
46
+ };
47
+
48
+ beforeEach(() => {
49
+ mockDb = createMockDb();
50
+ mockSignalService.broadcast.mockClear();
51
+ router = createAnnouncementRouter(mockDb as never, mockSignalService);
52
+ });
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // getActiveAnnouncements
56
+ // ---------------------------------------------------------------------------
57
+ describe("getActiveAnnouncements", () => {
58
+ it("returns active announcements for anonymous users", async () => {
59
+ const context = createMockRpcContext({ user: undefined });
60
+
61
+ // Mock select chain for announcements query
62
+ const mockWhereChain = Promise.resolve([sampleAnnouncement]);
63
+ const mockFromChain = Object.assign(
64
+ Promise.resolve([sampleAnnouncement]),
65
+ { where: mock(() => mockWhereChain) },
66
+ );
67
+ mockDb.select.mockReturnValueOnce({
68
+ from: mock(() => mockFromChain),
69
+ } as never);
70
+
71
+ const result = await call(router.getActiveAnnouncements, undefined, {
72
+ context,
73
+ });
74
+
75
+ expect(result.announcements).toHaveLength(1);
76
+ expect(result.announcements[0].title).toBe("Test Announcement");
77
+ });
78
+
79
+ it("filters out authenticated-only announcements for anonymous users", async () => {
80
+ const context = createMockRpcContext({ user: undefined });
81
+
82
+ const authenticatedOnly = {
83
+ ...sampleAnnouncement,
84
+ id: "ann-2",
85
+ visibility: "authenticated",
86
+ };
87
+
88
+ const mockWhereChain = Promise.resolve([
89
+ sampleAnnouncement,
90
+ authenticatedOnly,
91
+ ]);
92
+ const mockFromChain = Object.assign(
93
+ Promise.resolve([sampleAnnouncement, authenticatedOnly]),
94
+ { where: mock(() => mockWhereChain) },
95
+ );
96
+ mockDb.select.mockReturnValueOnce({
97
+ from: mock(() => mockFromChain),
98
+ } as never);
99
+
100
+ const result = await call(router.getActiveAnnouncements, undefined, {
101
+ context,
102
+ });
103
+
104
+ // Only the "all" visibility announcement should be returned
105
+ expect(result.announcements).toHaveLength(1);
106
+ expect(result.announcements[0].visibility).toBe("all");
107
+ });
108
+
109
+ it("excludes dismissed announcements for authenticated users", async () => {
110
+ const context = createMockRpcContext({ user: adminUser });
111
+
112
+ // First query: active announcements
113
+ const mockWhereChain = Promise.resolve([sampleAnnouncement]);
114
+ const mockFromChain = Object.assign(
115
+ Promise.resolve([sampleAnnouncement]),
116
+ { where: mock(() => mockWhereChain) },
117
+ );
118
+ mockDb.select.mockReturnValueOnce({
119
+ from: mock(() => mockFromChain),
120
+ } as never);
121
+
122
+ // Second query: dismissals for this user (ann-1 is dismissed)
123
+ const dismissalWhereChain = Promise.resolve([
124
+ { announcementId: "ann-1" },
125
+ ]);
126
+ const dismissalFromChain = Object.assign(
127
+ Promise.resolve([{ announcementId: "ann-1" }]),
128
+ { where: mock(() => dismissalWhereChain) },
129
+ );
130
+ mockDb.select.mockReturnValueOnce({
131
+ from: mock(() => dismissalFromChain),
132
+ } as never);
133
+
134
+ const result = await call(router.getActiveAnnouncements, undefined, {
135
+ context,
136
+ });
137
+
138
+ expect(result.announcements).toHaveLength(0);
139
+ });
140
+
141
+ it("includes dismissed announcements when includeDismissed is true", async () => {
142
+ const context = createMockRpcContext({ user: adminUser });
143
+
144
+ // Query returns one active announcement
145
+ const mockWhereChain = Promise.resolve([sampleAnnouncement]);
146
+ const mockFromChain = Object.assign(
147
+ Promise.resolve([sampleAnnouncement]),
148
+ { where: mock(() => mockWhereChain) },
149
+ );
150
+ mockDb.select.mockReturnValueOnce({
151
+ from: mock(() => mockFromChain),
152
+ } as never);
153
+
154
+ // No second select should happen (dismissals query is skipped)
155
+ const result = await call(
156
+ router.getActiveAnnouncements,
157
+ { includeDismissed: true },
158
+ { context },
159
+ );
160
+
161
+ // ann-1 should still be returned even though it would normally be dismissed
162
+ expect(result.announcements).toHaveLength(1);
163
+ expect(result.announcements[0].id).toBe("ann-1");
164
+ // Only one select call (no dismissal query)
165
+ expect(mockDb.select).toHaveBeenCalledTimes(1);
166
+ });
167
+ });
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // dismissAnnouncement
171
+ // ---------------------------------------------------------------------------
172
+ describe("dismissAnnouncement", () => {
173
+ it("creates a dismissal record for authenticated users", async () => {
174
+ const context = createMockRpcContext({ user: regularUser });
175
+
176
+ // Mock: check announcement exists
177
+ const mockWhereChain = Object.assign(Promise.resolve([{ id: "ann-1" }]), {
178
+ limit: mock(() => Promise.resolve([{ id: "ann-1" }])),
179
+ });
180
+ const mockFromChain = Object.assign(
181
+ Promise.resolve([{ id: "ann-1" }]),
182
+ { where: mock(() => mockWhereChain) },
183
+ );
184
+ mockDb.select.mockReturnValueOnce({
185
+ from: mock(() => mockFromChain),
186
+ } as never);
187
+
188
+ // Mock: insert dismissal with onConflictDoNothing chain
189
+ mockDb.insert.mockReturnValueOnce({
190
+ values: mock(() => ({
191
+ onConflictDoNothing: mock(() => Promise.resolve()),
192
+ })),
193
+ } as never);
194
+
195
+ await call(
196
+ router.dismissAnnouncement,
197
+ { announcementId: "ann-1" },
198
+ { context },
199
+ );
200
+
201
+ expect(mockDb.insert).toHaveBeenCalled();
202
+ });
203
+
204
+ it("rejects unauthenticated users", async () => {
205
+ const context = createMockRpcContext({ user: undefined });
206
+
207
+ await expect(
208
+ call(
209
+ router.dismissAnnouncement,
210
+ { announcementId: "ann-1" },
211
+ { context },
212
+ ),
213
+ ).rejects.toThrow("Authentication required");
214
+ });
215
+
216
+ it("throws NOT_FOUND for non-existent announcements", async () => {
217
+ const context = createMockRpcContext({ user: regularUser });
218
+
219
+ // Mock: announcement not found
220
+ const mockWhereChain = Object.assign(Promise.resolve([]), {
221
+ limit: mock(() => Promise.resolve([])),
222
+ });
223
+ const mockFromChain = Object.assign(Promise.resolve([]), {
224
+ where: mock(() => mockWhereChain),
225
+ });
226
+ mockDb.select.mockReturnValueOnce({
227
+ from: mock(() => mockFromChain),
228
+ } as never);
229
+
230
+ await expect(
231
+ call(
232
+ router.dismissAnnouncement,
233
+ { announcementId: "nonexistent" },
234
+ { context },
235
+ ),
236
+ ).rejects.toThrow("Announcement not found");
237
+ });
238
+ });
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // Admin: CRUD operations
242
+ // ---------------------------------------------------------------------------
243
+ describe("createAnnouncement", () => {
244
+ it("creates an announcement with all fields", async () => {
245
+ const context = createMockRpcContext({ user: adminUser });
246
+
247
+ // Mock: insert with returning chain
248
+ const createdRow = {
249
+ ...sampleAnnouncement,
250
+ id: "new-id",
251
+ title: "New Announcement",
252
+ severity: "warning",
253
+ displayMode: "banner",
254
+ createdBy: adminUser.id,
255
+ };
256
+ mockDb.insert.mockReturnValueOnce({
257
+ values: mock(() => ({
258
+ returning: mock(() => Promise.resolve([createdRow])),
259
+ })),
260
+ } as never);
261
+
262
+ const result = await call(
263
+ router.createAnnouncement,
264
+ {
265
+ title: "New Announcement",
266
+ message: "Important update!",
267
+ severity: "warning",
268
+ visibility: "all",
269
+ displayMode: "banner",
270
+ active: true,
271
+ },
272
+ { context },
273
+ );
274
+
275
+ expect(mockDb.insert).toHaveBeenCalled();
276
+ expect(result.title).toBe("New Announcement");
277
+ expect(result.severity).toBe("warning");
278
+ expect(result.displayMode).toBe("banner");
279
+
280
+ // Verify signal was broadcast
281
+ expect(mockSignalService.broadcast).toHaveBeenCalledWith(
282
+ ANNOUNCEMENT_UPDATED,
283
+ { announcementId: "new-id", action: "created" },
284
+ );
285
+ });
286
+
287
+ it("rejects unauthenticated users", async () => {
288
+ const context = createMockRpcContext({ user: undefined });
289
+
290
+ await expect(
291
+ call(
292
+ router.createAnnouncement,
293
+ {
294
+ title: "Test",
295
+ message: "Test",
296
+ severity: "info",
297
+ visibility: "all",
298
+ displayMode: "both",
299
+ },
300
+ { context },
301
+ ),
302
+ ).rejects.toThrow("Authentication required");
303
+ });
304
+ });
305
+
306
+ describe("updateAnnouncement", () => {
307
+ it("updates an existing announcement", async () => {
308
+ const context = createMockRpcContext({ user: adminUser });
309
+
310
+ const updatedRow = { ...sampleAnnouncement, title: "Updated Title" };
311
+ mockDb.update.mockReturnValueOnce({
312
+ set: mock(() => ({
313
+ where: mock(() => ({
314
+ returning: mock(() => Promise.resolve([updatedRow])),
315
+ })),
316
+ })),
317
+ } as never);
318
+
319
+ const result = await call(
320
+ router.updateAnnouncement,
321
+ { id: "ann-1", title: "Updated Title" },
322
+ { context },
323
+ );
324
+
325
+ expect(result.title).toBe("Updated Title");
326
+
327
+ // Verify signal was broadcast
328
+ expect(mockSignalService.broadcast).toHaveBeenCalledWith(
329
+ ANNOUNCEMENT_UPDATED,
330
+ { announcementId: "ann-1", action: "updated" },
331
+ );
332
+ });
333
+
334
+ it("throws NOT_FOUND for non-existent announcement", async () => {
335
+ const context = createMockRpcContext({ user: adminUser });
336
+
337
+ mockDb.update.mockReturnValueOnce({
338
+ set: mock(() => ({
339
+ where: mock(() => ({
340
+ returning: mock(() => Promise.resolve([])),
341
+ })),
342
+ })),
343
+ } as never);
344
+
345
+ await expect(
346
+ call(
347
+ router.updateAnnouncement,
348
+ { id: "nonexistent", title: "Test" },
349
+ { context },
350
+ ),
351
+ ).rejects.toThrow("Announcement not found");
352
+ });
353
+ });
354
+
355
+ describe("deleteAnnouncement", () => {
356
+ it("deletes an announcement and returns success", async () => {
357
+ const context = createMockRpcContext({ user: adminUser });
358
+
359
+ // Mock: delete with returning chain
360
+ mockDb.delete.mockReturnValueOnce({
361
+ where: mock(() => ({
362
+ returning: mock(() => Promise.resolve([{ id: "ann-1" }])),
363
+ })),
364
+ } as never);
365
+
366
+ const result = await call(
367
+ router.deleteAnnouncement,
368
+ { id: "ann-1" },
369
+ { context },
370
+ );
371
+
372
+ expect(result.success).toBe(true);
373
+ expect(mockDb.delete).toHaveBeenCalled();
374
+
375
+ // Verify signal was broadcast
376
+ expect(mockSignalService.broadcast).toHaveBeenCalledWith(
377
+ ANNOUNCEMENT_UPDATED,
378
+ { announcementId: "ann-1", action: "deleted" },
379
+ );
380
+ });
381
+ });
382
+
383
+ describe("listAllAnnouncements", () => {
384
+ it("returns all announcements for admins", async () => {
385
+ const context = createMockRpcContext({ user: adminUser });
386
+
387
+ const mockOrderByChain = Promise.resolve([sampleAnnouncement]);
388
+ const mockFromChain = Object.assign(
389
+ Promise.resolve([sampleAnnouncement]),
390
+ { orderBy: mock(() => mockOrderByChain) },
391
+ );
392
+ mockDb.select.mockReturnValueOnce({
393
+ from: mock(() => mockFromChain),
394
+ } as never);
395
+
396
+ const result = await call(router.listAllAnnouncements, undefined, {
397
+ context,
398
+ });
399
+
400
+ expect(result.announcements).toHaveLength(1);
401
+ });
402
+ });
403
+ });
package/src/router.ts ADDED
@@ -0,0 +1,255 @@
1
+ import { implement, ORPCError } from "@orpc/server";
2
+ import {
3
+ announcementContract,
4
+ ANNOUNCEMENT_UPDATED,
5
+ type Announcement,
6
+ } from "@checkstack/announcement-common";
7
+ import type { SignalService } from "@checkstack/signal-common";
8
+ import {
9
+ autoAuthMiddleware,
10
+ type RpcContext,
11
+ type RealUser,
12
+ } from "@checkstack/backend-api";
13
+ import type { SafeDatabase } from "@checkstack/backend-api";
14
+ import * as schema from "./schema";
15
+ import { eq, and, or, lte, gte, isNull } from "drizzle-orm";
16
+
17
+ type AnnouncementDb = SafeDatabase<typeof schema>;
18
+
19
+ /**
20
+ * Maps a database row to the Announcement domain type.
21
+ */
22
+ function toAnnouncement(
23
+ row: typeof schema.announcements.$inferSelect,
24
+ ): Announcement {
25
+ return {
26
+ id: row.id,
27
+ title: row.title,
28
+ message: row.message,
29
+ severity: row.severity as Announcement["severity"],
30
+ visibility: row.visibility as Announcement["visibility"],
31
+ displayMode: row.displayMode as Announcement["displayMode"],
32
+ active: row.active,
33
+ startsAt: row.startsAt ?? undefined,
34
+ expiresAt: row.expiresAt ?? undefined,
35
+ createdBy: row.createdBy,
36
+ createdAt: row.createdAt,
37
+ updatedAt: row.updatedAt,
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Creates the announcement router using contract-based implementation.
43
+ */
44
+ export function createAnnouncementRouter(
45
+ db: AnnouncementDb,
46
+ signalService: SignalService,
47
+ ) {
48
+ const os = implement(announcementContract)
49
+ .$context<RpcContext>()
50
+ .use(autoAuthMiddleware);
51
+
52
+ return os.router({
53
+ // -------------------------------------------------------------------------
54
+ // Public: Get active announcements
55
+ // -------------------------------------------------------------------------
56
+ getActiveAnnouncements: os.getActiveAnnouncements.handler(
57
+ async ({ input, context }) => {
58
+ const now = new Date();
59
+ const includeDismissed = input?.includeDismissed ?? false;
60
+
61
+ // Base query: active announcements within their time window
62
+ const rows = await db
63
+ .select()
64
+ .from(schema.announcements)
65
+ .where(
66
+ and(
67
+ eq(schema.announcements.active, true),
68
+ or(
69
+ isNull(schema.announcements.startsAt),
70
+ lte(schema.announcements.startsAt, now),
71
+ ),
72
+ or(
73
+ isNull(schema.announcements.expiresAt),
74
+ gte(schema.announcements.expiresAt, now),
75
+ ),
76
+ ),
77
+ );
78
+
79
+ let announcements = rows.map((row) => toAnnouncement(row));
80
+
81
+ // If the caller is authenticated, filter out their dismissed announcements
82
+ // (unless includeDismissed is explicitly requested, e.g. for dashboard)
83
+ const user = context.user;
84
+ if (!includeDismissed && user && "id" in user) {
85
+ const userId = (user as RealUser).id;
86
+ const dismissals = await db
87
+ .select({ announcementId: schema.announcementDismissals.announcementId })
88
+ .from(schema.announcementDismissals)
89
+ .where(eq(schema.announcementDismissals.userId, userId));
90
+
91
+ const dismissedIds = new Set(dismissals.map((d) => d.announcementId));
92
+ announcements = announcements.filter((a) => !dismissedIds.has(a.id));
93
+ }
94
+
95
+ // Filter visibility for unauthenticated users
96
+ if (!user) {
97
+ announcements = announcements.filter(
98
+ (a) => a.visibility === "all",
99
+ );
100
+ }
101
+
102
+ return { announcements };
103
+ },
104
+ ),
105
+
106
+ // -------------------------------------------------------------------------
107
+ // Authenticated: Dismiss an announcement
108
+ // -------------------------------------------------------------------------
109
+ dismissAnnouncement: os.dismissAnnouncement.handler(
110
+ async ({ input, context }) => {
111
+ const userId = (context.user as RealUser).id;
112
+
113
+ // Verify the announcement exists
114
+ const existing = await db
115
+ .select({ id: schema.announcements.id })
116
+ .from(schema.announcements)
117
+ .where(eq(schema.announcements.id, input.announcementId))
118
+ .limit(1);
119
+
120
+ if (existing.length === 0) {
121
+ throw new ORPCError("NOT_FOUND", {
122
+ message: "Announcement not found",
123
+ });
124
+ }
125
+
126
+ // Upsert dismissal (idempotent)
127
+ await db
128
+ .insert(schema.announcementDismissals)
129
+ .values({
130
+ announcementId: input.announcementId,
131
+ userId,
132
+ dismissedAt: new Date(),
133
+ })
134
+ .onConflictDoNothing();
135
+ },
136
+ ),
137
+
138
+ // -------------------------------------------------------------------------
139
+ // Admin: List all announcements
140
+ // -------------------------------------------------------------------------
141
+ listAllAnnouncements: os.listAllAnnouncements.handler(async () => {
142
+ const rows = await db
143
+ .select()
144
+ .from(schema.announcements)
145
+ .orderBy(schema.announcements.createdAt);
146
+
147
+ return { announcements: rows.map((row) => toAnnouncement(row)) };
148
+ }),
149
+
150
+ // -------------------------------------------------------------------------
151
+ // Admin: Create announcement
152
+ // -------------------------------------------------------------------------
153
+ createAnnouncement: os.createAnnouncement.handler(
154
+ async ({ input, context }) => {
155
+ const userId =
156
+ context.user && "id" in context.user ? context.user.id : "system";
157
+ const id = crypto.randomUUID();
158
+ const now = new Date();
159
+
160
+ const [row] = await db
161
+ .insert(schema.announcements)
162
+ .values({
163
+ id,
164
+ title: input.title,
165
+ message: input.message,
166
+ severity: input.severity,
167
+ visibility: input.visibility,
168
+ displayMode: input.displayMode,
169
+ active: input.active ?? true,
170
+ startsAt: input.startsAt ?? undefined,
171
+ expiresAt: input.expiresAt ?? undefined,
172
+ createdBy: userId,
173
+ createdAt: now,
174
+ updatedAt: now,
175
+ })
176
+ .returning();
177
+
178
+ const announcement = toAnnouncement(row);
179
+
180
+ await signalService.broadcast(ANNOUNCEMENT_UPDATED, {
181
+ announcementId: announcement.id,
182
+ action: "created",
183
+ });
184
+
185
+ return announcement;
186
+ },
187
+ ),
188
+
189
+ // -------------------------------------------------------------------------
190
+ // Admin: Update announcement
191
+ // -------------------------------------------------------------------------
192
+ updateAnnouncement: os.updateAnnouncement.handler(async ({ input }) => {
193
+ const { id, ...updates } = input;
194
+
195
+ // Build update object, only including provided fields
196
+ const updateData: Record<string, unknown> = {
197
+ updatedAt: new Date(),
198
+ };
199
+
200
+ if (updates.title !== undefined) updateData.title = updates.title;
201
+ if (updates.message !== undefined) updateData.message = updates.message;
202
+ if (updates.severity !== undefined) updateData.severity = updates.severity;
203
+ if (updates.visibility !== undefined)
204
+ updateData.visibility = updates.visibility;
205
+ if (updates.displayMode !== undefined)
206
+ updateData.displayMode = updates.displayMode;
207
+ if (updates.active !== undefined) updateData.active = updates.active;
208
+ if (updates.startsAt !== undefined) updateData.startsAt = updates.startsAt;
209
+ if (updates.expiresAt !== undefined)
210
+ updateData.expiresAt = updates.expiresAt;
211
+
212
+ const [row] = await db
213
+ .update(schema.announcements)
214
+ .set(updateData)
215
+ .where(eq(schema.announcements.id, id))
216
+ .returning();
217
+
218
+ if (!row) {
219
+ throw new ORPCError("NOT_FOUND", {
220
+ message: "Announcement not found",
221
+ });
222
+ }
223
+
224
+ const announcement = toAnnouncement(row);
225
+
226
+ await signalService.broadcast(ANNOUNCEMENT_UPDATED, {
227
+ announcementId: announcement.id,
228
+ action: "updated",
229
+ });
230
+
231
+ return announcement;
232
+ }),
233
+
234
+ // -------------------------------------------------------------------------
235
+ // Admin: Delete announcement
236
+ // -------------------------------------------------------------------------
237
+ deleteAnnouncement: os.deleteAnnouncement.handler(async ({ input }) => {
238
+ const result = await db
239
+ .delete(schema.announcements)
240
+ .where(eq(schema.announcements.id, input.id))
241
+ .returning({ id: schema.announcements.id });
242
+
243
+ if (result.length > 0) {
244
+ await signalService.broadcast(ANNOUNCEMENT_UPDATED, {
245
+ announcementId: input.id,
246
+ action: "deleted",
247
+ });
248
+ }
249
+
250
+ return { success: result.length > 0 };
251
+ }),
252
+ });
253
+ }
254
+
255
+ export type AnnouncementRouter = ReturnType<typeof createAnnouncementRouter>;
package/src/schema.ts ADDED
@@ -0,0 +1,44 @@
1
+ import {
2
+ pgTable,
3
+ text,
4
+ timestamp,
5
+ boolean,
6
+ primaryKey,
7
+ } from "drizzle-orm/pg-core";
8
+
9
+ /**
10
+ * Main announcements table.
11
+ * Stores admin-created portal announcements with temporal lifecycle support.
12
+ */
13
+ export const announcements = pgTable("announcements", {
14
+ id: text("id").primaryKey(),
15
+ title: text("title").notNull(),
16
+ message: text("message").notNull(),
17
+ severity: text("severity").notNull().default("info"),
18
+ visibility: text("visibility").notNull().default("all"),
19
+ displayMode: text("display_mode").notNull().default("both"),
20
+ active: boolean("active").notNull().default(true),
21
+ startsAt: timestamp("starts_at"),
22
+ expiresAt: timestamp("expires_at"),
23
+ createdBy: text("created_by").notNull(),
24
+ createdAt: timestamp("created_at").defaultNow().notNull(),
25
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
26
+ });
27
+
28
+ /**
29
+ * Tracks which announcements have been dismissed by which users.
30
+ * Allows server-side persistence of dismissals for authenticated users.
31
+ */
32
+ export const announcementDismissals = pgTable(
33
+ "announcement_dismissals",
34
+ {
35
+ announcementId: text("announcement_id")
36
+ .notNull()
37
+ .references(() => announcements.id, { onDelete: "cascade" }),
38
+ userId: text("user_id").notNull(),
39
+ dismissedAt: timestamp("dismissed_at").defaultNow().notNull(),
40
+ },
41
+ (t) => ({
42
+ pk: primaryKey(t.announcementId, t.userId),
43
+ }),
44
+ );
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/backend.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }