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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user