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

@@ -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;
}
}
}