@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 +19 -0
- package/drizzle/0000_cooing_onslaught.sql +23 -0
- package/drizzle/meta/0000_snapshot.json +164 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +7 -0
- package/package.json +34 -0
- package/src/index.ts +88 -0
- package/src/router.test.ts +403 -0
- package/src/router.ts +255 -0
- package/src/schema.ts +44 -0
- package/tsconfig.json +6 -0
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
|
+
}
|
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
|
+
);
|