feat : ask for noti
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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></>;
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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,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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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. */ }
|
||||
}
|
||||
@@ -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" };
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user