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:
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user