feat : ask for noti
CI / Verify (push) Successful in 1m50s
CI / Build immutable images and deploy (push) Successful in 2m59s

This commit is contained in:
2026-10-08 02:23:59 +07:00 Unverified
parent 5812d99715
commit 9bc710c94b
34 changed files with 10913 additions and 201 deletions
+13 -2
View File
@@ -172,11 +172,22 @@ Replies expand inline with connector lines; replies to replies identify their re
The Thai comment UI supports image paste (Ctrl+V) with previews and loading skeletons.
Comment boards and the admin inbox receive Redis-backed SSE change events;
reads after each event recheck authorization. Reconnecting refreshes missed updates.
Signed-in authors can enable browser notifications after posting. New replies notify
only the direct parent author, excluding self-replies. Notification clicks open the
One site-entry prompt enables browser notifications for comments, Commission,
and future notification features. “ไม่ต้องถามอีก” remembers dismissal in this
browser; the header bell settings can reopen the prompt. Permission granted before
sign-in is connected to the account after sign-in. New replies notify
only the direct parent author, excluding self-replies. Subscribed, verified admins
also receive new top-level comments from other authors, with a link that opens the
comment in an admin conversation dialog. Admins can enable this from the inbox
using “แจ้งเตือนความคิดเห็นใหม่”. Reply notification clicks open the
root thread, load the relevant reply page, and highlight/focus the exact reply.
This uses the existing `WEB_PUSH_PUBLIC_KEY`, `WEB_PUSH_PRIVATE_KEY`, and
`WEB_PUSH_SUBJECT` configuration, with subscriptions in `comments.push_subscription`.
The header bell lists comments and Commission notifications from `public.notification`,
updates through one user-scoped SSE stream, and caps its red unread badge at `9+`.
Clicking an item marks it read; “อ่านทั้งหมด” marks the account's history read.
Read state persists across visits, admin notifications recheck current permissions,
and source deletion removes its notifications through cascading foreign keys.
Likes, dislikes, and admin hearts update optimistically and save in the background;
a failed save rolls the reaction back and shows an error.
+5 -3
View File
@@ -4,14 +4,16 @@ import { AdminHeader } from "@/components/admin/admin-header";
import { AdminCommentInbox } from "@/components/comments/admin-comment-inbox";
import { getCommentViewer, listComments, listCommentTargets } from "@/lib/comments/repository";
export default async function AdminCommentsPage({ searchParams }: { searchParams: Promise<{ target?: string }> }) {
export default async function AdminCommentsPage({ searchParams }: { searchParams: Promise<{ target?: string; comment?: string }> }) {
await connection();
const viewer = await getCommentViewer();
if (!viewer?.admin) redirect("/auth/login?next=%2Fadmin%2Fcomments");
const initialTarget = (await searchParams).target;
const query = await searchParams;
const initialTarget = query.target;
const initialComment = initialTarget && query.comment && /^[0-9a-f-]{36}$/i.test(query.comment) ? query.comment : undefined;
const [initial, targets] = await Promise.all([listComments({ viewer, inbox: true, target: initialTarget }), listCommentTargets()]);
return <><AdminHeader /><main className="mx-auto flex w-full max-w-7xl flex-col gap-6 p-4 sm:p-6 lg:p-8">
<header><h1 className="font-heading text-3xl font-semibold">ความคิดเห็น</h1><p className="mt-2 text-muted-foreground">ตอบกลับและจัดการความคิดเห็นจากทุกไกด์</p></header>
<AdminCommentInbox key={initialTarget ?? "all"} initialTarget={initialTarget} initial={initial} targets={targets} />
<AdminCommentInbox key={`${initialTarget ?? "all"}:${initialComment ?? ""}`} initialTarget={initialTarget} initialComment={initialComment} initial={initial} targets={targets} />
</main></>;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { after } from "next/server";
import { sendCommentReplyPush } from "@/lib/comments/push";
import { sendCommentPush } from "@/lib/comments/push";
import { getCommentViewer, listComments, requireCommentViewer } from "@/lib/comments/repository";
import { publishComment } from "@/lib/comments/publish";
import { errorResponse, HttpError, requireSameOrigin } from "@/lib/security/http";
@@ -21,7 +21,7 @@ export async function POST(request: Request) {
const target = new URL(request.url).searchParams.get("target");
if (!target) throw new HttpError(400, "target-required");
const result = await publishComment(request, viewer, { target });
after(async () => { await sendCommentReplyPush(result.id).catch(() => console.error("Comment reply notification failed")); });
after(async () => { await sendCommentPush(result.id).catch(() => console.error("Comment notification failed")); });
return Response.json(result, { status: 201 });
} catch (cause) { return errorResponse(cause); }
}
+11
View File
@@ -0,0 +1,11 @@
import { requireCommissionUser } from '@/lib/commission/server';
import { createRedisNamedEventResponse } from '@/lib/events/redis-stream';
import { errorResponse } from '@/lib/security/http';
import { limitRequest, trustedClientAddress } from '@/lib/security/rate-limit';
export async function GET(request: Request) {
try {
const user = await requireCommissionUser();
await limitRequest('stream-open', trustedClientAddress(request.headers), 60);
return await createRedisNamedEventResponse(`notifications:user:${user.id}`, 'changed', request.signal);
} catch (cause) { return errorResponse(cause); }
}
+27
View File
@@ -0,0 +1,27 @@
import * as z from 'zod';
import { requireCommissionUser } from '@/lib/commission/server';
import { listNotifications, readNotifications } from '@/lib/notifications/repository';
import { errorResponse, HttpError, readJson, requireSameOrigin } from '@/lib/security/http';
import { limitRequest } from '@/lib/security/rate-limit';
export async function GET(request: Request) {
try {
const user = await requireCommissionUser();
const raw = new URL(request.url).searchParams.get('before');
const parts = raw?.split('|');
const parsed = parts ? z.object({ time: z.iso.datetime(), id: z.uuid() }).safeParse({ time: parts[0], id: parts[1] }) : undefined;
if (raw && (!parsed?.success || parts?.length !== 2)) throw new HttpError(400, 'invalid-cursor');
const before = parsed?.success ? parsed.data : undefined;
return Response.json(await listNotifications(user, before), { headers: { 'Cache-Control': 'private, no-store' } });
} catch (cause) { return errorResponse(cause); }
}
export async function POST(request: Request) {
try {
requireSameOrigin(request);
const user = await requireCommissionUser();
await limitRequest('notification-read', user.id, 120);
const parsed = z.object({ id: z.uuid().optional() }).safeParse(await readJson(request, 1024));
if (!parsed.success) throw new HttpError(400, 'invalid-notification');
await readNotifications(user.id, parsed.data.id);
return new Response(null, { status: 204 });
} catch (cause) { return errorResponse(cause); }
}
+6 -1
View File
@@ -4,6 +4,8 @@ import { Anuphan, Geist_Mono } from "next/font/google";
import { DeploymentUpdateNotifier } from "@/components/deployment-update-notifier";
import { PageProgress } from "@/components/page-progress";
import { Toaster } from "@/components/ui/sonner";
import { NotificationProvider } from "@/components/notifications/notification-bell";
import { SitePushPrompt } from "@/components/notifications/site-push-prompt";
import { siteUrl } from "@/lib/site-url";
import "./globals.css";
@@ -47,7 +49,10 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
>
<body className="flex min-h-full flex-col">
<PageProgress />
{children}
<NotificationProvider>
{children}
<SitePushPrompt configured={Boolean(process.env.WEB_PUSH_PUBLIC_KEY && process.env.WEB_PUSH_PRIVATE_KEY && process.env.WEB_PUSH_SUBJECT)} />
</NotificationProvider>
<DeploymentUpdateNotifier deploymentId={deploymentId} />
<Toaster
richColors
+4 -4
View File
@@ -15,14 +15,13 @@ import {
import { AdminUnreadCount } from "@/components/comments/admin-unread-comments";
import { Button } from "@/components/ui/button";
import { SignOutButton } from "@/components/auth/sign-out-button";
import { CommissionPushEntryPrompt } from "@/components/commission/push-entry-prompt";
import { PublicAccountMenu } from "@/components/public/public-account-menu";
import { NotificationBell } from "@/components/notifications/notification-bell";
export function AdminHeader() {
const pathname = usePathname();
return (<>
{pathname.startsWith("/admin/commission") && <CommissionPushEntryPrompt scope="admin" />}
<header className="border-b bg-background/95 supports-backdrop-filter:backdrop-blur">
<div className="mx-auto flex min-h-16 w-full max-w-7xl flex-nowrap items-center gap-2 px-4 py-2 sm:px-6">
<Link
@@ -104,8 +103,9 @@ export function AdminHeader() {
>
<ExternalLinkIcon />
</Button>
<SignOutButton admin />
</nav>
<NotificationBell />
<PublicAccountMenu />
</div>
</header>
</>
+9 -3
View File
@@ -9,18 +9,20 @@ import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { FuzzyCombobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from "@/components/ui/combobox";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Empty, EmptyHeader, EmptyTitle, EmptyDescription } from "@/components/ui/empty";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import type { CommentPage } from "@/lib/comments/types";
import { characterRarityGradientClass } from "@/lib/catalog/rarity";
import { cn } from "@/lib/utils";
import { useMarkCommentsRead } from "./admin-unread-comments";
import { CommentPushPrompt } from "./comment-push-prompt";
import { CommentCard } from "./comment-card";
import { CommentCard, CommentThread } from "./comment-card";
import { CommentSkeleton } from "./comment-skeleton";
import { useCommentEvents } from "./use-comment-events";
import { useCommentFeed } from "./use-comment-feed";
type TargetOption = { value: string; label: string; image: string | null; rarity: number | null; characterKey: string | null };
export function AdminCommentInbox({ initial, targets, initialTarget }: { initial: CommentPage; targets: TargetOption[]; initialTarget?: string }) {
export function AdminCommentInbox({ initial, targets, initialTarget, initialComment }: { initial: CommentPage; targets: TargetOption[]; initialTarget?: string; initialComment?: string }) {
const [notificationOpen, setNotificationOpen] = useState(Boolean(initialComment));
const [target, setTarget] = useState<TargetOption | null>(targets.find((item) => item.value === initialTarget) ?? null);
const [status, setStatus] = useState("all");
const [answered, setAnswered] = useState("all");
@@ -31,7 +33,11 @@ export function AdminCommentInbox({ initial, targets, initialTarget }: { initial
const markRead = useMarkCommentsRead();
useEffect(() => { void markRead(target?.value ?? "all"); }, [markRead, target, feed.revision]);
return <div className="flex flex-col gap-6">
{feed.page.viewer && <CommentPushPrompt userId={feed.page.viewer.id} />}
{feed.page.viewer && <CommentPushPrompt userId={feed.page.viewer.id} admin />}
{initialComment && initialTarget && <Dialog open={notificationOpen} onOpenChange={setNotificationOpen}><DialogContent className="max-h-[90svh] overflow-y-auto sm:max-w-4xl">
<DialogHeader><DialogTitle>การสนทนา</DialogTitle><DialogDescription>ความคิดเห็นจากการแจ้งเตือนและคำตอบกลับ</DialogDescription></DialogHeader>
{notificationOpen && <CommentThread rootId={initialComment} expectedTarget={initialTarget} onChange={() => feed.refresh({ silent: true })} />}
</DialogContent></Dialog>}
<FieldGroup className="md:grid md:grid-cols-3">
<Field><FieldLabel>ไกด์หรือรอบ Stygian</FieldLabel>
<FuzzyCombobox items={targets} value={target} onValueChange={setTarget} itemToStringLabel={(item) => item.label} itemToStringValue={(item) => item.value}
+1 -1
View File
@@ -39,7 +39,7 @@ export function CommentBoard({ target, initial }: { target: string; initial: Com
return () => window.removeEventListener("hashchange", showThread);
}, []);
return <div className="flex flex-col gap-6 py-2">
{feed.page.viewer && <CommentPushPrompt userId={feed.page.viewer.id} />}
{feed.page.viewer && <CommentPushPrompt userId={feed.page.viewer.id} admin={feed.page.viewer.admin} />}
<div className="flex items-start gap-3"><Avatar className="size-10 shrink-0"><AvatarImage src={feed.page.viewer?.image ?? undefined} alt={feed.page.viewer?.name ?? "โปรไฟล์"} /><AvatarFallback>{feed.page.viewer?.name?.slice(0, 2) || "คุณ"}</AvatarFallback></Avatar><div className="min-w-0 flex-1">{feed.page.viewer ? <CommentComposer target={target} onSaved={() => void feed.refresh()} />
: <div className="flex flex-col items-start gap-2"><p className="text-sm text-muted-foreground">เข้าสู่ระบบเพื่อแสดงความคิดเห็น ตอบกลับ และกดถูกใจ</p><Button onClick={() => router.push(commentSignInHref())}>เข้าสู่ระบบ</Button></div>}</div></div>
<div className="order-first flex flex-wrap items-center gap-3"><h2 className="font-heading text-xl font-semibold">ความคิดเห็น</h2>
+1
View File
@@ -256,6 +256,7 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={item.targetImage} alt={`ภาพ Overview ของ ${item.targetName}`} loading="lazy" className="aspect-video w-full object-cover" />
</>}
{item.target.startsWith("stygian:") && <Badge variant="secondary" className="whitespace-normal">{item.targetName}</Badge>}
</Link>}
</div>
<Dialog open={history} onOpenChange={setHistory}><DialogContent className="sm:max-w-2xl"><DialogHeader><DialogTitle>ประวัติการแก้ไข</DialogTitle><DialogDescription>ดูข้อความและรูปภาพในแต่ละเวอร์ชันของความคิดเห็น</DialogDescription></DialogHeader>
-1
View File
@@ -96,7 +96,6 @@ export function CommentComposer({ target, replyTo, editing, onSaved, onCancel }:
});
setText(""); setFiles([]); setKept([]); setExpanded(false);
toast.success(editing ? "แก้ไขความคิดเห็นแล้ว" : "ส่งความคิดเห็นแล้ว");
window.dispatchEvent(new Event("comment:posted"));
onSaved();
} catch (cause) { setError(cause instanceof Error ? cause.message : "ไม่สามารถบันทึกความคิดเห็นได้"); }
finally { setPending(false); uploadRef.current = null; }
+5 -71
View File
@@ -1,72 +1,6 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { BellIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
export function CommentPushPrompt({ userId }: { userId: string }) {
const [publicKey, setPublicKey] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const [enabled, setEnabled] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const askedKey = `comment-push-asked-${userId}`;
const syncedKey = `comment-push-synced-${userId}`;
const syncPush = useCallback(async (key: string) => {
// Reuse the site's existing root service worker so ticket notifications keep working.
const registration = await navigator.serviceWorker.register("/commission-push-sw.js", { scope: "/" });
const subscription = await registration.pushManager.getSubscription() ?? await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key });
const response = await fetch("/api/comments/push-subscription", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(subscription.toJSON()) });
if (!response.ok) throw new Error("subscription-failed");
sessionStorage.setItem(syncedKey, "1");
}, [syncedKey]);
useEffect(() => {
if (!window.isSecureContext || !("Notification" in window) || !("PushManager" in window) || !("serviceWorker" in navigator)) return;
let active = true;
void fetch("/api/comments/push-subscription", { cache: "no-store" }).then(async (response) => {
if (!response.ok) return;
const config = await response.json() as { publicKey: string };
if (!active) return;
setPublicKey(config.publicKey);
if (Notification.permission === "granted") {
if (!sessionStorage.getItem(syncedKey)) await syncPush(config.publicKey);
if (active) setEnabled(true);
}
}).catch(() => undefined);
return () => { active = false; };
}, [userId, syncedKey, syncPush]);
useEffect(() => {
function ask() {
if (!publicKey || Notification.permission !== "default" || localStorage.getItem(askedKey)) return;
localStorage.setItem(askedKey, "1");
setOpen(true);
}
window.addEventListener("comment:posted", ask);
return () => window.removeEventListener("comment:posted", ask);
}, [publicKey, askedKey]);
async function enable() {
if (!publicKey) return;
setBusy(true); setError("");
try {
if (await Notification.requestPermission() !== "granted") {
setError("ยังไม่ได้อนุญาตการแจ้งเตือน เปิดสิทธิ์การแจ้งเตือนในการตั้งค่าเว็บไซต์ของเบราว์เซอร์");
return;
}
await syncPush(publicKey);
setEnabled(true); setOpen(false);
} catch { setError("เปิดการแจ้งเตือนไม่สำเร็จ กรุณาลองอีกครั้ง"); }
finally { setBusy(false); }
}
if (!publicKey || enabled) return null;
return <>
<Button variant="outline" size="sm" className="self-start" onClick={() => setOpen(true)}><BellIcon data-icon="inline-start" />แจ้งเตือนเมื่อมีคนตอบกลับ</Button>
<Dialog open={open} onOpenChange={setOpen}><DialogContent>
<DialogHeader><DialogTitle>รับการแจ้งเตือนเมื่อมีคนตอบกลับ?</DialogTitle><DialogDescription>เราจะแจ้งเตือนเมื่อมีคนตอบกลับความคิดเห็นของคุณ แม้ปิดแท็บแล้ว กดที่การแจ้งเตือนเพื่อไปยังคำตอบนั้น</DialogDescription></DialogHeader>
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
<DialogFooter><Button variant="outline" disabled={busy} onClick={() => setOpen(false)}>ไว้ก่อน</Button><Button disabled={busy} onClick={() => void enable()}>เปิดการแจ้งเตือน</Button></DialogFooter>
</DialogContent></Dialog>
</>;
'use client';
import { BellIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
export function CommentPushPrompt({ admin = false }: { userId: string; admin?: boolean }) {
return <Button variant="outline" size="sm" className="self-start" onClick={() => window.dispatchEvent(new Event('notifications:enable'))}><BellIcon data-icon="inline-start" />{admin ? 'แจ้งเตือนความคิดเห็นใหม่' : 'แจ้งเตือนเมื่อมีคนตอบกลับ'}</Button>;
}
+1 -18
View File
@@ -1,26 +1,9 @@
"use client";
export async function subscribeCommissionPush(scope: "customer" | "admin", publicKey: string) {
if (await Notification.requestPermission() !== "granted") return false;
await syncCommissionPush(scope, publicKey);
return true;
}
export async function syncCommissionPush(scope: "customer" | "admin", publicKey: string) {
const registration = await navigator.serviceWorker.register("/commission-push-sw.js", { scope: "/" });
const subscription = await registration.pushManager.getSubscription() ??
await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: publicKey });
const response = await fetch("/api/commission/push-subscription", { method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...subscription.toJSON(), scope }) });
if (!response.ok) throw new Error("Could not save push subscription");
sessionStorage.setItem(`commission-push-synced-${scope}`, "1");
}
export async function unsubscribeCommissionPush() {
sessionStorage.removeItem("commission-push-synced-customer");
sessionStorage.removeItem("commission-push-synced-admin");
for (const key of Object.keys(sessionStorage)) if (key.startsWith("comment-push-synced-")) sessionStorage.removeItem(key);
for (const key of Object.keys(sessionStorage)) if (key.startsWith("comment-push-synced-") || key.startsWith("site-push-synced-")) sessionStorage.removeItem(key);
if (!("serviceWorker" in navigator)) return;
const registration = await navigator.serviceWorker.getRegistration("/");
const subscription = await registration?.pushManager.getSubscription();
@@ -1,66 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { subscribeCommissionPush, syncCommissionPush } from "./push-client";
type PushRequest = { scope: "customer" | "admin"; publicKey: string };
const askedKey = "commission-push-asked";
export function CommissionPushEntryPrompt({ scope }: { scope: "customer" | "admin" }) {
const [request, setRequest] = useState<PushRequest | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!window.isSecureContext ||
!("Notification" in window) || !("PushManager" in window) || !("serviceWorker" in navigator) ||
Notification.permission === "denied" ||
(Notification.permission === "default" && sessionStorage.getItem(askedKey)) ||
(Notification.permission === "granted" && (sessionStorage.getItem(`commission-push-synced-${scope}`) ||
(scope === "customer" && sessionStorage.getItem("commission-push-synced-admin"))))) return;
let active = true;
void fetch("/api/commission/push-subscription", { cache: "no-store" }).then(async (response) => {
if (!response.ok) return;
const config = await response.json() as { publicKey: string; canAdmin: boolean };
if (!active || (scope === "admin" && !config.canAdmin)) return;
const subscriptionScope = config.canAdmin ? "admin" : scope;
if (Notification.permission === "granted") {
await syncCommissionPush(subscriptionScope, config.publicKey);
return;
}
if (Notification.permission !== "default") return;
sessionStorage.setItem(askedKey, "1");
setRequest({ scope: subscriptionScope, publicKey: config.publicKey });
}).catch(() => undefined);
return () => { active = false; };
}, [scope]);
async function enable() {
if (!request) return;
setBusy(true);
setError("");
try {
await subscribeCommissionPush(request.scope, request.publicKey);
setRequest(null);
} catch {
setError("เปิดการแจ้งเตือนไม่สำเร็จ กรุณาลองอีกครั้ง");
} finally { setBusy(false); }
}
return <Dialog open={request !== null} onOpenChange={(open) => { if (!open) setRequest(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>รับการแจ้งเตือนข้อความใหม่?</DialogTitle>
<DialogDescription>เมื่อมีข้อความใหม่ใน Ticket เราจะแจ้งเตือนพร้อมชื่อผู้ส่งและข้อความ แม้ปิดแท็บแล้ว</DialogDescription>
</DialogHeader>
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button type="button" variant="outline" disabled={busy} onClick={() => setRequest(null)}>ไว้ก่อน</Button>
<Button type="button" disabled={busy} onClick={() => void enable()}>เปิดการแจ้งเตือน</Button>
</DialogFooter>
</DialogContent>
</Dialog>;
}
-2
View File
@@ -80,7 +80,6 @@ import {
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { parseCommissionMessageEvent } from "@/lib/commission/message-event";
import { CommissionPushEntryPrompt } from "./push-entry-prompt";
import { keepCommissionTitle } from "./keep-title";
type ChatMessage = {
@@ -626,7 +625,6 @@ export function CommissionTicketChat({
return (
<>
{!admin && <CommissionPushEntryPrompt scope="customer" />}
<AlertDialog open={closedAlertOpen} onOpenChange={setClosedAlertOpen}>
<AlertDialogContent>
<AlertDialogHeader>
@@ -0,0 +1,82 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
import Link from 'next/link';
import { BellIcon, SettingsIcon } from 'lucide-react';
import { authClient } from '@/lib/auth/client';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
type Item = { id: string; title: string; body: string; url: string; readAt: string | null };
type Feed = { items: Item[]; unread: number; nextCursor: string | null };
const empty: Feed = { items: [], unread: 0, nextCursor: null };
type State = Feed & { signedIn: boolean; busy: boolean; error: string; refresh: (more?: boolean) => Promise<void>; read: (id?: string) => Promise<void> };
const Context = createContext<State>({ ...empty, signedIn: false, busy: false, error: '', refresh: async () => {}, read: async () => {} });
export function NotificationProvider({ children }: { children: ReactNode }) {
const { data } = authClient.useSession();
const userId = data?.user.id;
const [feed, setFeed] = useState<Feed>(empty);
const [owner, setOwner] = useState<string | undefined>(undefined);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const current = useRef(feed);
useEffect(() => { current.current = feed; }, [feed]);
const sequence = useRef(0);
const invalidate = useCallback(() => { ++sequence.current; }, []);
const refresh = useCallback(async (more = false) => {
if (!userId) return;
const request = ++sequence.current;
const cursor = more ? current.current.nextCursor : null;
setBusy(true);
try {
const response = await fetch(`/api/notifications${cursor ? `?before=${encodeURIComponent(cursor)}` : ''}`, { cache: 'no-store' });
if (!response.ok) throw new Error('โหลดการแจ้งเตือนไม่สำเร็จ');
const result = await response.json() as Feed;
if (sequence.current !== request) return;
setFeed(previous => ({ ...result, items: more ? [...previous.items, ...result.items.filter(item => !previous.items.some(old => old.id === item.id))] : result.items }));
setOwner(userId); setError('');
} catch (cause) { if (sequence.current === request) setError(cause instanceof Error ? cause.message : 'โหลดการแจ้งเตือนไม่สำเร็จ'); }
finally { if (sequence.current === request) setBusy(false); }
}, [userId]);
useEffect(() => {
if (!userId) return;
void Promise.resolve().then(() => refresh());
const source = new EventSource('/api/notifications/events');
source.addEventListener('changed', () => void refresh());
source.addEventListener('open', () => void refresh());
return () => { invalidate(); source.close(); };
}, [userId, refresh, invalidate]);
const read = useCallback(async (id?: string) => {
if (!userId) return;
setFeed(previous => ({ ...previous, unread: id ? Math.max(0, previous.unread - (previous.items.some(item => item.id === id && !item.readAt) ? 1 : 0)) : 0, items: previous.items.map(item => !id || item.id === id ? { ...item, readAt: new Date().toISOString() } : item) }));
try {
const response = await fetch('/api/notifications', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(id ? { id } : {}) });
if (!response.ok) throw new Error('บันทึกการอ่านไม่สำเร็จ');
} finally { await refresh(); }
}, [userId, refresh]);
return <Context value={{ ...(owner === userId ? feed : empty), signedIn: Boolean(userId), busy, error, refresh, read }}>{children}</Context>;
}
export function NotificationBell() {
const state = useContext(Context);
const [open, setOpen] = useState(false);
return <Popover open={open} onOpenChange={value => { setOpen(value); if (value) void state.refresh(); }}>
<PopoverTrigger render={<Button variant="ghost" size="icon" className="relative shrink-0" aria-label={`การแจ้งเตือน${state.unread ? ` ใหม่ ${state.unread} รายการ` : ''}`} />}><BellIcon />
{state.unread > 0 && <Badge variant="destructive" className="absolute -right-1 -top-1 min-w-5 justify-center rounded-full px-1">{state.unread > 9 ? '9+' : state.unread}</Badge>}
</PopoverTrigger>
<PopoverContent align="end" className="w-96 max-w-[calc(100vw-2rem)]">
<div className="flex items-center justify-between gap-2"><h2 className="font-semibold">การแจ้งเตือน</h2><Button variant="ghost" size="icon-sm" aria-label="ตั้งค่าการแจ้งเตือน" onClick={() => { setOpen(false); window.dispatchEvent(new Event('notifications:enable')); }}><SettingsIcon /></Button></div>
{!state.signedIn ? <Link href="/auth/login" className="text-sm text-primary underline">เข้าสู่ระบบเพื่อดูการแจ้งเตือน</Link> : <>
{state.unread > 0 && <Button variant="link" size="sm" className="self-start" onClick={() => void state.read().catch(() => undefined)}>อ่านทั้งหมด</Button>}
<div className="flex max-h-[60svh] flex-col gap-1 overflow-y-auto">
{state.items.map(item => <Link key={item.id} href={item.url} prefetch={false} className={cn('rounded-lg p-3', !item.readAt ? 'bg-accent' : 'hover:bg-muted')} onClick={() => { setOpen(false); void state.read(item.id).catch(() => undefined); }}><p className="text-sm font-medium">{!item.readAt && <span className="mr-1 text-destructive" aria-label="ยังไม่ได้อ่าน">●</span>}{item.title}</p><p className="line-clamp-2 break-words text-xs text-muted-foreground">{item.body}</p></Link>)}
{state.busy && !state.items.length && <div role="status" aria-label="กำลังโหลดการแจ้งเตือน" className="flex flex-col gap-2"><Skeleton className="h-16 w-full" /><Skeleton className="h-16 w-full" /></div>}
{!state.busy && !state.items.length && !state.error && <p className="py-6 text-center text-sm text-muted-foreground">ยังไม่มีการแจ้งเตือน</p>}
{state.error && <p role="alert" className="text-sm text-destructive">{state.error}</p>}
{state.nextCursor && <Button variant="ghost" disabled={state.busy} onClick={() => void state.refresh(true)}>แสดงเพิ่มเติม</Button>}
</div>
</>}
</PopoverContent>
</Popover>;
}
+18
View File
@@ -0,0 +1,18 @@
'use client';
// All notification features share one browser permission and root service worker.
export async function syncSitePush(userId: string) {
const configResponse = await fetch('/api/commission/push-subscription', { cache: 'no-store' });
if (!configResponse.ok) throw new Error('push-config-failed');
const config = await configResponse.json() as { publicKey: string; canAdmin: boolean };
const registration = await navigator.serviceWorker.register('/commission-push-sw.js', { scope: '/' });
const subscription = await registration.pushManager.getSubscription() ?? await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: config.publicKey });
const data = subscription.toJSON();
const registrations = [
{ url: '/api/comments/push-subscription', body: data },
{ url: '/api/commission/push-subscription', body: { ...data, scope: config.canAdmin ? 'admin' : 'customer' } },
];
const responses = await Promise.all(registrations.map(feature => fetch(feature.url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(feature.body) })));
if (responses.some(response => !response.ok)) throw new Error('push-subscription-failed');
sessionStorage.setItem(`site-push-synced-${userId}`, '1');
}
@@ -0,0 +1,60 @@
'use client';
import { useEffect, useId, useState } from 'react';
import { authClient } from '@/lib/auth/client';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Field, FieldLabel } from '@/components/ui/field';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { syncSitePush } from './push-client';
const dismissedKey = 'site-push-dont-ask';
const askedKey = 'site-push-asked';
export function SitePushPrompt({ configured }: { configured: boolean }) {
const { data } = authClient.useSession();
const userId = data?.user.id;
const id = useId();
const [open, setOpen] = useState(false);
const [dontAsk, setDontAsk] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (!configured || !window.isSecureContext || !('Notification' in window) || !('PushManager' in window) || !('serviceWorker' in navigator)) return;
let active = true;
function show() { setError(''); setOpen(true); }
window.addEventListener('notifications:enable', show);
void Promise.resolve().then(async () => {
if (!active) return;
if (Notification.permission === 'granted') {
if (userId && !sessionStorage.getItem(`site-push-synced-${userId}`)) await syncSitePush(userId);
} else if (Notification.permission === 'default' && !localStorage.getItem(dismissedKey) && !sessionStorage.getItem(askedKey)) {
sessionStorage.setItem(askedKey, '1');
setOpen(true);
}
}).catch(() => undefined);
return () => { active = false; window.removeEventListener('notifications:enable', show); };
}, [configured, userId]);
function close() {
if (dontAsk) localStorage.setItem(dismissedKey, '1');
setOpen(false);
}
async function enable() {
setBusy(true); setError('');
try {
if (await Notification.requestPermission() !== 'granted') {
setError('ยังไม่ได้อนุญาตการแจ้งเตือน เปิดสิทธิ์ในการตั้งค่าเว็บไซต์ของเบราว์เซอร์');
return;
}
if (userId) await syncSitePush(userId);
close();
} catch { setError('เปิดการแจ้งเตือนไม่สำเร็จ กรุณาลองอีกครั้ง'); }
finally { setBusy(false); }
}
return <Dialog open={open} onOpenChange={value => { if (!value && !busy) close(); }}><DialogContent>
<DialogHeader><DialogTitle>รับการแจ้งเตือนจากเว็บไซต์?</DialogTitle><DialogDescription>รับข้อความ Commission คำตอบกลับความคิดเห็น และการแจ้งเตือนอื่นๆ ของเว็บไซต์ในอนาคต แม้ปิดแท็บแล้ว</DialogDescription></DialogHeader>
{!userId && <p className="text-sm text-muted-foreground">หลังเข้าสู่ระบบ เราจะเชื่อมการแจ้งเตือนกับบัญชีของคุณ</p>}
<Field orientation="horizontal"><Checkbox id={id} checked={dontAsk} onCheckedChange={setDontAsk} disabled={busy} /><FieldLabel htmlFor={id}>ไม่ต้องถามอีก</FieldLabel></Field>
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
<DialogFooter><Button variant="outline" disabled={busy} onClick={close}>ไว้ก่อน</Button><Button disabled={busy} onClick={() => void enable()}>เปิดการแจ้งเตือน</Button></DialogFooter>
</DialogContent></Dialog>;
}
+2
View File
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { VisitorPresence } from "@/components/public/visitor-presence";
import { PublicNavigation } from "@/components/public/public-navigation";
import { PublicAccountMenu } from "@/components/public/public-account-menu";
import { NotificationBell } from "@/components/notifications/notification-bell";
import { getAdminSession } from "@/lib/auth/server";
import { HttpError } from "@/lib/security/http";
@@ -45,6 +46,7 @@ export function SiteHeader({ editHref = "/admin", guideId }: { editHref?: string
<Suspense fallback={null}>
<AdminEditLink editHref={editHref} />
</Suspense>
<NotificationBell />
<PublicAccountMenu />
</div>
</header>
+21
View File
@@ -793,3 +793,24 @@ export const commentReadState = commentsSchema.table("read_state", {
target: text("target").notNull(),
seenAt: timestamp("seen_at", { withTimezone: true }).defaultNow().notNull(),
}, (table) => [primaryKey({ columns: [table.userId, table.target] })]);
export const notifications = pgTable("notification", {
id: uuid("id").defaultRandom().primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
eventKey: text("event_key").notNull(),
kind: text("kind").notNull(),
title: text("title").notNull(),
body: text("body").notNull(),
url: text("url").notNull(),
adminOnly: boolean("admin_only").default(false).notNull(),
commentId: uuid("comment_id").references(() => comments.id, { onDelete: "cascade" }),
messageId: uuid("message_id").references(() => commissionMessages.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
readAt: timestamp("read_at", { withTimezone: true }),
}, (table) => [
uniqueIndex("notification_user_event_unique").on(table.userId, table.eventKey),
index("notification_user_time_idx").on(table.userId, table.createdAt, table.id),
index("notification_user_unread_idx").on(table.userId).where(sql`${table.readAt} is null`),
index("notification_comment_idx").on(table.commentId),
index("notification_message_idx").on(table.messageId),
]);
+21
View File
@@ -0,0 +1,21 @@
CREATE TABLE "notification" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"event_key" text NOT NULL,
"kind" text NOT NULL,
"title" text NOT NULL,
"body" text NOT NULL,
"url" text NOT NULL,
"admin_only" boolean DEFAULT false NOT NULL,
"comment_id" uuid,
"message_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"read_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "notification" ADD CONSTRAINT "notification_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "notification" ADD CONSTRAINT "notification_comment_id_comment_id_fk" FOREIGN KEY ("comment_id") REFERENCES "comments"."comment"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "notification" ADD CONSTRAINT "notification_message_id_message_id_fk" FOREIGN KEY ("message_id") REFERENCES "commission"."message"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "notification_user_event_unique" ON "notification" USING btree ("user_id","event_key");--> statement-breakpoint
CREATE INDEX "notification_user_time_idx" ON "notification" USING btree ("user_id","created_at","id");--> statement-breakpoint
CREATE INDEX "notification_user_unread_idx" ON "notification" USING btree ("user_id") WHERE "notification"."read_at" is null;
+2
View File
@@ -0,0 +1,2 @@
CREATE INDEX "notification_comment_idx" ON "notification" USING btree ("comment_id");--> statement-breakpoint
CREATE INDEX "notification_message_idx" ON "notification" USING btree ("message_id");
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -547,6 +547,20 @@
"when": 1791395148797,
"tag": "0077_reflective_chimera",
"breakpoints": true
},
{
"idx": 78,
"version": "7",
"when": 1791400048838,
"tag": "0078_slow_black_bolt",
"breakpoints": true
},
{
"idx": 79,
"version": "7",
"when": 1791400712755,
"tag": "0079_luxuriant_thunderball",
"breakpoints": true
}
]
}
+42 -16
View File
@@ -1,33 +1,59 @@
import "server-only";
import { and, eq } from "drizzle-orm";
import { and, eq, inArray, ne, sql } from "drizzle-orm";
import webPush from "web-push";
import { getDb } from "@/db";
import { comments, commentPushSubscriptions, commentRevisions, users } from "@/db/schema";
import { pushPublicKey, validPushEndpoint } from "@/lib/commission/push";
import { createNotifications } from "@/lib/notifications/repository";
import { authorizeComment } from "./repository";
export async function sendCommentReplyPush(id: string) {
if (!pushPublicKey()) return;
export async function sendCommentPush(id: string) {
const [reply] = await getDb().select().from(comments).where(eq(comments.id, id)).limit(1);
if (!reply?.replyToId || reply.hidden || reply.deletedAt) return;
const [parent] = await getDb().select().from(comments).where(eq(comments.id, reply.replyToId)).limit(1);
if (!parent || parent.authorId === reply.authorId || parent.hidden || parent.deletedAt) return;
const [recipient] = await getDb().select().from(users).where(eq(users.id, parent.authorId)).limit(1);
if (!recipient || recipient.banned) return;
let destination;
try {
({ destination } = await authorizeComment(id, { id: recipient.id, admin: recipient.role === "admin" && recipient.emailVerified }));
} catch { return; }
if (!reply || reply.hidden || reply.deletedAt) return;
let recipients: { id: string; admin: boolean }[];
let url: string;
let title: string;
if (reply.replyToId) {
const [parent] = await getDb().select().from(comments).where(eq(comments.id, reply.replyToId)).limit(1);
if (!parent || parent.authorId === reply.authorId || parent.hidden || parent.deletedAt) return;
const [recipient] = await getDb().select().from(users).where(eq(users.id, parent.authorId)).limit(1);
if (!recipient || recipient.banned) return;
let destination;
try {
({ destination } = await authorizeComment(id, { id: recipient.id, admin: recipient.role === "admin" && recipient.emailVerified }));
} catch { return; }
recipients = [{ id: recipient.id, admin: false }];
url = `${destination.href}${destination.href.includes("?") ? "&" : "?"}reply=${id}#comment-${reply.rootId}`;
title = "ตอบกลับความคิดเห็นของคุณ";
} else {
recipients = (await getDb().select({ id: users.id }).from(users)
.where(and(eq(users.role, "admin"), eq(users.emailVerified, true), ne(users.id, reply.authorId), sql`${users.banned} is not true`)))
.map((user) => ({ ...user, admin: true }));
if (!recipients.length) return;
let destination;
try {
({ destination } = await authorizeComment(id, { id: recipients[0].id, admin: true }));
} catch { return; }
if (!destination.writable) return;
url = `/admin/comments?target=${encodeURIComponent(destination.target)}&comment=${id}`;
title = `แสดงความคิดเห็นใหม่ใน ${destination.name}`;
}
const [author] = await getDb().select({ name: users.name }).from(users).where(eq(users.id, reply.authorId)).limit(1);
const [revision] = await getDb().select({ text: commentRevisions.text }).from(commentRevisions)
.where(and(eq(commentRevisions.commentId, id), eq(commentRevisions.version, reply.version))).limit(1);
const subscriptions = await getDb().select().from(commentPushSubscriptions).where(eq(commentPushSubscriptions.userId, recipient.id));
const notificationTitle = `${author?.name ?? "ผู้ใช้"} ${title}`;
const body = revision?.text.slice(0, 300) || "ส่งรูปภาพ";
await createNotifications(recipients.map(recipient => ({ userId: recipient.id, adminOnly: recipient.admin, url })), {
eventKey: `comment:${id}`, kind: reply.replyToId ? "comment_reply" : "comment_new", title: notificationTitle, body, commentId: id,
});
if (!pushPublicKey()) return;
const subscriptions = await getDb().select().from(commentPushSubscriptions).where(inArray(commentPushSubscriptions.userId, recipients.map(recipient => recipient.id)));
if (!subscriptions.length) return;
webPush.setVapidDetails(process.env.WEB_PUSH_SUBJECT!, process.env.WEB_PUSH_PUBLIC_KEY!, process.env.WEB_PUSH_PRIVATE_KEY!);
const payload = JSON.stringify({ id: `comment-${id}`, title: `${author?.name ?? "ผู้ใช้"} ตอบกลับความคิดเห็นของคุณ`,
body: revision?.text.slice(0, 300) || "ส่งรูปภาพ", icon: "/icon/nav/Comment.webp",
url: `${destination.href}${destination.href.includes("?") ? "&" : "?"}reply=${id}#comment-${reply.rootId}` });
const payload = JSON.stringify({ id: `comment-${id}`, title: notificationTitle,
body, icon: "/icon/nav/Comment.webp",
url });
for (let offset = 0; offset < subscriptions.length; offset += 10) {
await Promise.all(subscriptions.slice(offset, offset + 10).map(async (subscription) => {
if (!validPushEndpoint(subscription.endpoint)) return;
+10 -2
View File
@@ -5,7 +5,8 @@ import { notifyCommentChange } from "./events";
import { and, asc, desc, eq, inArray, isNull, lt, or, sql, type SQL } from "drizzle-orm";
import { alias } from "drizzle-orm/pg-core";
import { getDb, type Database } from "@/db";
import { commentThreads, comments, commentRevisions, commentAttachments, commentRevisionAttachments, commentReactions, catalogCharacters, guides, stygianSchedules, users } from "@/db/schema";
import { commentThreads, comments, commentRevisions, commentAttachments, commentRevisionAttachments, commentReactions, catalogCharacters, guides, stygianSchedules, users, notifications } from "@/db/schema";
import { notifyNotificationChange } from "@/lib/notifications/events";
import { getMediaStorage, publicMediaUrl } from "@/lib/media/storage";
import { getCustomerSession } from "@/lib/auth/server";
import { isAuthorizedAdmin } from "@/lib/auth/authorization";
@@ -14,7 +15,7 @@ import { HttpError } from "@/lib/security/http";
import { decodeCursor, encodeCursor, parseTarget, uuidSchema } from "./validation";
import type { CommentHistoryItem, CommentImage, CommentItem, CommentPage, CommentViewer } from "./types";
type Reader = Pick<Database, "select" | "insert" | "update" | "delete" | "execute">;
type Reader = Pick<Database, "select" | "selectDistinct" | "insert" | "update" | "delete" | "execute">;
export async function getCommentViewer(): Promise<CommentViewer | null> {
const session = await getCustomerSession();
if (!session) return null;
@@ -199,11 +200,14 @@ export async function withCommentLock<T>(id: string, viewer: CommentViewer, task
export async function mutateComment(id: string, viewer: CommentViewer, action: "delete" | "reaction" | "moderation" | "heart", value?: number | boolean) {
let target = "";
let deletedImages: { objectKey: string }[] = [];
let notificationUsers: { userId: string }[] = [];
const result = await withCommentLock(id, viewer, async (tx, context) => {
target = context.destination.target;
const c = context.comment;
if (action === "moderation") {
requireCommentAdmin(viewer);
notificationUsers = await tx.selectDistinct({ userId: notifications.userId }).from(notifications)
.innerJoin(comments, eq(comments.id, notifications.commentId)).where(or(eq(comments.id, id), eq(comments.rootId, id)));
await tx.update(comments).set({ hidden: value as boolean }).where(eq(comments.id, id));
const [actor] = await tx.select({ id: users.id, name: users.name }).from(users).where(eq(users.id, viewer.id));
await writeAuditLog(tx, auditActor(actor), { action: value ? "comment.hidden" : "comment.restored", targetType: "comment", targetId: id,
@@ -221,6 +225,8 @@ export async function mutateComment(id: string, viewer: CommentViewer, action: "
) select id from descendants`;
deletedImages = await tx.select({ objectKey: commentAttachments.objectKey }).from(commentAttachments)
.where(sql`${commentAttachments.commentId} in (${descendants})`);
notificationUsers = await tx.selectDistinct({ userId: notifications.userId }).from(notifications)
.where(sql`${notifications.commentId} in (${descendants})`);
// Delete the entire subtree in one statement so self-referencing foreign keys remain valid.
// Revisions, attachment metadata, revision links, and reactions cascade automatically.
await tx.delete(comments).where(sql`${comments.id} in (${descendants})`);
@@ -234,6 +240,8 @@ export async function mutateComment(id: string, viewer: CommentViewer, action: "
.onConflictDoUpdate({ target: [commentReactions.commentId, commentReactions.userId], set: { value: value as number } });
}
});
for (let offset = 0; offset < notificationUsers.length; offset += 10)
await Promise.all(notificationUsers.slice(offset, offset + 10).map(user => notifyNotificationChange(user.userId)));
await notifyCommentChange(target);
if (deletedImages.length) {
try {
+8 -1
View File
@@ -1,7 +1,8 @@
import "server-only";
import { and, eq, ne, or } from "drizzle-orm";
import { and, eq, ne, or, sql } from "drizzle-orm";
import webPush from "web-push";
import { createNotifications } from "@/lib/notifications/repository";
import { getDb } from "@/db";
import { commissionPushSubscriptions, users } from "@/db/schema";
@@ -25,6 +26,12 @@ export async function sendCommissionMessagePush(message: {
id: string; ticketId: string; ticketTitle: string; customerId: string; authorId: string;
authorName: string; text: string | null;
}) {
const recipients = await getDb().select({ id: users.id, role: users.role, verified: users.emailVerified }).from(users)
.where(and(ne(users.id, message.authorId), sql`${users.banned} is not true`, or(eq(users.id, message.customerId), and(eq(users.role, "admin"), eq(users.emailVerified, true)))));
await createNotifications(recipients.map(user => {
const admin = user.role === "admin" && user.verified && user.id !== message.customerId;
return { userId: user.id, adminOnly: admin, url: `/${admin ? "admin/commission" : "commission/tickets"}/${message.ticketId}` };
}), { eventKey: `commission:${message.id}`, kind: "commission_message", title: `${message.authorName} - ${message.ticketTitle}`, body: message.text?.slice(0, 300) || "ส่งรูปภาพ", messageId: message.id });
if (!pushPublicKey()) return;
const subscriptions = await getDb().select({ subscription: commissionPushSubscriptions })
.from(commissionPushSubscriptions)
+6
View File
@@ -0,0 +1,6 @@
import 'server-only';
import { getRedisClient, redisEventChannel } from '@/lib/redis/client';
export async function notifyNotificationChange(userId: string) {
try { await (await getRedisClient()).publish(redisEventChannel(`notifications:user:${userId}`), 'changed'); }
catch { /* Saved notifications remain available after reconnect. */ }
}
+41
View File
@@ -0,0 +1,41 @@
import 'server-only';
import { and, desc, eq, isNull, or, sql } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core';
import { getDb } from '@/db';
import { notifications, comments, commentThreads, guides } from '@/db/schema';
import { notifyNotificationChange } from './events';
type Recipient = { userId: string; adminOnly?: boolean; url: string };
type Message = { eventKey: string; kind: string; title: string; body: string; commentId?: string; messageId?: string };
export async function createNotifications(recipients: Recipient[], message: Message) {
for (let offset = 0; offset < recipients.length; offset += 100) {
const inserted = await getDb().insert(notifications).values(recipients.slice(offset, offset + 100).map(recipient => ({ ...recipient, ...message })))
.onConflictDoNothing({ target: [notifications.userId, notifications.eventKey] }).returning({ userId: notifications.userId });
for (let start = 0; start < inserted.length; start += 10) await Promise.all(inserted.slice(start, start + 10).map(row => notifyNotificationChange(row.userId)));
}
}
const root = alias(comments, 'notification_root');
export async function listNotifications(user: { id: string; role: string | null; emailVerified: boolean }, before?: { time: string; id: string }) {
const admin = user.role === 'admin' && user.emailVerified;
const visible = and(eq(notifications.userId, user.id), admin ? undefined : and(eq(notifications.adminOnly, false), or(isNull(notifications.commentId), and(
eq(comments.hidden, false), sql`coalesce(${root.hidden}, false) = false`,
or(isNull(commentThreads.guideId), and(eq(guides.isPublic, true), isNull(guides.trashedAt))),
))));
const query = () => getDb().select({ notification: notifications, cursorTime: sql<string>`to_char(${notifications.createdAt} at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')` }).from(notifications)
.leftJoin(comments, eq(comments.id, notifications.commentId)).leftJoin(root, eq(root.id, comments.rootId))
.leftJoin(commentThreads, eq(commentThreads.id, comments.threadId)).leftJoin(guides, eq(guides.id, commentThreads.guideId));
const [rows, count] = await Promise.all([
query().where(and(visible, before ? sql`(${notifications.createdAt}, ${notifications.id}) < (${before.time}::timestamptz, ${before.id}::uuid)` : undefined)).orderBy(desc(notifications.createdAt), desc(notifications.id)).limit(21),
getDb().select({ count: sql<number>`count(*)::int` }).from(notifications)
.leftJoin(comments, eq(comments.id, notifications.commentId)).leftJoin(root, eq(root.id, comments.rootId))
.leftJoin(commentThreads, eq(commentThreads.id, comments.threadId)).leftJoin(guides, eq(guides.id, commentThreads.guideId))
.where(and(visible, isNull(notifications.readAt))),
]);
const items = rows.slice(0, 20).map(row => row.notification);
const last = rows[19];
return { items, unread: count[0]?.count ?? 0, nextCursor: rows.length > 20 ? `${last.cursorTime}|${last.notification.id}` : null };
}
export async function readNotifications(userId: string, id?: string) {
await getDb().update(notifications).set({ readAt: sql`now()` }).where(and(eq(notifications.userId, userId), isNull(notifications.readAt), id ? eq(notifications.id, id) : undefined));
await notifyNotificationChange(userId);
}
@@ -22,6 +22,14 @@ describe("reply notification navigation", () => {
expect(close).toHaveBeenCalled();
expect(self.clients.openWindow).toHaveBeenCalledWith(`https://guide.example.test${url}`);
});
it("opens the admin inbox at the notified comment", async () => {
const { self, handlers } = worker();
const url = "/admin/comments?target=stygian%3A123&comment=comment-id";
let task: Promise<unknown> | undefined;
handlers.notificationclick({ notification: { close: vi.fn(), data: { url } }, waitUntil: (promise: Promise<unknown>) => { task = promise; } });
await task;
expect(self.clients.openWindow).toHaveBeenCalledWith(`https://guide.example.test${url}`);
});
it("uses the comment icon and rejects off-site notification destinations", async () => {
const { self, handlers } = worker();
const message = { id: "comment-id", title: "Reply", body: "Text", icon: "/icon/nav/Comment.webp", url: "/guide/comment#comment-root" };
+110 -8
View File
@@ -4,6 +4,7 @@ import sharp from "sharp";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("server-only", () => ({}));
vi.mock("@/lib/notifications/events", () => ({ notifyNotificationChange: vi.fn() }));
vi.mock("next/server", () => ({ after: vi.fn() }));
const pushTransport = vi.hoisted(() => ({ sendNotification: vi.fn(), setVapidDetails: vi.fn() }));
vi.mock("web-push", () => ({ default: pushTransport }));
@@ -24,7 +25,10 @@ const commentsRoute = await import("@/app/api/comments/route");
const itemRoute = await import("@/app/api/comments/[id]/route");
const actionRoute = await import("@/app/api/comments/[id]/[action]/route");
const inboxRoute = await import("@/app/api/admin/comments/route");
const { sendCommentReplyPush } = await import("@/lib/comments/push");
const { sendCommentPush } = await import("@/lib/comments/push");
const { notifyNotificationChange } = await import("@/lib/notifications/events");
const { createNotifications, listNotifications, readNotifications } = await import("@/lib/notifications/repository");
const { sendCommissionMessagePush } = await import("@/lib/commission/push");
const { unreadCommentCounts, markCommentsRead } = await import("@/lib/comments/unread");
const subscriptionRoute = await import("@/app/api/comments/push-subscription/route");
const imageRoute = await import("@/app/api/comments/images/[id]/route");
@@ -73,6 +77,7 @@ describeDatabase("guide comments against PostgreSQL", { timeout: 30000 }, () =>
pushTransport.sendNotification.mockReset().mockResolvedValue(undefined);
pushTransport.setVapidDetails.mockReset();
await getDb().delete(schema.commentPushSubscriptions).where(inArray(schema.commentPushSubscriptions.userId, [author.id, other.id, admin.id]));
await getDb().delete(schema.notifications).where(inArray(schema.notifications.userId, [author.id, other.id, admin.id]));
await getDb().delete(schema.commentReadState).where(inArray(schema.commentReadState.userId, [author.id, other.id, admin.id]));
await getDb().delete(schema.commentThreads).where(inArray(schema.commentThreads.guideId, [guideId, otherGuideId, privateGuideId]));
await getDb().delete(schema.commentThreads).where(inArray(schema.commentThreads.scheduleId, [scheduleId, scheduleId + 1]));
@@ -359,7 +364,7 @@ describeDatabase("guide comments against PostgreSQL", { timeout: 30000 }, () =>
await saveSubscription(other.id, `${subscription.endpoint}-other`);
const root = await post("Root");
const reply = await publishComment(request("Reply", { reply: root.id }), other, { target });
await sendCommentReplyPush(reply.id);
await sendCommentPush(reply.id);
expect(pushTransport.sendNotification).toHaveBeenCalledTimes(1);
expect(pushTransport.sendNotification.mock.calls[0][0].endpoint).toBe(subscription.endpoint);
expect(JSON.parse(pushTransport.sendNotification.mock.calls[0][1])).toMatchObject({ body: "Reply", url: `/comment-test-${guideId}/comment?reply=${reply.id}#comment-${root.id}` });
@@ -367,31 +372,128 @@ describeDatabase("guide comments against PostgreSQL", { timeout: 30000 }, () =>
const nested = await post("Nested", { reply: reply.id });
expect((await listComments({ viewer: admin, rootId: root.id })).items.find((item) => item.id === reply.id)?.hasReplies).toBe(true);
expect((await listComments({ viewer: admin, rootId: root.id })).items.find((item) => item.id === nested.id)?.hasReplies).toBe(false);
await sendCommentReplyPush(nested.id);
await sendCommentPush(nested.id);
expect(pushTransport.sendNotification).toHaveBeenCalledTimes(1);
expect(pushTransport.sendNotification.mock.calls[0][0].endpoint).toBe(`${subscription.endpoint}-other`);
expect(JSON.parse(pushTransport.sendNotification.mock.calls[0][1]).url).toContain(`reply=${nested.id}#comment-${root.id}`);
pushTransport.sendNotification.mockClear();
await sendCommentReplyPush((await post("Self", { reply: root.id })).id);
await sendCommentReplyPush(root.id);
await sendCommentPush((await post("Self", { reply: root.id })).id);
expect(pushTransport.sendNotification).not.toHaveBeenCalled();
});
it("notifies subscribed admins about new comments, checks current roles, and skips the author", async () => {
await saveSubscription(admin.id, `${subscription.endpoint}-admin`);
await saveSubscription(other.id, `${subscription.endpoint}-reader`);
const root = await post("New root");
const adminCalls = () => pushTransport.sendNotification.mock.calls.filter(([recipient]) => recipient.endpoint === `${subscription.endpoint}-admin`);
await sendCommentPush(root.id);
expect(adminCalls()).toHaveLength(1);
expect(JSON.parse(adminCalls()[0][1])).toMatchObject({ body: "New root", icon: "/icon/nav/Comment.webp", url: `/admin/comments?target=${encodeURIComponent(target)}&comment=${root.id}` });
expect(pushTransport.sendNotification.mock.calls.some(([recipient]) => recipient.endpoint === `${subscription.endpoint}-reader`)).toBe(false);
pushTransport.sendNotification.mockClear();
await sendCommentPush((await publishComment(request("Admin's own"), admin, { target })).id);
expect(adminCalls()).toHaveLength(0);
try {
for (const state of [{ role: "user" }, { role: "admin", emailVerified: false }, { role: "admin", emailVerified: true, banned: true }]) {
await getDb().update(schema.users).set(state).where(eq(schema.users.id, admin.id));
await sendCommentPush(root.id);
expect(adminCalls()).toHaveLength(0);
}
} finally {
await getDb().update(schema.users).set({ role: "admin", emailVerified: true, banned: false }).where(eq(schema.users.id, admin.id));
}
}, 60000);
it("links Stygian admin notifications and suppresses removed comments and expired subscriptions", async () => {
const endpoint = `${subscription.endpoint}-admin`;
await saveSubscription(admin.id, endpoint);
const root = await publishComment(request("New Stygian comment"), author, { target: `stygian:${scheduleId}` });
const adminCalls = () => pushTransport.sendNotification.mock.calls.filter(([recipient]) => recipient.endpoint === endpoint);
await sendCommentPush(root.id);
expect(JSON.parse(adminCalls()[0][1]).url).toBe(`/admin/comments?target=stygian%3A${scheduleId}&comment=${root.id}`);
pushTransport.sendNotification.mockClear();
await mutateComment(root.id, admin, "moderation", true);
await sendCommentPush(root.id);
expect(adminCalls()).toHaveLength(0);
await mutateComment(root.id, admin, "moderation", false);
pushTransport.sendNotification.mockImplementation(async (recipient) => { if (recipient.endpoint === endpoint) throw { statusCode: 410 }; });
await sendCommentPush(root.id);
expect(await getDb().select().from(schema.commentPushSubscriptions).where(eq(schema.commentPushSubscriptions.endpoint, endpoint))).toHaveLength(0);
await saveSubscription(admin.id, endpoint);
await mutateComment(root.id, author, "delete");
pushTransport.sendNotification.mockClear();
await sendCommentPush(root.id);
expect(adminCalls()).toHaveLength(0);
}, 60000);
it("does not push hidden or deleted replies and removes expired browser subscriptions", async () => {
await saveSubscription(author.id);
const root = await post("Root");
const reply = await publishComment(request("Reply", { reply: root.id }), other, { target });
await mutateComment(root.id, admin, "moderation", true);
await sendCommentReplyPush(reply.id);
await sendCommentPush(reply.id);
expect(pushTransport.sendNotification).not.toHaveBeenCalled();
await mutateComment(root.id, admin, "moderation", false);
pushTransport.sendNotification.mockRejectedValueOnce({ statusCode: 410 });
await sendCommentReplyPush(reply.id);
await sendCommentPush(reply.id);
expect(await getDb().select().from(schema.commentPushSubscriptions).where(eq(schema.commentPushSubscriptions.userId, author.id))).toHaveLength(0);
await mutateComment(root.id, author, "delete");
pushTransport.sendNotification.mockClear();
await sendCommentReplyPush(reply.id);
await sendCommentPush(reply.id);
expect(pushTransport.sendNotification).not.toHaveBeenCalled();
});
it("persists comment notifications without push permission and keeps history scoped to its owner", async () => {
const root = await post("Bell root");
await sendCommentPush(root.id);
const adminUser = { ...admin, role: "admin", emailVerified: true };
const authorUser = { ...author, role: "user", emailVerified: true };
expect((await listNotifications(adminUser)).items[0]).toMatchObject({ commentId: root.id, kind: "comment_new", readAt: null });
expect((await listNotifications(authorUser)).unread).toBe(0);
const reply = await publishComment(request("Bell reply", { reply: root.id }), other, { target });
const key = process.env.WEB_PUSH_PUBLIC_KEY;
delete process.env.WEB_PUSH_PUBLIC_KEY;
try { await sendCommentPush(reply.id); await sendCommentPush(reply.id); } finally { process.env.WEB_PUSH_PUBLIC_KEY = key; }
const inbox = await listNotifications(authorUser);
expect(inbox.unread).toBe(1); expect(inbox.items).toHaveLength(1);
await readNotifications(other.id, inbox.items[0].id);
expect((await listNotifications(authorUser)).unread).toBe(1);
await readNotifications(author.id, inbox.items[0].id);
expect((await listNotifications(authorUser)).unread).toBe(0);
expect((await listNotifications({ ...adminUser, role: "user" })).items).toHaveLength(0);
await mutateComment(root.id, admin, "moderation", true);
expect((await listNotifications(authorUser)).items).toHaveLength(0);
await mutateComment(root.id, admin, "moderation", false);
vi.mocked(notifyNotificationChange).mockClear();
await mutateComment(root.id, author, "delete");
expect(notifyNotificationChange).toHaveBeenCalledWith(author.id);
expect(notifyNotificationChange).toHaveBeenCalledWith(admin.id);
expect((await listNotifications(adminUser)).items).toHaveLength(0);
}, 60000);
it("pages notification history without dropping notifications sharing a timestamp", async () => {
const user = { ...author, role: "user", emailVerified: true };
for (let index = 0; index < 25; index++) await createNotifications([{ userId: author.id, url: "/" }], { eventKey: `fixture:${index}`, kind: "future", title: "Future notification", body: "Text" });
await getDb().update(schema.notifications).set({ createdAt: new Date("2026-01-01T00:00:00Z") }).where(eq(schema.notifications.userId, author.id));
const first = await listNotifications(user);
const [time, id] = first.nextCursor!.split("|");
const second = await listNotifications(user, { time, id });
expect(first.items).toHaveLength(20); expect(second.items).toHaveLength(5);
expect(new Set([...first.items, ...second.items].map(item => item.id)).size).toBe(25);
await readNotifications(author.id);
expect((await listNotifications(user)).unread).toBe(0);
}, 60000);
it("records commission notifications for customers and admins without browser subscriptions", async () => {
const ticketId = randomUUID(), messageId = randomUUID(), checkoutId = randomUUID();
await getDb().insert(schema.commissionCheckouts).values({ id: checkoutId, userId: author.id, amountBaht: 20, request: { teams: [], weapons: [], constellations: [{ characterKey: "test", levels: [0] }] } });
await getDb().insert(schema.commissionTickets).values({ id: ticketId, checkoutId, userId: author.id, title: "Notification ticket" });
await getDb().insert(schema.commissionMessages).values({ id: messageId, ticketId, authorId: other.id, text: "Commission bell" });
try {
await sendCommissionMessagePush({ id: messageId, ticketId, customerId: author.id, authorId: other.id, authorName: "Reader", ticketTitle: "Ticket", text: "Commission bell" });
expect((await listNotifications({ ...author, role: "user", emailVerified: true })).items[0]).toMatchObject({ kind: "commission_message", url: `/commission/tickets/${ticketId}` });
expect((await listNotifications({ ...admin, role: "admin", emailVerified: true })).items[0]).toMatchObject({ kind: "commission_message", url: `/admin/commission/${ticketId}` });
expect((await listNotifications({ ...other, role: "user", emailVerified: true })).items).toHaveLength(0);
} finally {
await getDb().delete(schema.commissionMessages).where(eq(schema.commissionMessages.id, messageId));
await getDb().delete(schema.commissionTickets).where(eq(schema.commissionTickets.id, ticketId));
await getDb().delete(schema.commissionCheckouts).where(eq(schema.commissionCheckouts.id, checkoutId));
}
}, 60000);
it("counts unread comments per guide for admins and clears only the selected guide", async () => {
await markCommentsRead(admin, "all");
await post("Unread");
+30
View File
@@ -0,0 +1,30 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { HttpError } from '@/lib/security/http';
vi.mock('server-only', () => ({}));
const mocks = vi.hoisted(() => ({ user: vi.fn(), list: vi.fn(), read: vi.fn(), limit: vi.fn() }));
vi.mock('@/lib/commission/server', () => ({ requireCommissionUser: mocks.user }));
vi.mock('@/lib/notifications/repository', () => ({ listNotifications: mocks.list, readNotifications: mocks.read }));
vi.mock('@/lib/security/rate-limit', () => ({ limitRequest: mocks.limit }));
const { GET, POST } = await import('@/app/api/notifications/route');
beforeEach(() => {
vi.clearAllMocks(); process.env.BETTER_AUTH_URL = 'https://guide.example.test';
mocks.user.mockResolvedValue({ id: 'owner', role: 'user', emailVerified: true });
mocks.list.mockResolvedValue({ items: [], unread: 0, nextCursor: null });
});
describe('notification endpoints', () => {
it('requires an authenticated account and disables caching', async () => {
const request = new Request('https://guide.example.test/api/notifications');
expect((await GET(request)).headers.get('cache-control')).toBe('private, no-store');
mocks.user.mockRejectedValueOnce(new HttpError(401, 'unauthorized'));
expect((await GET(request)).status).toBe(401);
});
it('validates cursors and uses the current account for mark-read', async () => {
expect((await GET(new Request('https://guide.example.test/api/notifications?before=bad'))).status).toBe(400);
const id = '00000000-0000-4000-8000-000000000001';
const request = (origin: string) => new Request('https://guide.example.test/api/notifications', { method: 'POST', headers: { Origin: origin, 'Content-Type': 'application/json' }, body: JSON.stringify({ id, userId: 'victim' }) });
expect((await POST(request('https://evil.example'))).status).toBe(403);
expect(mocks.read).not.toHaveBeenCalled();
expect((await POST(request('https://guide.example.test'))).status).toBe(204);
expect(mocks.read).toHaveBeenCalledWith('owner', id);
});
});
+27
View File
@@ -0,0 +1,27 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { syncSitePush } from '@/components/notifications/push-client';
afterEach(() => vi.unstubAllGlobals());
describe('shared site push subscriptions', () => {
for (const admin of [false, true]) it(`registers comments and commission with one ${admin ? 'admin' : 'customer'} browser subscription`, async () => {
const json = { endpoint: 'https://fcm.googleapis.com/fcm/send/test', keys: { auth: 'test', p256dh: 'test' } };
const register = vi.fn().mockResolvedValue({ pushManager: { getSubscription: async () => ({ toJSON: () => json }) } });
const setItem = vi.fn();
const fetcher = vi.fn(async (_url: string, options?: RequestInit) => options?.method === "POST" ? new Response(null, { status: 204 }) : Response.json({ publicKey: 'key', canAdmin: admin }));
vi.stubGlobal('navigator', { serviceWorker: { register } });
vi.stubGlobal('fetch', fetcher); vi.stubGlobal('sessionStorage', { setItem });
await syncSitePush('user');
expect(register).toHaveBeenCalledTimes(1);
expect(fetcher.mock.calls[1][0]).toBe('/api/comments/push-subscription');
expect(JSON.parse(fetcher.mock.calls[1][1]!.body as string)).toEqual(json);
expect(JSON.parse(fetcher.mock.calls[2][1]!.body as string)).toEqual({ ...json, scope: admin ? 'admin' : 'customer' });
expect(setItem).toHaveBeenCalledWith('site-push-synced-user', '1');
});
it('keeps failed registration retryable', async () => {
const setItem = vi.fn();
vi.stubGlobal('navigator', { serviceWorker: { register: async () => ({ pushManager: { getSubscription: async () => ({ toJSON: () => ({ endpoint: 'endpoint' }) }) } }) } });
vi.stubGlobal('sessionStorage', { setItem });
vi.stubGlobal('fetch', vi.fn(async (url: string, options?: RequestInit) => options?.method === "POST" ? new Response(null, { status: url.includes('comments') ? 503 : 204 }) : Response.json({ publicKey: 'key', canAdmin: false })));
await expect(syncSitePush('user')).rejects.toThrow('push-subscription-failed');
expect(setItem).not.toHaveBeenCalled();
});
});