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
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:
1 parent
8bf48f46e7
commit
83f4585167
30 files changed
+2196
-51
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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[]> {
|
||||
|
||||
Reference in new issue
Block a user