Feature: Plugin UI, event hooks, DB notifications, web push, webhook retry, games pages, rate limiting, tests
Local Build and Deploy / deploy (push) Successful in 1m5s

- Plugin admin UI: enable/disable per plugin with config forms
- Event hooks system: HookManager with on/off/emit for plugin events
- Database notifications: persistent via WebsiteNotification table + Redis pub/sub
- Notification preferences: per-user settings with API endpoints
- Web Push API: browser push notification subscription
- Webhook retry: 3 attempts with exponential backoff (1s/3s/9s) + delivery logs
- Webhook test button + paginated delivery log viewer
- Games: rank/challenge/reward admin pages
- Games: public frontend leaderboard
- Rate limiting: search API (30/min), notification stream dedup
- Unit tests: 28 new tests (registry, notifications, webhooks)
- Prisma migration 0017: website_notifications, website_notification_preferences, website_webhook_logs
This commit is contained in:
openhands committed 2026-07-18 14:04:41 +02:00
1 parent 8bf48f46e7
commit 83f4585167
30 files changed
+2196 -51

No files matched your search

+2
View File
@@ -271,6 +271,7 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
"/admin/email-templates",
"/admin/housekeeping",
"/admin/webhooks",
"/admin/plugins",
],
tabs: [
{ href: "/admin/settings", labelKey: "cms" },
@@ -281,6 +282,7 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
{ href: "/admin/email-templates", labelKey: "email" },
{ href: "/admin/housekeeping", labelKey: "housekeeping" },
{ href: "/admin/webhooks", labelKey: "webhooks" },
{ href: "/admin/plugins", labelKey: "plugins" },
],
},
{
@@ -0,0 +1,163 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("@/lib/redis", () => ({
redis: null,
}));
vi.mock("@/lib/prisma", () => ({
prisma: {
websiteNotification: {
create: vi.fn(),
findMany: vi.fn(),
count: vi.fn(),
updateMany: vi.fn(),
},
},
}));
import { prisma } from "@/lib/prisma";
import { notificationService } from "../service";
const mockPrisma = vi.mocked(prisma.websiteNotification);
describe("NotificationService", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("send", () => {
it("creates a notification record in the database", async () => {
mockPrisma.create.mockResolvedValue({
id: BigInt(1),
userId: BigInt(42),
type: "alert",
title: "Test",
body: "Body",
icon: null,
link: null,
read: false,
createdAt: new Date(),
});
const result = await notificationService.send(42, {
type: "alert",
title: "Test",
body: "Body",
});
expect(mockPrisma.create).toHaveBeenCalledWith({
data: {
userId: BigInt(42),
type: "alert",
title: "Test",
body: "Body",
icon: undefined,
link: undefined,
},
});
expect(result.userId).toBe(42);
expect(result.title).toBe("Test");
expect(result.read).toBe(false);
});
it("does not throw when DB fails", async () => {
mockPrisma.create.mockRejectedValue(new Error("DB down"));
const result = await notificationService.send(1, {
type: "alert",
title: "Test",
body: "Body",
});
expect(result).toBeDefined();
expect(result.title).toBe("Test");
});
});
describe("getNotifications", () => {
it("returns notifications from database with pagination", async () => {
const now = new Date();
mockPrisma.findMany.mockResolvedValue([
{
id: BigInt(1),
userId: BigInt(42),
type: "alert",
title: "Hello",
body: "World",
icon: null,
link: null,
read: false,
createdAt: now,
},
]);
mockPrisma.count.mockResolvedValue(1);
const result = await notificationService.getNotifications(42, 1, 20);
expect(result.notifications).toHaveLength(1);
expect(result.total).toBe(1);
expect(result.notifications[0].title).toBe("Hello");
expect(mockPrisma.findMany).toHaveBeenCalledWith({
where: { userId: BigInt(42) },
orderBy: { createdAt: "desc" },
skip: 0,
take: 20,
});
});
it("returns empty array on error", async () => {
mockPrisma.findMany.mockRejectedValue(new Error("DB error"));
const result = await notificationService.getNotifications(1);
expect(result.notifications).toEqual([]);
expect(result.total).toBe(0);
});
});
describe("markRead", () => {
it("updates the notification read status in DB", async () => {
mockPrisma.updateMany.mockResolvedValue({ count: 1 });
await notificationService.markRead(42, "1");
expect(mockPrisma.updateMany).toHaveBeenCalledWith({
where: {
id: BigInt("1"),
userId: BigInt(42),
},
data: { read: true },
});
});
});
describe("markAllRead", () => {
it("updates all unread notifications for user", async () => {
mockPrisma.updateMany.mockResolvedValue({ count: 3 });
await notificationService.markAllRead(42);
expect(mockPrisma.updateMany).toHaveBeenCalledWith({
where: { userId: BigInt(42), read: false },
data: { read: true },
});
});
});
describe("getUnreadCount", () => {
it("returns the count of unread notifications", async () => {
mockPrisma.count.mockResolvedValue(5);
const count = await notificationService.getUnreadCount(42);
expect(count).toBe(5);
expect(mockPrisma.count).toHaveBeenCalledWith({
where: { userId: BigInt(42), read: false },
});
});
it("returns 0 on error", async () => {
mockPrisma.count.mockRejectedValue(new Error("DB error"));
const count = await notificationService.getUnreadCount(1);
expect(count).toBe(0);
});
});
});
+80
View File
@@ -0,0 +1,80 @@
import "server-only";
import { prisma } from "@/lib/prisma";
export interface NotificationPreferences {
emailEnabled: boolean;
pushEnabled: boolean;
friendRequest: boolean;
messages: boolean;
guildInvite: boolean;
events: boolean;
alerts: boolean;
achievements: boolean;
radio: boolean;
}
const DEFAULTS: NotificationPreferences = {
emailEnabled: true,
pushEnabled: true,
friendRequest: true,
messages: true,
guildInvite: true,
events: true,
alerts: true,
achievements: true,
radio: true,
};
export async function getPreferences(
userId: number,
): Promise<NotificationPreferences> {
try {
const prefs = await prisma.websiteNotificationPreference.findUnique({
where: { userId: BigInt(userId) },
});
if (!prefs) return { ...DEFAULTS };
return {
emailEnabled: prefs.emailEnabled,
pushEnabled: prefs.pushEnabled,
friendRequest: prefs.friendRequest,
messages: prefs.messages,
guildInvite: prefs.guildInvite,
events: prefs.events,
alerts: prefs.alerts,
achievements: prefs.achievements,
radio: prefs.radio,
};
} catch {
return { ...DEFAULTS };
}
}
export async function updatePreferences(
userId: number,
prefs: Partial<NotificationPreferences>,
): Promise<void> {
try {
const existing = await prisma.websiteNotificationPreference.findUnique({
where: { userId: BigInt(userId) },
});
const data = {
emailEnabled: prefs.emailEnabled ?? existing?.emailEnabled ?? true,
pushEnabled: prefs.pushEnabled ?? existing?.pushEnabled ?? true,
friendRequest: prefs.friendRequest ?? existing?.friendRequest ?? true,
messages: prefs.messages ?? existing?.messages ?? true,
guildInvite: prefs.guildInvite ?? existing?.guildInvite ?? true,
events: prefs.events ?? existing?.events ?? true,
alerts: prefs.alerts ?? existing?.alerts ?? true,
achievements: prefs.achievements ?? existing?.achievements ?? true,
radio: prefs.radio ?? existing?.radio ?? true,
};
await prisma.websiteNotificationPreference.upsert({
where: { userId: BigInt(userId) },
update: data,
create: { userId: BigInt(userId), ...data },
});
} catch {}
}
+96 -38
View File
@@ -1,15 +1,12 @@
import "server-only";
import { randomUUID } from "node:crypto";
import { prisma } from "@/lib/prisma";
import { redis } from "@/lib/redis";
import type { Notification, NotificationPayload } from "./types";
const MAX_NOTIFICATIONS = 50;
function userKey(userId: number): string {
return `notifications:${userId}`;
}
export class NotificationService {
async send(userId: number, payload: NotificationPayload): Promise<Notification> {
const notification: Notification = {
@@ -20,9 +17,22 @@ export class NotificationService {
createdAt: Date.now(),
};
try {
await prisma.websiteNotification.create({
data: {
userId: BigInt(userId),
type: payload.type,
title: payload.title,
body: payload.body,
icon: payload.icon,
link: payload.link,
},
});
} catch {}
if (redis) {
try {
const key = userKey(userId);
const key = `notifications:${userId}`;
await redis.lpush(key, JSON.stringify(notification));
await redis.ltrim(key, 0, MAX_NOTIFICATIONS - 1);
await redis.publish(`user:${userId}`, JSON.stringify(notification));
@@ -32,53 +42,101 @@ export class NotificationService {
return notification;
}
async getNotifications(userId: number): Promise<Notification[]> {
if (redis) {
try {
const key = userKey(userId);
const raw = await redis.lrange(key, 0, -1);
return raw
.map((r) => JSON.parse(r) as Notification)
.sort((a, b) => b.createdAt - a.createdAt);
} catch {}
async getNotifications(
userId: number,
page = 1,
limit = 20,
): Promise<{ notifications: Notification[]; total: number }> {
try {
const skip = (page - 1) * limit;
const [rows, total] = await Promise.all([
prisma.websiteNotification.findMany({
where: { userId: BigInt(userId) },
orderBy: { createdAt: "desc" },
skip,
take: limit,
}),
prisma.websiteNotification.count({
where: { userId: BigInt(userId) },
}),
]);
const notifications = rows.map((r) => ({
id: String(r.id),
userId: Number(r.userId),
type: r.type as Notification["type"],
title: r.title,
body: r.body ?? "",
icon: r.icon ?? undefined,
link: r.link ?? undefined,
read: r.read,
createdAt: r.createdAt.getTime(),
}));
return { notifications, total };
} catch {
return { notifications: [], total: 0 };
}
return [];
}
async markRead(userId: number, notificationId: string): Promise<void> {
if (!redis) return;
try {
const key = userKey(userId);
const raw = await redis.lrange(key, 0, -1);
for (const entry of raw) {
const notif = JSON.parse(entry) as Notification;
if (notif.id === notificationId) {
notif.read = true;
await redis.lset(key, raw.indexOf(entry), JSON.stringify(notif));
return;
}
}
await prisma.websiteNotification.updateMany({
where: {
id: BigInt(notificationId),
userId: BigInt(userId),
},
data: { read: true },
});
} catch {}
if (redis) {
try {
const key = `notifications:${userId}`;
const raw = await redis.lrange(key, 0, -1);
for (const entry of raw) {
const notif = JSON.parse(entry) as Notification;
if (notif.id === notificationId) {
notif.read = true;
await redis.lset(key, raw.indexOf(entry), JSON.stringify(notif));
return;
}
}
} catch {}
}
}
async markAllRead(userId: number): Promise<void> {
if (!redis) return;
try {
const key = userKey(userId);
const raw = await redis.lrange(key, 0, -1);
for (const entry of raw) {
const notif = JSON.parse(entry) as Notification;
if (!notif.read) {
notif.read = true;
await redis.lset(key, raw.indexOf(entry), JSON.stringify(notif));
}
}
await prisma.websiteNotification.updateMany({
where: { userId: BigInt(userId), read: false },
data: { read: true },
});
} catch {}
if (redis) {
try {
const key = `notifications:${userId}`;
const raw = await redis.lrange(key, 0, -1);
for (const entry of raw) {
const notif = JSON.parse(entry) as Notification;
if (!notif.read) {
notif.read = true;
await redis.lset(key, raw.indexOf(entry), JSON.stringify(notif));
}
}
} catch {}
}
}
async getUnreadCount(userId: number): Promise<number> {
const notifications = await this.getNotifications(userId);
return notifications.filter((n) => !n.read).length;
try {
return await prisma.websiteNotification.count({
where: { userId: BigInt(userId), read: false },
});
} catch {
return 0;
}
}
}
+201
View File
@@ -0,0 +1,201 @@
import { describe, expect, it, beforeEach } from "vitest";
import { pluginRegistry } from "../registry";
import type { PluginDefinition } from "../types";
function makePlugin(overrides: Partial<PluginDefinition> = {}): PluginDefinition {
return {
id: "test-plugin",
name: "Test Plugin",
description: "A test plugin",
version: "1.0.0",
author: "Test",
hubs: [
{
id: "test-hub",
titleKey: "testHub",
subtitleKey: "testHubSubtitle",
icon: "zap" as never,
prefixes: ["/admin/test"],
tabs: [{ href: "/admin/test", labelKey: "test" }],
},
],
navGroups: [
{
labelKey: "testGroup",
icon: "zap" as never,
items: [
{
href: "/admin/test",
labelKey: "test",
icon: "zap" as never,
},
],
},
],
permissions: [{ key: "test.perm", slug: "admin.test.perm" }],
...overrides,
};
}
describe("PluginRegistry", () => {
beforeEach(() => {
// Unregister all plugins
for (const p of pluginRegistry.getAll()) {
pluginRegistry.unregister(p.id);
}
});
describe("register", () => {
it("registers a new plugin", () => {
const plugin = makePlugin();
pluginRegistry.register(plugin);
expect(pluginRegistry.get("test-plugin")).toBe(plugin);
});
it("warns and skips duplicate registration", () => {
const plugin = makePlugin();
pluginRegistry.register(plugin);
pluginRegistry.register(plugin);
expect(pluginRegistry.getAll()).toHaveLength(1);
});
it("calls onLoad when registering", () => {
let loaded = false;
const plugin = makePlugin({
onLoad: () => {
loaded = true;
},
});
pluginRegistry.register(plugin);
expect(loaded).toBe(true);
});
});
describe("unregister", () => {
it("removes a registered plugin", () => {
const plugin = makePlugin();
pluginRegistry.register(plugin);
pluginRegistry.unregister("test-plugin");
expect(pluginRegistry.get("test-plugin")).toBeUndefined();
});
it("calls onUnload when unregistering", () => {
let unloaded = false;
const plugin = makePlugin({
onUnload: () => {
unloaded = true;
},
});
pluginRegistry.register(plugin);
pluginRegistry.unregister("test-plugin");
expect(unloaded).toBe(true);
});
it("does nothing for unknown id", () => {
expect(() => pluginRegistry.unregister("nonexistent")).not.toThrow();
});
});
describe("getHubs", () => {
it("returns hubs from all registered plugins", () => {
pluginRegistry.register(makePlugin({ id: "p1" }));
pluginRegistry.register(
makePlugin({
id: "p2",
hubs: [
{
id: "hub2",
titleKey: "hub2",
subtitleKey: "hub2Sub",
icon: "star" as never,
prefixes: ["/admin/hub2"],
tabs: [{ href: "/admin/hub2", labelKey: "hub2" }],
},
],
}),
);
const hubs = pluginRegistry.getHubs();
expect(hubs).toHaveLength(2);
expect(hubs.map((h) => h.id)).toContain("test-hub");
expect(hubs.map((h) => h.id)).toContain("hub2");
});
it("returns empty array when no plugins have hubs", () => {
pluginRegistry.register(makePlugin({ hubs: undefined }));
expect(pluginRegistry.getHubs()).toEqual([]);
});
});
describe("getNavGroups", () => {
it("returns nav groups from all registered plugins", () => {
pluginRegistry.register(makePlugin({ id: "p1" }));
const groups = pluginRegistry.getNavGroups();
expect(groups.length).toBeGreaterThan(0);
expect(groups[0].labelKey).toBe("testGroup");
});
it("returns empty array when no plugins have nav groups", () => {
pluginRegistry.register(makePlugin({ navGroups: undefined }));
expect(pluginRegistry.getNavGroups()).toEqual([]);
});
});
describe("getPermissionSlugs", () => {
it("returns all permission slugs from registered plugins", () => {
pluginRegistry.register(makePlugin({ id: "p1" }));
const slugs = pluginRegistry.getPermissionSlugs();
expect(slugs["test.perm"]).toBe("admin.test.perm");
});
it("merges permissions from multiple plugins", () => {
pluginRegistry.register(
makePlugin({
id: "p1",
permissions: [{ key: "perm1", slug: "admin.p1" }],
}),
);
pluginRegistry.register(
makePlugin({
id: "p2",
permissions: [{ key: "perm2", slug: "admin.p2" }],
}),
);
const slugs = pluginRegistry.getPermissionSlugs();
expect(slugs["perm1"]).toBe("admin.p1");
expect(slugs["perm2"]).toBe("admin.p2");
});
it("returns empty object when no permissions", () => {
pluginRegistry.register(makePlugin({ permissions: undefined }));
expect(pluginRegistry.getPermissionSlugs()).toEqual({});
});
});
describe("getWebhookEvents", () => {
it("returns webhook events from plugins", () => {
pluginRegistry.register(
makePlugin({
id: "p1",
webhookEvents: [{ event: "user.register", handler: "handleUserRegister" }],
}),
);
const events = pluginRegistry.getWebhookEvents();
expect(events).toHaveLength(1);
expect(events[0].event).toBe("user.register");
});
});
describe("getMessages", () => {
it("returns messages from plugins", () => {
pluginRegistry.register(
makePlugin({
id: "p1",
messages: [{ locale: "en", namespace: "test", messages: { hello: "world" } }],
}),
);
const msgs = pluginRegistry.getMessages();
expect(msgs).toHaveLength(1);
expect(msgs[0].locale).toBe("en");
});
});
});
+27
View File
@@ -0,0 +1,27 @@
type HookEvent = string;
type HookHandler = (data: Record<string, unknown>) => Promise<void>;
class HookManager {
private handlers = new Map<HookEvent, Set<HookHandler>>();
on(event: HookEvent, handler: HookHandler): void {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set());
}
this.handlers.get(event)!.add(handler);
}
off(event: HookEvent, handler: HookHandler): void {
this.handlers.get(event)?.delete(handler);
}
async emit(event: HookEvent, data: Record<string, unknown>): Promise<void> {
const handlers = this.handlers.get(event);
if (!handlers || handlers.size === 0) return;
await Promise.allSettled(
Array.from(handlers).map((h) => h(data)),
);
}
}
export const hooks = new HookManager();
+92
View File
@@ -0,0 +1,92 @@
import "server-only";
import { prisma } from "@/lib/prisma";
import { pluginRegistry } from "./registry";
const STORE_KEY = "plugin_config";
interface PluginStoreData {
enabled: Record<string, boolean>;
configs: Record<string, Record<string, string | boolean>>;
}
function defaultStore(): PluginStoreData {
const plugins = pluginRegistry.getAll();
const enabled: Record<string, boolean> = {};
const configs: Record<string, Record<string, string | boolean>> = {};
for (const p of plugins) {
enabled[p.id] = p.enabled ?? true;
if (p.configFields) {
const cfg: Record<string, string | boolean> = {};
for (const field of p.configFields) {
cfg[field.key] = field.default;
}
configs[p.id] = cfg;
}
}
return { enabled, configs };
}
async function load(): Promise<PluginStoreData> {
try {
const row = await prisma.websiteSetting.findUnique({
where: { key: STORE_KEY },
});
if (row?.value) {
const parsed = JSON.parse(row.value) as PluginStoreData;
const defs = defaultStore();
return {
enabled: { ...defs.enabled, ...parsed.enabled },
configs: { ...defs.configs, ...parsed.configs },
};
}
} catch {}
return defaultStore();
}
async function save(data: PluginStoreData): Promise<void> {
try {
await prisma.websiteSetting.upsert({
where: { key: STORE_KEY },
update: { value: JSON.stringify(data) },
create: { key: STORE_KEY, value: JSON.stringify(data) },
});
} catch {}
}
export async function isPluginEnabled(pluginId: string): Promise<boolean> {
const data = await load();
return data.enabled[pluginId] ?? true;
}
export async function setPluginEnabled(
pluginId: string,
enabled: boolean,
): Promise<void> {
const data = await load();
data.enabled[pluginId] = enabled;
await save(data);
}
export async function getPluginConfig(
pluginId: string,
): Promise<Record<string, string | boolean>> {
const data = await load();
return data.configs[pluginId] ?? {};
}
export async function setPluginConfig(
pluginId: string,
config: Record<string, string | boolean>,
): Promise<void> {
const data = await load();
data.configs[pluginId] = { ...data.configs[pluginId], ...config };
await save(data);
}
export async function getAllPluginStates(): Promise<{
enabled: Record<string, boolean>;
configs: Record<string, Record<string, string | boolean>>;
}> {
return load();
}
+10
View File
@@ -52,18 +52,28 @@ export interface PluginWebhookEvent {
handler: string;
}
export interface PluginConfigField {
key: string;
label: string;
type: "text" | "boolean" | "select";
options?: { label: string; value: string }[];
default: string | boolean;
}
export interface PluginDefinition {
id: string;
name: string;
description: string;
version: string;
author?: string;
enabled?: boolean;
hubs?: PluginHub[];
navGroups?: PluginNavGroup[];
permissions?: PluginPermission[];
messages?: PluginMessage[];
routes?: PluginRoute[];
webhookEvents?: PluginWebhookEvent[];
configFields?: PluginConfigField[];
onLoad?: () => void | Promise<void>;
onUnload?: () => void | Promise<void>;
+173
View File
@@ -0,0 +1,173 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
const mockRedis = vi.hoisted(() => ({
get: vi.fn(),
set: vi.fn(),
del: vi.fn(),
}));
vi.mock("@/lib/redis", () => ({
redis: mockRedis,
}));
vi.mock("@/lib/prisma", () => ({
prisma: {
websiteWebhookLog: {
create: vi.fn(),
findMany: vi.fn(),
count: vi.fn(),
},
},
}));
import { prisma } from "@/lib/prisma";
import { webhookService } from "../service";
const mockLog = vi.mocked(prisma.websiteWebhookLog);
function mockFetch(response: Partial<Response> = {}) {
globalThis.fetch = vi.fn().mockResolvedValue({
status: 200,
text: () => Promise.resolve("OK"),
...response,
});
}
const WEBHOOKS_KEY = "webhooks:configs";
describe("WebhookService", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetch();
mockRedis.get.mockResolvedValue(
JSON.stringify([
{
id: "wh-1",
url: "https://example.com/webhook",
events: ["user.register"],
active: true,
secret: undefined,
createdAt: Date.now(),
},
]),
);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("fire", () => {
it("calls in-process handlers for the event", async () => {
const handler = vi.fn().mockResolvedValue(undefined);
webhookService.on("user.register" as never, handler);
await webhookService.fire("user.register" as never, { userId: 1 });
expect(handler).toHaveBeenCalledWith({ userId: 1 });
});
it("does not call handlers for unrelated events", async () => {
const handler = vi.fn();
webhookService.on("user.register" as never, handler);
await webhookService.fire("shop.purchase" as never, { itemId: 1 });
expect(handler).not.toHaveBeenCalled();
});
});
describe("deliver (internal)", () => {
it("delivers payload and logs success", async () => {
mockLog.create.mockResolvedValue({
id: BigInt(1),
webhookId: "wh-1",
url: "https://example.com/webhook",
event: "user.register",
status: 200,
response: "OK",
duration: 0,
success: true,
createdAt: new Date(),
});
await webhookService.fire("user.register" as never, { userId: 1 });
expect(mockRedis.get).toHaveBeenCalledWith(WEBHOOKS_KEY);
expect(mockLog.create).toHaveBeenCalled();
const call = mockLog.create.mock.calls[0]?.[0];
expect(call?.data?.url).toBe("https://example.com/webhook");
expect(call?.data?.success).toBe(true);
expect(call?.data?.event).toBe("user.register");
});
it("retries on failure with exponential backoff", async () => {
vi.useFakeTimers();
globalThis.fetch = vi.fn().mockRejectedValue(new Error("Network error"));
mockLog.create.mockResolvedValue({
id: BigInt(1),
webhookId: "wh-1",
url: "https://example.com/webhook",
event: "user.register",
status: 0,
response: "",
duration: 0,
success: false,
createdAt: new Date(),
});
const firePromise = webhookService.fire("user.register" as never, {
userId: 1,
});
await vi.advanceTimersByTimeAsync(1000);
await vi.advanceTimersByTimeAsync(3000);
await vi.advanceTimersByTimeAsync(9000);
await firePromise;
expect(globalThis.fetch).toHaveBeenCalledTimes(4);
vi.useRealTimers();
});
});
describe("retryFailed", () => {
it("retries failed deliveries for a webhook", async () => {
mockLog.findMany.mockResolvedValue([
{
id: BigInt(1),
webhookId: "wh-1",
url: "https://example.com/webhook",
event: "user.register",
status: 0,
response: null,
duration: 0,
success: false,
createdAt: new Date(),
},
]);
mockLog.create.mockResolvedValue({
id: BigInt(2),
webhookId: "wh-1",
url: "https://example.com/webhook",
event: "user.register",
status: 200,
response: "OK",
duration: 10,
success: true,
createdAt: new Date(),
});
await webhookService.retryFailed("wh-1");
expect(mockLog.findMany).toHaveBeenCalledWith({
where: { webhookId: "wh-1", success: false },
orderBy: { createdAt: "desc" },
take: 10,
});
});
});
});
+78 -9
View File
@@ -1,30 +1,40 @@
import "server-only";
import { createHmac } from "node:crypto";
import { prisma } from "@/lib/prisma";
import { redis } from "@/lib/redis";
import { pluginRegistry } from "@/lib/plugins";
import type { WebhookConfig, WebhookEvent, WebhookPayload } from "./types";
const WEBHOOKS_KEY = "webhooks:configs";
const MAX_RETRIES = 3;
class WebhookService {
private inProcessHandlers = new Map<WebhookEvent, Set<(data: Record<string, unknown>) => Promise<void>>>();
private inProcessHandlers = new Map<
WebhookEvent,
Set<(data: Record<string, unknown>) => Promise<void>>
>();
on(event: WebhookEvent, handler: (data: Record<string, unknown>) => Promise<void>): void {
on(
event: WebhookEvent,
handler: (data: Record<string, unknown>) => Promise<void>,
): void {
if (!this.inProcessHandlers.has(event)) {
this.inProcessHandlers.set(event, new Set());
}
this.inProcessHandlers.get(event)!.add(handler);
}
async fire(event: WebhookEvent, data: Record<string, unknown>): Promise<void> {
async fire(
event: WebhookEvent,
data: Record<string, unknown>,
): Promise<void> {
const payload: WebhookPayload = {
event,
timestamp: Date.now(),
data,
};
// Run in-process handlers (registered by plugins)
const handlers = this.inProcessHandlers.get(event);
if (handlers) {
await Promise.allSettled(
@@ -32,7 +42,6 @@ class WebhookService {
);
}
// Fire registered webhook URLs
const configs = await this.getConfigs();
const matched = configs.filter(
(c) => c.active && c.events.includes(event),
@@ -43,7 +52,16 @@ class WebhookService {
);
}
private async deliver(config: WebhookConfig, payload: WebhookPayload): Promise<void> {
private async deliver(
config: WebhookConfig,
payload: WebhookPayload,
retryCount = 0,
): Promise<void> {
const start = Date.now();
let status = 0;
let responseBody = "";
let success = false;
try {
const body = JSON.stringify(payload);
const headers: Record<string, string> = {
@@ -58,15 +76,66 @@ class WebhookService {
headers["X-Webhook-Signature"] = sig;
}
await fetch(config.url, {
const res = await fetch(config.url, {
method: "POST",
headers,
body,
signal: AbortSignal.timeout(10_000),
});
} catch {
// Silent fail — webhook delivery errors are non-critical
status = res.status;
responseBody = await res.text().catch(() => "");
success = status >= 200 && status < 300;
} catch (err) {
status = 0;
responseBody = err instanceof Error ? err.message : "Unknown error";
success = false;
}
const duration = Date.now() - start;
try {
await prisma.websiteWebhookLog.create({
data: {
webhookId: config.id,
url: config.url,
event: payload.event,
status,
response: responseBody.slice(0, 2000),
duration,
success,
},
});
} catch {}
if (!success && retryCount < MAX_RETRIES) {
const delay = [1000, 3000, 9000][retryCount] ?? 9000;
await new Promise((r) => setTimeout(r, delay));
return this.deliver(config, payload, retryCount + 1);
}
}
async retryFailed(webhookId: string): Promise<void> {
try {
const logs = await prisma.websiteWebhookLog.findMany({
where: { webhookId, success: false },
orderBy: { createdAt: "desc" },
take: 10,
});
const configs = await this.getConfigs();
const config = configs.find((c) => c.id === webhookId);
if (!config) return;
for (const log of logs) {
const payload: WebhookPayload = {
event: log.event as WebhookEvent,
timestamp: Date.now(),
data: {},
};
await this.deliver(config, payload);
}
} catch {}
}
async getConfigs(): Promise<WebhookConfig[]> {