Add web push notification system for user engagement and updates

Implement a web push notification system, including service worker integration, user subscription management, and an admin interface for sending broadcast messages.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 23852c00-4779-460a-9e0c-d09fee4b6c92
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: f585829f-898b-492f-82a5-11f4a76c87fb
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/f209e72a-0939-48fa-84fc-57854de71967/23852c00-4779-460a-9e0c-d09fee4b6c92/ICRgny1
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
sebastjanartic
2026-03-07 15:16:12 +00:00
parent 8e7dc999f0
commit f6478a7663
11 changed files with 423 additions and 1 deletions
+79
View File
@@ -13,6 +13,7 @@ import multer from "multer";
import path from "path";
import fs from "fs";
import https from "https";
import webpush from "web-push";
const apiCache = new Map<string, { data: any; timestamp: number }>();
function getCached<T>(key: string, ttlMs: number): T | null {
@@ -495,6 +496,84 @@ export async function registerRoutes(
}
});
const vapidPublic = process.env.VAPID_PUBLIC_KEY || "";
const vapidPrivate = process.env.VAPID_PRIVATE_KEY || "";
if (vapidPublic && vapidPrivate) {
webpush.setVapidDetails("mailto:office@folx.tv", vapidPublic, vapidPrivate);
}
app.get("/api/push/vapid-key", (_req, res) => {
res.json({ publicKey: vapidPublic });
});
app.post("/api/push/subscribe", async (req, res) => {
try {
const { endpoint, keys } = req.body;
if (!endpoint || !keys?.p256dh || !keys?.auth) {
return res.status(400).json({ message: "Ungültige Subscription-Daten." });
}
await storage.savePushSubscription({ endpoint, p256dh: keys.p256dh, auth: keys.auth });
res.json({ ok: true });
} catch (err: any) {
res.status(500).json({ message: err.message });
}
});
app.post("/api/push/unsubscribe", async (req, res) => {
try {
const { endpoint } = req.body;
if (!endpoint) return res.status(400).json({ message: "Endpoint fehlt." });
await storage.deletePushSubscription(endpoint);
res.json({ ok: true });
} catch (err: any) {
res.status(500).json({ message: err.message });
}
});
app.get("/api/push/count", async (_req, res) => {
try {
const count = await storage.getPushSubscriptionCount();
res.json({ count });
} catch {
res.json({ count: 0 });
}
});
app.post("/api/admin/push/send", async (req, res) => {
try {
const { title, body, url } = req.body;
if (!title || !body) return res.status(400).json({ message: "Titel und Nachricht sind erforderlich." });
if (typeof title !== "string" || title.length > 200) return res.status(400).json({ message: "Titel ungültig." });
if (typeof body !== "string" || body.length > 500) return res.status(400).json({ message: "Nachricht ungültig." });
let safeUrl = "/";
if (url && typeof url === "string" && url.startsWith("/") && !url.startsWith("//")) {
safeUrl = url;
}
const subs = await storage.getAllPushSubscriptions();
const payload = JSON.stringify({ title, body, url: safeUrl });
let sent = 0;
let failed = 0;
for (const sub of subs) {
try {
await webpush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
payload
);
sent++;
} catch (err: any) {
if (err.statusCode === 410 || err.statusCode === 404) {
await storage.deletePushSubscription(sub.endpoint);
}
failed++;
}
}
console.log(`[push] Sent to ${sent}, failed ${failed} of ${subs.length} subscribers`);
res.json({ ok: true, sent, failed, total: subs.length });
} catch (err: any) {
res.status(500).json({ message: err.message });
}
});
app.get("/api/gallery/artists", (_req, res) => {
try {
const aPath = path.join(process.cwd(), "server/gallery-artist-overrides.json");
+28 -1
View File
@@ -1,4 +1,4 @@
import { type Article, type InsertArticle, articles, articleViews } from "@shared/schema";
import { type Article, type InsertArticle, articles, articleViews, type InsertPushSubscription, type PushSubscription, pushSubscriptions } from "@shared/schema";
import { db } from "./db";
import { eq, desc, sql, count, and } from "drizzle-orm";
import crypto from "crypto";
@@ -15,6 +15,10 @@ export interface IStorage {
updateArticle(id: number, article: Partial<InsertArticle>): Promise<Article | undefined>;
incrementViews(id: number, ip: string): Promise<boolean>;
deleteArticle(id: number): Promise<void>;
savePushSubscription(sub: InsertPushSubscription): Promise<PushSubscription>;
deletePushSubscription(endpoint: string): Promise<void>;
getAllPushSubscriptions(): Promise<PushSubscription[]>;
getPushSubscriptionCount(): Promise<number>;
}
export class DatabaseStorage implements IStorage {
@@ -76,6 +80,29 @@ export class DatabaseStorage implements IStorage {
async deleteArticle(id: number): Promise<void> {
await db.delete(articles).where(eq(articles.id, id));
}
async savePushSubscription(sub: InsertPushSubscription): Promise<PushSubscription> {
const [existing] = await db.select().from(pushSubscriptions).where(eq(pushSubscriptions.endpoint, sub.endpoint)).limit(1);
if (existing) {
const [updated] = await db.update(pushSubscriptions).set(sub).where(eq(pushSubscriptions.endpoint, sub.endpoint)).returning();
return updated;
}
const [created] = await db.insert(pushSubscriptions).values(sub).returning();
return created;
}
async deletePushSubscription(endpoint: string): Promise<void> {
await db.delete(pushSubscriptions).where(eq(pushSubscriptions.endpoint, endpoint));
}
async getAllPushSubscriptions(): Promise<PushSubscription[]> {
return db.select().from(pushSubscriptions);
}
async getPushSubscriptionCount(): Promise<number> {
const [result] = await db.select({ count: count() }).from(pushSubscriptions);
return result.count;
}
}
export const storage = new DatabaseStorage();