diff --git a/prisma/migrations/0017_notifications_webhooks.sql b/prisma/migrations/0017_notifications_webhooks.sql new file mode 100644 index 00000000..00c8bc09 --- /dev/null +++ b/prisma/migrations/0017_notifications_webhooks.sql @@ -0,0 +1,48 @@ +-- Notifications +CREATE TABLE IF NOT EXISTS `website_notifications` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` BIGINT UNSIGNED NOT NULL, + `type` VARCHAR(50) NOT NULL, + `title` VARCHAR(255) NOT NULL, + `body` TEXT DEFAULT NULL, + `icon` VARCHAR(50) DEFAULT NULL, + `link` VARCHAR(500) DEFAULT NULL, + `read` TINYINT(1) NOT NULL DEFAULT 0, + `created_at` TIMESTAMP(0) DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + INDEX `idx_user_read` (`user_id`, `read`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Notification preferences +CREATE TABLE IF NOT EXISTS `website_notification_preferences` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` BIGINT UNSIGNED NOT NULL, + `email_enabled` TINYINT(1) NOT NULL DEFAULT 1, + `push_enabled` TINYINT(1) NOT NULL DEFAULT 1, + `friend_request` TINYINT(1) NOT NULL DEFAULT 1, + `messages` TINYINT(1) NOT NULL DEFAULT 1, + `guild_invite` TINYINT(1) NOT NULL DEFAULT 1, + `events` TINYINT(1) NOT NULL DEFAULT 1, + `alerts` TINYINT(1) NOT NULL DEFAULT 1, + `achievements` TINYINT(1) NOT NULL DEFAULT 1, + `radio` TINYINT(1) NOT NULL DEFAULT 1, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_user` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Webhook delivery logs +CREATE TABLE IF NOT EXISTS `website_webhook_logs` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `webhook_id` VARCHAR(100) NOT NULL, + `url` VARCHAR(500) NOT NULL, + `event` VARCHAR(100) NOT NULL, + `status` INT NOT NULL DEFAULT 0, + `response` TEXT DEFAULT NULL, + `duration` INT NOT NULL DEFAULT 0, + `success` TINYINT(1) NOT NULL DEFAULT 0, + `created_at` TIMESTAMP(0) DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + INDEX `idx_webhook_logs` (`webhook_id`, `created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Migration tracking diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3b63ab29..cad8af95 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2759,3 +2759,50 @@ model WebsitePollVote { @@index([userId]) @@map("website_poll_votes") } + +model WebsiteNotification { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + userId BigInt @map("user_id") @db.UnsignedBigInt + type String @db.VarChar(50) + title String @db.VarChar(255) + body String? @db.Text + icon String? @db.VarChar(50) + link String? @db.VarChar(500) + read Boolean @default(false) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) + + @@index([userId, read]) + @@map("website_notifications") +} + +model WebsiteNotificationPreference { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + userId BigInt @map("user_id") @db.UnsignedBigInt + emailEnabled Boolean @default(true) @map("email_enabled") + pushEnabled Boolean @default(true) @map("push_enabled") + friendRequest Boolean @default(true) @map("friend_request") + messages Boolean @default(true) + guildInvite Boolean @default(true) @map("guild_invite") + events Boolean @default(true) + alerts Boolean @default(true) + achievements Boolean @default(true) + radio Boolean @default(true) + + @@unique([userId]) + @@map("website_notification_preferences") +} + +model WebsiteWebhookLog { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + webhookId String @map("webhook_id") @db.VarChar(100) + url String @db.VarChar(500) + event String @db.VarChar(100) + status Int @default(0) + response String? @db.Text + duration Int @default(0) + success Boolean @default(false) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) + + @@index([webhookId, createdAt]) + @@map("website_webhook_logs") +} diff --git a/src/app/(site)/games/leaderboard/page.tsx b/src/app/(site)/games/leaderboard/page.tsx new file mode 100644 index 00000000..ff731bfb --- /dev/null +++ b/src/app/(site)/games/leaderboard/page.tsx @@ -0,0 +1,144 @@ +import Link from "next/link"; +import { getTranslations } from "next-intl/server"; +import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui"; +import { avatarImageUrl } from "@/lib/format"; +import { prisma } from "@/lib/prisma"; +import { siteSettings } from "@/lib/services/site-settings"; + +export const revalidate = 300; +export const metadata = { title: "Game Leaderboard" }; + +const GAMES = ["snowstorm", "battleball", "habbo_lido", "fast_food"] as const; +type GameKey = (typeof GAMES)[number]; + +const GAME_LABELS: Record = { + snowstorm: "SnowStorm", + battleball: "Battle Ball", + habbo_lido: "Habbo Lido", + fast_food: "Fast Food", +}; + +async function loadLeaderboard( + game: GameKey, +): Promise> { + try { + const top = await prisma.gameScores.findMany({ + where: { game }, + orderBy: { score: "desc" }, + take: 50, + select: { userId: true, score: true }, + }); + + if (top.length === 0) return []; + + const users = await prisma.user.findMany({ + where: { id: { in: top.map((t) => t.userId) } }, + select: { id: true, username: true, look: true }, + }); + const byId = new Map(users.map((u) => [u.id, u])); + + return top + .map((t) => { + const u = byId.get(t.userId); + if (!u) return null; + return { username: u.username, look: u.look, score: t.score }; + }) + .filter((r): r is { username: string; look: string; score: number } => r !== null); + } catch { + return []; + } +} + +export default async function GamesLeaderboardPage({ + searchParams, +}: { + searchParams: Promise<{ game?: string }>; +}) { + const t = await getTranslations("pages.games.leaderboard"); + const { game } = await searchParams; + const active: GameKey = GAMES.includes(game as GameKey) + ? (game as GameKey) + : "snowstorm"; + + const [rows, imagerBase] = await Promise.all([ + loadLeaderboard(active), + siteSettings.get( + "habbo_imaging_url", + "https://www.habbo.com/habbo-imaging/avatarimage", + ), + ]); + + return ( +
+ + +
+ {GAMES.map((g) => ( + + {GAME_LABELS[g]} + + ))} +
+ + + {rows.length === 0 ? ( + {t("emptyState")} + ) : ( + + + + + + + + + + + {rows.map((row, i) => { + const avatar = avatarImageUrl(imagerBase ?? "", row.look, { + size: "s", + headOnly: true, + }); + return ( + + + + + + + ); + })} + +
#{t("colAvatar")}{t("colPlayer")}{t("colScore")}
+ + + {`${row.username} + + + {row.username} + + + {row.score.toLocaleString()} +
+ )} +
+
+ ); +} diff --git a/src/app/admin/games/challenges/page.tsx b/src/app/admin/games/challenges/page.tsx new file mode 100644 index 00000000..d2aa2c99 --- /dev/null +++ b/src/app/admin/games/challenges/page.tsx @@ -0,0 +1,80 @@ +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; +export const metadata = { title: "Daily Challenges" }; + +export default async function ChallengesPage() { + let challenges: Array<{ + id: bigint; + name: string; + description: string; + game: string | null; + type: string; + target: number; + rewardCurrency: string; + rewardAmount: number; + date: Date; + }> = []; + try { + challenges = await prisma.dailyChallenges.findMany({ + orderBy: { date: "desc" }, + }); + } catch {} + + return ( +
+
+ + + + + + + + + + + + + + + {challenges.map((c) => ( + + + + + + + + + + + ))} + {challenges.length === 0 && ( + + + + )} + +
IDNameDescriptionGameTypeTargetRewardDate
{String(c.id)} + {c.name} + + {c.description} + {c.game ?? "—"} + + {c.type} + + {c.target} + {c.rewardAmount} {c.rewardCurrency} + + {c.date.toISOString().slice(0, 10)} +
+ No daily challenges found. +
+
+
+ ); +} diff --git a/src/app/admin/games/ranks/page.tsx b/src/app/admin/games/ranks/page.tsx new file mode 100644 index 00000000..8c97c358 --- /dev/null +++ b/src/app/admin/games/ranks/page.tsx @@ -0,0 +1,142 @@ +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; +export const metadata = { title: "Game Ranks" }; + +export default async function GameRanksPage() { + let ranks: Array<{ + id: bigint; + name: string; + slug: string; + icon: string; + color: string; + minPoints: number; + maxPoints: number | null; + tier: number; + }> = []; + try { + ranks = await prisma.gameRanks.findMany({ + orderBy: { minPoints: "asc" }, + }); + } catch {} + + return ( +
+
+ + + + + + + + + + + + + + + + {ranks.map((rank) => ( + + + + + + + + + + + + ))} + {ranks.length === 0 && ( + + + + )} + +
IDNameSlugIconColorMin PointsMax PointsTierActions
{String(rank.id)} + + {rank.slug} + + + + + + + + {rank.tier} + +
+ No game ranks found. +
+
+
+ ); +} + +function SaveButton({ id }: { id: string }) { + return ( + + ); +} diff --git a/src/app/admin/games/rewards/page.tsx b/src/app/admin/games/rewards/page.tsx new file mode 100644 index 00000000..a8368b56 --- /dev/null +++ b/src/app/admin/games/rewards/page.tsx @@ -0,0 +1,74 @@ +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; +export const metadata = { title: "Game Level Rewards" }; + +export default async function GameRewardsPage() { + let rewards: Array<{ + id: bigint; + game: string; + level: number; + rewardType: string; + rewardCurrency: string | null; + rewardAmount: number; + claimed: boolean; + }> = []; + try { + rewards = await prisma.gameLevelRewards.findMany({ + orderBy: [{ game: "asc" }, { level: "asc" }], + }); + } catch {} + + return ( +
+
+ + + + + + + + + + + + + + {rewards.map((reward) => ( + + + + + + + + + + ))} + {rewards.length === 0 && ( + + + + )} + +
IDGameLevelReward TypeCurrencyAmountClaimed
{String(reward.id)}{reward.game}{reward.level} + + {reward.rewardType} + + {reward.rewardCurrency ?? "—"}{reward.rewardAmount} + {reward.claimed ? ( + Yes + ) : ( + No + )} +
+ No level rewards found. +
+
+
+ ); +} diff --git a/src/app/admin/plugins/client.tsx b/src/app/admin/plugins/client.tsx new file mode 100644 index 00000000..31cea22d --- /dev/null +++ b/src/app/admin/plugins/client.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { pluginRegistry } from "@/lib/plugins/registry"; +import type { PluginDefinition, PluginConfigField } from "@/lib/plugins/types"; + +interface PluginWithState extends PluginDefinition { + enabled: boolean; + config: Record; +} + +export function PluginsClient() { + const [plugins, setPlugins] = useState([]); + const [editing, setEditing] = useState(null); + + const load = useCallback(async () => { + try { + const res = await fetch("/api/admin/plugins"); + const data = await res.json(); + setPlugins(data.plugins ?? []); + } catch {} + }, []); + + useEffect(() => { + load(); + }, [load]); + + const toggleEnabled = async (id: string, current: boolean) => { + try { + await fetch(`/api/admin/plugins`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id, enabled: !current }), + }); + load(); + } catch {} + }; + + const saveConfig = async (id: string, config: Record) => { + try { + await fetch(`/api/admin/plugins`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id, config }), + }); + setEditing(null); + load(); + } catch {} + }; + + return ( +
+ {plugins.length === 0 && ( +
+ No plugins registered. +
+ )} + {plugins.map((plugin) => ( +
+
+
+

+ {plugin.name} +

+ + v{plugin.version} + +
+

+ {plugin.description} +

+
+ ID: {plugin.id} + {plugin.author && Author: {plugin.author}} +
+ + {plugin.configFields && plugin.configFields.length > 0 && ( +
+ {editing === plugin.id ? ( + saveConfig(plugin.id, cfg)} + onCancel={() => setEditing(null)} + /> + ) : ( + + )} +
+ )} +
+ +
+
+ ))} +
+ ); +} + +function ConfigForm({ + fields, + initial, + onSave, + onCancel, +}: { + fields: PluginConfigField[]; + initial: Record; + onSave: (config: Record) => void; + onCancel: () => void; +}) { + const [values, setValues] = useState>(initial); + + const update = (key: string, value: string | boolean) => { + setValues((prev) => ({ ...prev, [key]: value })); + }; + + return ( +
+ {fields.map((field) => ( +
+ + {field.type === "text" && ( + update(field.key, e.target.value)} + className="w-full px-2 py-1 text-xs rounded-md border border-[var(--admin-border)] bg-transparent text-[var(--admin-text)]" + /> + )} + {field.type === "boolean" && ( +
+ ); +} diff --git a/src/app/admin/plugins/page.tsx b/src/app/admin/plugins/page.tsx new file mode 100644 index 00000000..9a1ec387 --- /dev/null +++ b/src/app/admin/plugins/page.tsx @@ -0,0 +1,27 @@ +import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; +import { PluginsClient } from "./client"; +import { canAccess, getAdminContext } from "@/lib/permissions"; + +export const dynamic = "force-dynamic"; +export const metadata = { title: "Plugins" }; + +export default async function AdminPluginsPage() { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, "admin.settings.view", session.user.rank)) { + redirect("/admin"); + } + + const t = await getTranslations("pages.admin.plugins"); + + return ( +
+
+

+ {t("subtitle") ?? "Manage installed plugins"} +

+
+ +
+ ); +} diff --git a/src/app/admin/webhooks/page.tsx b/src/app/admin/webhooks/page.tsx index 9b145971..47d1f775 100644 --- a/src/app/admin/webhooks/page.tsx +++ b/src/app/admin/webhooks/page.tsx @@ -167,7 +167,7 @@ export default function WebhooksPage() { diff --git a/src/app/api/admin/games/ranks/route.ts b/src/app/api/admin/games/ranks/route.ts new file mode 100644 index 00000000..fd41837b --- /dev/null +++ b/src/app/api/admin/games/ranks/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from "next/server"; +import { PERMS } from "@/lib/permissions"; +import { prisma } from "@/lib/prisma"; + +async function checkAuth() { + const { getApiAdminContext } = await import("@/lib/permissions"); + const ctx = await getApiAdminContext(); + if (!ctx || !ctx.permissions.has(PERMS.SETTINGS_VIEW)) return null; + return ctx; +} + +export const dynamic = "force-dynamic"; + +export async function PATCH(request: Request) { + const ctx = await checkAuth(); + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let body: Record; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const id = body.id as string | undefined; + if (!id) { + return NextResponse.json({ error: "ID is required" }, { status: 400 }); + } + + try { + await prisma.gameRanks.update({ + where: { id: BigInt(id) }, + data: { + ...(typeof body.name === "string" && { name: body.name }), + ...(typeof body.icon === "string" && { icon: body.icon }), + ...(typeof body.color === "string" && { color: body.color }), + ...(typeof body.minPoints === "number" && { + minPoints: body.minPoints, + }), + ...(body.maxPoints === null && { maxPoints: null }), + ...(typeof body.maxPoints === "number" && { + maxPoints: body.maxPoints, + }), + }, + }); + return NextResponse.json({ ok: true }); + } catch (err) { + return NextResponse.json( + { error: "Update failed", details: String(err) }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/admin/plugins/route.ts b/src/app/api/admin/plugins/route.ts new file mode 100644 index 00000000..feac995b --- /dev/null +++ b/src/app/api/admin/plugins/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from "next/server"; +import { PERMS } from "@/lib/permissions"; +import { pluginRegistry } from "@/lib/plugins"; +import { + getAllPluginStates, + setPluginEnabled, + setPluginConfig, +} from "@/lib/plugins/store"; + +async function checkAuth() { + const { getApiAdminContext } = await import("@/lib/permissions"); + const ctx = await getApiAdminContext(); + if (!ctx || !ctx.permissions.has(PERMS.SETTINGS_VIEW)) return null; + return ctx; +} + +export const dynamic = "force-dynamic"; + +export async function GET() { + const ctx = await checkAuth(); + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const all = pluginRegistry.getAll(); + const states = await getAllPluginStates(); + + const plugins = all.map((p) => ({ + id: p.id, + name: p.name, + description: p.description, + version: p.version, + author: p.author, + configFields: p.configFields ?? [], + enabled: states.enabled[p.id] ?? true, + config: states.configs[p.id] ?? {}, + })); + + return NextResponse.json({ plugins }); +} + +export async function PATCH(request: Request) { + const ctx = await checkAuth(); + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + if (!body.id) { + return NextResponse.json({ error: "Plugin ID is required" }, { status: 400 }); + } + + const plugin = pluginRegistry.get(body.id); + if (!plugin) { + return NextResponse.json({ error: "Plugin not found" }, { status: 404 }); + } + + if (typeof body.enabled === "boolean") { + await setPluginEnabled(body.id, body.enabled); + } + + if (body.config && typeof body.config === "object") { + await setPluginConfig(body.id, body.config); + } + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/admin/webhooks/logs/route.ts b/src/app/api/admin/webhooks/logs/route.ts new file mode 100644 index 00000000..1fe8062c --- /dev/null +++ b/src/app/api/admin/webhooks/logs/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from "next/server"; +import { PERMS } from "@/lib/permissions"; +import { prisma } from "@/lib/prisma"; + +async function checkAuth() { + const { getApiAdminContext } = await import("@/lib/permissions"); + const ctx = await getApiAdminContext(); + if (!ctx || !ctx.permissions.has(PERMS.SETTINGS_VIEW)) return null; + return ctx; +} + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + const ctx = await checkAuth(); + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const webhookId = searchParams.get("webhook_id"); + const page = Math.max(1, Number(searchParams.get("page")) || 1); + const limit = Math.min(100, Math.max(1, Number(searchParams.get("limit")) || 20)); + + if (!webhookId) { + return NextResponse.json( + { error: "webhook_id query parameter is required" }, + { status: 400 }, + ); + } + + try { + const skip = (page - 1) * limit; + const [logs, total] = await Promise.all([ + prisma.websiteWebhookLog.findMany({ + where: { webhookId }, + orderBy: { createdAt: "desc" }, + skip, + take: limit, + }), + prisma.websiteWebhookLog.count({ + where: { webhookId }, + }), + ]); + + const rows = logs.map((l) => ({ + id: String(l.id), + webhookId: l.webhookId, + url: l.url, + event: l.event, + status: l.status, + response: l.response, + duration: l.duration, + success: l.success, + createdAt: l.createdAt.toISOString(), + })); + + return NextResponse.json({ + logs: rows, + total, + page, + lastPage: Math.ceil(total / limit), + }); + } catch { + return NextResponse.json({ logs: [], total: 0, page: 1, lastPage: 1 }); + } +} diff --git a/src/app/api/admin/webhooks/test/route.ts b/src/app/api/admin/webhooks/test/route.ts new file mode 100644 index 00000000..8927d059 --- /dev/null +++ b/src/app/api/admin/webhooks/test/route.ts @@ -0,0 +1,69 @@ +import { NextResponse } from "next/server"; +import { PERMS } from "@/lib/permissions"; + +async function checkAuth() { + const { getApiAdminContext } = await import("@/lib/permissions"); + const ctx = await getApiAdminContext(); + if (!ctx || !ctx.permissions.has(PERMS.SETTINGS_VIEW)) return null; + return ctx; +} + +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + const ctx = await checkAuth(); + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let body: { url?: string }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + if (!body.url) { + return NextResponse.json({ error: "URL is required" }, { status: 400 }); + } + + const testPayload = { + event: "webhook.test", + timestamp: Date.now(), + data: { + message: "This is a test webhook from Atom CMS", + source: "admin-webhooks-test", + }, + }; + + const start = Date.now(); + let status = 0; + let responseText = ""; + + try { + const res = await fetch(body.url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Webhook-Event": "webhook.test", + }, + body: JSON.stringify(testPayload), + signal: AbortSignal.timeout(10_000), + }); + + status = res.status; + responseText = await res.text().catch(() => ""); + } catch (err) { + status = 0; + responseText = err instanceof Error ? err.message : "Connection failed"; + } + + const duration = Date.now() - start; + + return NextResponse.json({ + status, + duration, + response: responseText.slice(0, 2000), + success: status >= 200 && status < 300, + }); +} diff --git a/src/app/api/notifications/preferences/route.ts b/src/app/api/notifications/preferences/route.ts new file mode 100644 index 00000000..6be2d337 --- /dev/null +++ b/src/app/api/notifications/preferences/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { + getPreferences, + updatePreferences, +} from "@/lib/notifications/preferences"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const userId = Number(session.user.id); + const prefs = await getPreferences(userId); + + return NextResponse.json({ preferences: prefs }); +} + +export async function PUT(request: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const userId = Number(session.user.id); + const body = await request.json(); + + await updatePreferences(userId, { + emailEnabled: body.emailEnabled, + pushEnabled: body.pushEnabled, + friendRequest: body.friendRequest, + messages: body.messages, + guildInvite: body.guildInvite, + events: body.events, + alerts: body.alerts, + achievements: body.achievements, + radio: body.radio, + }); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/notifications/push/subscribe/route.ts b/src/app/api/notifications/push/subscribe/route.ts new file mode 100644 index 00000000..5c813ffd --- /dev/null +++ b/src/app/api/notifications/push/subscribe/route.ts @@ -0,0 +1,61 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { redis } from "@/lib/redis"; + +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const userId = Number(session.user.id); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let subscription: Record; + try { + subscription = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid body" }, { status: 400 }); + } + + if (!subscription || !subscription.endpoint) { + return NextResponse.json( + { error: "Missing endpoint" }, + { status: 400 }, + ); + } + + if (redis) { + try { + const key = `push:sub:${userId}`; + await redis.set(key, JSON.stringify(subscription)); + } catch {} + } + + return NextResponse.json({ ok: true }); +} + +export async function DELETE(request: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const userId = Number(session.user.id); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (redis) { + try { + const key = `push:sub:${userId}`; + await redis.del(key); + } catch {} + } + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/notifications/push/vapid-public-key/route.ts b/src/app/api/notifications/push/vapid-public-key/route.ts new file mode 100644 index 00000000..e2b158a1 --- /dev/null +++ b/src/app/api/notifications/push/vapid-public-key/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const publicKey = + process.env.VAPID_PUBLIC_KEY ?? + "BBI9jJhPdVxJXpP1T7GEX-NmT0ZzLqDwQfN4F0KxLxYqQ0GpCjVvXcR-Kx3WQmY-NiZ9jJhPdVxJXpP1T7GEXA"; + + return NextResponse.json({ publicKey }); +} diff --git a/src/app/api/notifications/stream/route.ts b/src/app/api/notifications/stream/route.ts index f860fa23..43cbc960 100644 --- a/src/app/api/notifications/stream/route.ts +++ b/src/app/api/notifications/stream/route.ts @@ -15,22 +15,59 @@ export async function GET(request: Request) { return new Response("Unauthorized", { status: 401 }); } + // Track active connections per user — close duplicate connections + if (redis) { + try { + const connKey = `stream:conn:${userId}`; + const existing = await redis.get(connKey); + if (existing) { + // Signal the previous connection to close via pub/sub + await redis.publish( + `stream:close:${userId}`, + JSON.stringify({ reason: "duplicate" }), + ); + } + await redis.set(connKey, "active", "EX", 60); + } catch {} + } + const stream = new ReadableStream({ start(controller) { const encoder = new TextEncoder(); let cleanup: (() => void) | null = null; + let closed = false; const sendEvent = (data: string) => { - controller.enqueue(encoder.encode(`data: ${data}\n\n`)); + if (!closed) { + controller.enqueue(encoder.encode(`data: ${data}\n\n`)); + } }; sendEvent(JSON.stringify({ type: "connected" })); if (redis) { const subscriber = redis.duplicate(); + + // Listen for close signals (duplicate connection) + subscriber.subscribe(`stream:close:${userId}`, () => { + sendEvent( + JSON.stringify({ + type: "closed", + message: "Duplicate connection detected", + }), + ); + closed = true; + controller.close(); + }); + subscriber.subscribe(`user:${userId}`, (err) => { if (err) { - sendEvent(JSON.stringify({ type: "error", message: "Subscription failed" })); + sendEvent( + JSON.stringify({ + type: "error", + message: "Subscription failed", + }), + ); } }); @@ -51,6 +88,11 @@ export async function GET(request: Request) { request.signal.addEventListener("abort", () => { clearInterval(keepAlive); cleanup?.(); + if (redis) { + try { + redis.del(`stream:conn:${userId}`); + } catch {} + } }); }, }); diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 72d0f6d2..5186265b 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -1,7 +1,17 @@ import { NextResponse } from "next/server"; +import { clientIp, rateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/prisma"; export async function GET(request: Request) { + const ip = await clientIp(); + const limit = await rateLimit(`search:${ip}`, 30, 60_000); + if (!limit.ok) { + return NextResponse.json( + { error: "Too many requests", retryAfter: limit.retryAfter }, + { status: 429 }, + ); + } + const { searchParams } = new URL(request.url); const q = searchParams.get("q")?.trim(); diff --git a/src/components/admin/confirm-dialog.tsx b/src/components/admin/confirm-dialog.tsx index 2e324d0f..5f293bb3 100644 --- a/src/components/admin/confirm-dialog.tsx +++ b/src/components/admin/confirm-dialog.tsx @@ -102,7 +102,7 @@ export function ConfirmDialog({ variant={variant === "danger" ? "destructive" : "default"} disabled={isLoading} onClick={handleConfirm} - className={cn(variant === "danger" && "bg-destructive text-white")} + className={cn(variant === "danger" && "bg-destructive text-destructive-foreground")} autoFocus > {confirmLabel} diff --git a/src/components/push-subscribe.tsx b/src/components/push-subscribe.tsx new file mode 100644 index 00000000..54426d2e --- /dev/null +++ b/src/components/push-subscribe.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); + const rawData = atob(base64); + return Uint8Array.from(rawData.split("").map((c) => c.charCodeAt(0))); +} + +export function PushSubscribeButton() { + const [supported, setSupported] = useState(false); + const [subscribed, setSubscribed] = useState(false); + const [loading, setLoading] = useState(false); + + useEffect(() => { + setSupported("serviceWorker" in navigator && "PushManager" in window); + }, []); + + const subscribe = useCallback(async () => { + if (!supported) return; + setLoading(true); + try { + const registration = await navigator.serviceWorker.ready; + + const existing = await registration.pushManager.getSubscription(); + if (existing) { + await existing.unsubscribe(); + } + + const response = await fetch("/api/notifications/push/vapid-public-key"); + const { publicKey } = await response.json(); + + const key = urlBase64ToUint8Array(publicKey); + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: key.buffer as unknown as BufferSource, + }); + + await fetch("/api/notifications/push/subscribe", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(subscription.toJSON()), + }); + + setSubscribed(true); + } catch { + setSubscribed(false); + } finally { + setLoading(false); + } + }, [supported]); + + const unsubscribe = useCallback(async () => { + setLoading(true); + try { + const registration = await navigator.serviceWorker.ready; + const existing = await registration.pushManager.getSubscription(); + if (existing) { + await existing.unsubscribe(); + } + await fetch("/api/notifications/push/subscribe", { method: "DELETE" }); + setSubscribed(false); + } catch { + } finally { + setLoading(false); + } + }, []); + + if (!supported) return null; + + return ( + + ); +} diff --git a/src/lib/admin-nav.ts b/src/lib/admin-nav.ts index 1e4fb1a6..b27a9ccd 100644 --- a/src/lib/admin-nav.ts +++ b/src/lib/admin-nav.ts @@ -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" }, ], }, { diff --git a/src/lib/notifications/__tests__/service.test.ts b/src/lib/notifications/__tests__/service.test.ts new file mode 100644 index 00000000..008895df --- /dev/null +++ b/src/lib/notifications/__tests__/service.test.ts @@ -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); + }); + }); +}); diff --git a/src/lib/notifications/preferences.ts b/src/lib/notifications/preferences.ts new file mode 100644 index 00000000..bacc5ec6 --- /dev/null +++ b/src/lib/notifications/preferences.ts @@ -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 { + 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, +): Promise { + 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 {} +} diff --git a/src/lib/notifications/service.ts b/src/lib/notifications/service.ts index 76eb74af..def3a53a 100644 --- a/src/lib/notifications/service.ts +++ b/src/lib/notifications/service.ts @@ -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 { 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 { - 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 { - 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 { - 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 { - 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; + } } } diff --git a/src/lib/plugins/__tests__/registry.test.ts b/src/lib/plugins/__tests__/registry.test.ts new file mode 100644 index 00000000..54c15344 --- /dev/null +++ b/src/lib/plugins/__tests__/registry.test.ts @@ -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 { + 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"); + }); + }); +}); diff --git a/src/lib/plugins/hooks.ts b/src/lib/plugins/hooks.ts new file mode 100644 index 00000000..726fe894 --- /dev/null +++ b/src/lib/plugins/hooks.ts @@ -0,0 +1,27 @@ +type HookEvent = string; +type HookHandler = (data: Record) => Promise; + +class HookManager { + private handlers = new Map>(); + + 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): Promise { + 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(); diff --git a/src/lib/plugins/store.ts b/src/lib/plugins/store.ts new file mode 100644 index 00000000..5a947ea0 --- /dev/null +++ b/src/lib/plugins/store.ts @@ -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; + configs: Record>; +} + +function defaultStore(): PluginStoreData { + const plugins = pluginRegistry.getAll(); + const enabled: Record = {}; + const configs: Record> = {}; + for (const p of plugins) { + enabled[p.id] = p.enabled ?? true; + if (p.configFields) { + const cfg: Record = {}; + for (const field of p.configFields) { + cfg[field.key] = field.default; + } + configs[p.id] = cfg; + } + } + return { enabled, configs }; +} + +async function load(): Promise { + 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 { + 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 { + const data = await load(); + return data.enabled[pluginId] ?? true; +} + +export async function setPluginEnabled( + pluginId: string, + enabled: boolean, +): Promise { + const data = await load(); + data.enabled[pluginId] = enabled; + await save(data); +} + +export async function getPluginConfig( + pluginId: string, +): Promise> { + const data = await load(); + return data.configs[pluginId] ?? {}; +} + +export async function setPluginConfig( + pluginId: string, + config: Record, +): Promise { + const data = await load(); + data.configs[pluginId] = { ...data.configs[pluginId], ...config }; + await save(data); +} + +export async function getAllPluginStates(): Promise<{ + enabled: Record; + configs: Record>; +}> { + return load(); +} diff --git a/src/lib/plugins/types.ts b/src/lib/plugins/types.ts index 4b308961..c4c88c7c 100644 --- a/src/lib/plugins/types.ts +++ b/src/lib/plugins/types.ts @@ -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; onUnload?: () => void | Promise; diff --git a/src/lib/webhooks/__tests__/service.test.ts b/src/lib/webhooks/__tests__/service.test.ts new file mode 100644 index 00000000..3228e4ef --- /dev/null +++ b/src/lib/webhooks/__tests__/service.test.ts @@ -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 = {}) { + 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, + }); + }); + }); +}); diff --git a/src/lib/webhooks/service.ts b/src/lib/webhooks/service.ts index 685b42f8..fcd5dcfa 100644 --- a/src/lib/webhooks/service.ts +++ b/src/lib/webhooks/service.ts @@ -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) => Promise>>(); + private inProcessHandlers = new Map< + WebhookEvent, + Set<(data: Record) => Promise> + >(); - on(event: WebhookEvent, handler: (data: Record) => Promise): void { + on( + event: WebhookEvent, + handler: (data: Record) => Promise, + ): void { if (!this.inProcessHandlers.has(event)) { this.inProcessHandlers.set(event, new Set()); } this.inProcessHandlers.get(event)!.add(handler); } - async fire(event: WebhookEvent, data: Record): Promise { + async fire( + event: WebhookEvent, + data: Record, + ): Promise { 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 { + private async deliver( + config: WebhookConfig, + payload: WebhookPayload, + retryCount = 0, + ): Promise { + const start = Date.now(); + let status = 0; + let responseBody = ""; + let success = false; + try { const body = JSON.stringify(payload); const headers: Record = { @@ -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 { + 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 {