feat(26-04): create push_subscriptions schema, migration, pushService, and push routes

- Add push_subscriptions pgTable with endpoint, p256dh, auth, userId, companyId, deviceLabel
- Add 0055_create_push_subscriptions.sql migration with CREATE TABLE and endpoint index
- Export pushSubscriptions from schema/index.ts
- Create pushService with initVapid, getVapidPublicKey, saveSubscription, removeSubscription, sendPushToAll
- sendPushToAll auto-deletes stale subscriptions on 410/404 response
- Create pushRoutes: GET /vapid-public-key, POST /subscribe, DELETE /subscribe
- Mount /api/push routes and call initVapid() in app.ts with graceful skip
- Install web-push and @types/web-push
This commit is contained in:
Nexus Dev 2026-04-02 02:29:35 +00:00
parent 471a9daaa6
commit 66bbfbf766
8 changed files with 277 additions and 0 deletions

View file

@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS "push_subscriptions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"endpoint" text NOT NULL,
"p256dh" text NOT NULL,
"auth" text NOT NULL,
"user_id" uuid,
"company_id" uuid,
"device_label" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
CREATE INDEX IF NOT EXISTS "push_sub_endpoint_idx" ON "push_subscriptions" ("endpoint");

View file

@ -63,3 +63,4 @@ export { chatMessages } from "./chat_messages.js";
export { chatMessageBookmarks } from "./chat_message_bookmarks.js";
export { chatFiles } from "./chat_files.js";
export { chatFileReferences } from "./chat_file_references.js";
export { pushSubscriptions } from "./push_subscriptions.js";

View file

@ -0,0 +1,18 @@
import { pgTable, uuid, text, timestamp, index } from "drizzle-orm/pg-core";
export const pushSubscriptions = pgTable(
"push_subscriptions",
{
id: uuid("id").primaryKey().defaultRandom(),
endpoint: text("endpoint").notNull(),
p256dh: text("p256dh").notNull(),
auth: text("auth").notNull(),
userId: uuid("user_id"),
companyId: uuid("company_id"),
deviceLabel: text("device_label"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
endpointIdx: index("push_sub_endpoint_idx").on(table.endpoint),
}),
);

84
pnpm-lock.yaml generated
View file

@ -485,6 +485,9 @@ importers:
'@paperclipai/shared':
specifier: workspace:*
version: link:../packages/shared
'@types/web-push':
specifier: ^3.6.4
version: 3.6.4
ajv:
specifier: ^8.18.0
version: 8.18.0
@ -539,6 +542,9 @@ importers:
sharp:
specifier: ^0.34.5
version: 0.34.5
web-push:
specifier: ^3.6.7
version: 3.6.7
ws:
specifier: ^8.19.0
version: 8.19.0
@ -3599,6 +3605,9 @@ packages:
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
'@types/web-push@3.6.4':
resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
@ -3700,6 +3709,9 @@ packages:
asap@2.0.6:
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
asn1.js@5.4.1:
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
@ -3798,6 +3810,9 @@ packages:
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
bn.js@4.12.3:
resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==}
body-parser@2.2.2:
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
engines: {node: '>=18'}
@ -3810,6 +3825,9 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@ -4383,6 +4401,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@ -4665,6 +4686,10 @@ packages:
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
engines: {node: '>= 14'}
http_ece@1.2.0:
resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==}
engines: {node: '>=16'}
https-proxy-agent@7.0.6:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
@ -4800,6 +4825,12 @@ packages:
engines: {node: '>=6'}
hasBin: true
jwa@2.0.1:
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
jws@4.0.1:
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
katex@0.16.37:
resolution: {integrity: sha512-TIGjO2cCGYono+uUzgkE7RFF329mLLWGuHUlSr6cwIVj9O8f0VQZ783rsanmJpFUo32vvtj7XT04NGRPh+SZFg==}
hasBin: true
@ -4835,6 +4866,7 @@ packages:
libsql@0.5.29:
resolution: {integrity: sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==}
cpu: [x64, arm64, wasm32, arm]
os: [darwin, linux, win32]
lightningcss-android-arm64@1.30.2:
@ -5168,6 +5200,9 @@ packages:
engines: {node: '>=4.0.0'}
hasBin: true
minimalistic-assert@1.0.1:
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
@ -6116,6 +6151,11 @@ packages:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
web-push@3.6.7:
resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==}
engines: {node: '>= 16'}
hasBin: true
web-streams-polyfill@3.3.3:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
@ -9645,6 +9685,10 @@ snapshots:
'@types/unist@3.0.3': {}
'@types/web-push@3.6.4':
dependencies:
'@types/node': 25.2.3
'@types/ws@8.18.1':
dependencies:
'@types/node': 25.2.3
@ -9759,6 +9803,13 @@ snapshots:
asap@2.0.6: {}
asn1.js@5.4.1:
dependencies:
bn.js: 4.12.3
inherits: 2.0.4
minimalistic-assert: 1.0.1
safer-buffer: 2.1.2
assertion-error@2.0.1: {}
async-exit-hook@2.0.1: {}
@ -9808,6 +9859,8 @@ snapshots:
dependencies:
require-from-string: 2.0.2
bn.js@4.12.3: {}
body-parser@2.2.2:
dependencies:
bytes: 3.1.2
@ -9832,6 +9885,8 @@ snapshots:
node-releases: 2.0.27
update-browserslist-db: 1.2.3(browserslist@4.28.1)
buffer-equal-constant-time@1.0.1: {}
buffer-from@1.1.2: {}
buffer@6.0.3:
@ -10328,6 +10383,10 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
ecdsa-sig-formatter@1.0.11:
dependencies:
safe-buffer: 5.2.1
ee-first@1.1.1: {}
electron-to-chromium@1.5.286: {}
@ -10741,6 +10800,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
http_ece@1.2.0: {}
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.4
@ -10858,6 +10919,17 @@ snapshots:
json5@2.2.3: {}
jwa@2.0.1:
dependencies:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
jws@4.0.1:
dependencies:
jwa: 2.0.1
safe-buffer: 5.2.1
katex@0.16.37:
dependencies:
commander: 8.3.0
@ -11517,6 +11589,8 @@ snapshots:
mime@2.6.0: {}
minimalistic-assert@1.0.1: {}
minimist@1.2.8: {}
mkdirp@0.5.6:
@ -12675,6 +12749,16 @@ snapshots:
dependencies:
xml-name-validator: 5.0.0
web-push@3.6.7:
dependencies:
asn1.js: 5.4.1
http_ece: 1.2.0
https-proxy-agent: 7.0.6
jws: 4.0.1
minimist: 1.2.8
transitivePeerDependencies:
- supports-color
web-streams-polyfill@3.3.3: {}
webidl-conversions@3.0.1: {}

View file

@ -57,6 +57,7 @@
"@paperclipai/db": "workspace:*",
"@paperclipai/plugin-sdk": "workspace:*",
"@paperclipai/shared": "workspace:*",
"@types/web-push": "^3.6.4",
"ajv": "^8.18.0",
"ajv-formats": "^3.0.1",
"better-auth": "1.4.18",
@ -75,6 +76,7 @@
"pino-http": "^10.4.0",
"pino-pretty": "^13.1.3",
"sharp": "^0.34.5",
"web-push": "^3.6.7",
"ws": "^8.19.0",
"zod": "^3.24.2"
},

View file

@ -31,6 +31,8 @@ import { instanceSettingsRoutes } from "./routes/instance-settings.js";
import { llmRoutes } from "./routes/llms.js";
import { assetRoutes } from "./routes/assets.js";
import { chatFileRoutes } from "./routes/chat-files.js";
import { pushRoutes } from "./routes/push.js";
import { initVapid } from "./services/pushService.js";
import { accessRoutes } from "./routes/access.js";
import { pluginRoutes } from "./routes/plugins.js";
import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js";
@ -159,6 +161,7 @@ export async function createApp(
api.use(agentRoutes(db));
api.use(assetRoutes(db, opts.storageService));
api.use(chatFileRoutes(db, opts.storageService));
api.use("/push", pushRoutes(db));
api.use(projectRoutes(db));
api.use(issueRoutes(db, opts.storageService, {
feedbackExportService: opts.feedbackExportService,
@ -305,6 +308,13 @@ export async function createApp(
app.use(errorHandler);
// Initialize VAPID for push notifications (graceful skip if keys not set)
try {
initVapid();
} catch (err) {
logger.warn({ err }, "VAPID init skipped — push notifications unavailable");
}
jobCoordinator.start();
scheduler.start();
const feedbackExportTimer = opts.feedbackExportService

59
server/src/routes/push.ts Normal file
View file

@ -0,0 +1,59 @@
import { Router } from "express";
import type { Db } from "@paperclipai/db";
import { getVapidPublicKey, saveSubscription, removeSubscription } from "../services/pushService.js";
export function pushRoutes(db: Db): Router {
const router = Router();
// GET /api/push/vapid-public-key
router.get("/vapid-public-key", (_req, res) => {
const publicKey = getVapidPublicKey();
if (!publicKey) {
res.status(404).json({ error: "VAPID not configured" });
return;
}
res.json({ publicKey });
});
// POST /api/push/subscribe
router.post("/subscribe", async (req, res) => {
const { endpoint, keys, userId, companyId, deviceLabel } = req.body as {
endpoint: string;
keys: { p256dh: string; auth: string };
userId?: string;
companyId?: string;
deviceLabel?: string;
};
if (!endpoint || !keys?.p256dh || !keys?.auth) {
res.status(400).json({ error: "Missing required subscription fields" });
return;
}
const id = await saveSubscription(db, {
endpoint,
p256dh: keys.p256dh,
auth: keys.auth,
userId,
companyId,
deviceLabel,
});
res.status(201).json({ id });
});
// DELETE /api/push/subscribe
router.delete("/subscribe", async (req, res) => {
const { endpoint } = req.body as { endpoint: string };
if (!endpoint) {
res.status(400).json({ error: "Missing endpoint" });
return;
}
await removeSubscription(db, endpoint);
res.status(204).end();
});
return router;
}

View file

@ -0,0 +1,91 @@
import webPush from "web-push";
import { eq, and, isNotNull } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { pushSubscriptions } from "@paperclipai/db";
import { logger } from "../middleware/logger.js";
export function initVapid(): void {
const publicKey = process.env.VAPID_PUBLIC_KEY;
const privateKey = process.env.VAPID_PRIVATE_KEY;
if (!publicKey || !privateKey) {
logger.warn("VAPID keys not configured — push notifications disabled");
return;
}
try {
webPush.setVapidDetails(
process.env.VAPID_SUBJECT ?? "mailto:admin@nexus.local",
publicKey,
privateKey,
);
} catch (err) {
logger.warn({ err }, "Failed to initialize VAPID — push notifications disabled");
}
}
export function getVapidPublicKey(): string | null {
return process.env.VAPID_PUBLIC_KEY ?? null;
}
export async function saveSubscription(
db: Db,
params: {
endpoint: string;
p256dh: string;
auth: string;
userId?: string;
companyId?: string;
deviceLabel?: string;
},
): Promise<string> {
const id = crypto.randomUUID();
await db.insert(pushSubscriptions).values({
id,
endpoint: params.endpoint,
p256dh: params.p256dh,
auth: params.auth,
userId: params.userId ?? null,
companyId: params.companyId ?? null,
deviceLabel: params.deviceLabel ?? null,
}).onConflictDoNothing();
return id;
}
export async function removeSubscription(db: Db, endpoint: string): Promise<void> {
await db.delete(pushSubscriptions).where(eq(pushSubscriptions.endpoint, endpoint));
}
export async function sendPushToAll(
db: Db,
companyId: string | null,
payload: { title: string; body: string; icon?: string; data?: Record<string, string> },
): Promise<void> {
const rows = await db
.select()
.from(pushSubscriptions)
.where(
companyId
? and(eq(pushSubscriptions.companyId, companyId), isNotNull(pushSubscriptions.endpoint))
: isNotNull(pushSubscriptions.endpoint),
);
const payloadStr = JSON.stringify(payload);
await Promise.allSettled(
rows.map(async (row) => {
try {
await webPush.sendNotification(
{ endpoint: row.endpoint, keys: { p256dh: row.p256dh, auth: row.auth } },
payloadStr,
);
} catch (err: unknown) {
const status = (err as { statusCode?: number }).statusCode;
if (status === 410 || status === 404) {
// Stale subscription — remove it
await removeSubscription(db, row.endpoint).catch(() => {});
} else {
logger.warn({ err, endpoint: row.endpoint }, "Push notification send failed");
}
}
}),
);
}