feat(commission) : add push notifications and unread ticket titles
CI / Verify (push) Successful in 1m48s
CI / Build immutable images and deploy (push) Successful in 2m47s

This commit is contained in:
2026-10-02 11:51:05 +07:00 Unverified
parent bb5c571837
commit 3473b55768
27 changed files with 4480 additions and 24 deletions
+4
View File
@@ -35,6 +35,10 @@ DISCORD_BOT_TOKEN=replace-with-discord-bot-token
DISCORD_CHANNEL_ID=replace-with-private-channel-id DISCORD_CHANNEL_ID=replace-with-private-channel-id
DISCORD_LOG_CHANNEL_ID=1547193755772125184 DISCORD_LOG_CHANNEL_ID=1547193755772125184
COMMISSION_DISCORD_WEBHOOK_URL=replace-with-commission-discord-webhook-url COMMISSION_DISCORD_WEBHOOK_URL=replace-with-commission-discord-webhook-url
# Generate a stable pair with: bunx web-push generate-vapid-keys --json
WEB_PUSH_PUBLIC_KEY=replace-with-vapid-public-key
WEB_PUSH_PRIVATE_KEY=replace-with-vapid-private-key
WEB_PUSH_SUBJECT=mailto:[email protected]
# S3-compatible media storage; guide and commission uploads use public CDN objects. # S3-compatible media storage; guide and commission uploads use public CDN objects.
S3_ENDPOINT=https://s3.example.internal S3_ENDPOINT=https://s3.example.internal
+10
View File
@@ -212,6 +212,9 @@ COMMISSION_PROMPTPAY_VALUE
COMMISSION_RECEIVER_ACCOUNT_NUMBER COMMISSION_RECEIVER_ACCOUNT_NUMBER
SLIP2GO_VERIFY_URL SLIP2GO_VERIFY_URL
SLIP2GO_API_SECRET SLIP2GO_API_SECRET
WEB_PUSH_PUBLIC_KEY
WEB_PUSH_PRIVATE_KEY
WEB_PUSH_SUBJECT
DISCORD_BOT_TOKEN DISCORD_BOT_TOKEN
DISCORD_CHANNEL_ID DISCORD_CHANNEL_ID
``` ```
@@ -235,6 +238,13 @@ view it. Each checkout accepts payment for one hour after creation. The outbox
worker removes expired unpaid checkouts; paid tickets remain available. Apply worker removes expired unpaid checkouts; paid tickets remain available. Apply
the commission database migration before enabling checkout. the commission database migration before enabling checkout.
Commission message notifications require a stable VAPID key pair and subject
(`bunx web-push generate-vapid-keys --json`). Set the three `WEB_PUSH_*` values
in the web deployment secret and apply the push-subscription migration before
enabling notifications. Users opt in from their ticket page. Browser push can
arrive after tabs close; on iPhone and iPad, the site must be added to the Home
Screen before the browser offers push permission.
Traefik must overwrite `X-Real-Ip` for every request, and the web pods must be Traefik must overwrite `X-Real-Ip` for every request, and the web pods must be
reachable only through Traefik before enabling the configured client IP based reachable only through Traefik before enabling the configured client IP based
rate limits. The NetworkPolicy assumes Traefik runs in `kube-system` with the rate limits. The NetworkPolicy assumes Traefik runs in `kube-system` with the
+8 -3
View File
@@ -6,6 +6,7 @@ import { getDb } from "@/db";
import { commissionCheckouts, commissionTickets, users } from "@/db/schema"; import { commissionCheckouts, commissionTickets, users } from "@/db/schema";
import { AdminHeader } from "@/components/admin/admin-header"; import { AdminHeader } from "@/components/admin/admin-header";
import { CommissionLiveRefresh } from "@/components/commission/live-refresh"; import { CommissionLiveRefresh } from "@/components/commission/live-refresh";
import { CommissionPushPermission } from "@/components/commission/push-permission";
import { AdminCommissionAvailabilityControl } from "@/components/commission/admin-availability-control"; import { AdminCommissionAvailabilityControl } from "@/components/commission/admin-availability-control";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -15,7 +16,8 @@ import { getCommissionAvailability } from "@/lib/commission/availability";
export const instant = false; export const instant = false;
export default async function AdminCommissionPage({ searchParams }: PageProps<"/admin/commission">) { export default async function AdminCommissionPage({ searchParams }: PageProps<"/admin/commission">) {
await connection(); await connection();
if (!(await getAdminSession())) redirect("/admin/login"); const session = await getAdminSession();
if (!session) redirect("/admin/login");
const query = await searchParams; const query = await searchParams;
const rawPage = Number(query.page ?? 1); const rawPage = Number(query.page ?? 1);
const page = Number.isInteger(rawPage) && rawPage > 0 ? Math.min(rawPage, 10000) : 1; const page = Number.isInteger(rawPage) && rawPage > 0 ? Math.min(rawPage, 10000) : 1;
@@ -25,9 +27,12 @@ export default async function AdminCommissionPage({ searchParams }: PageProps<"/
.innerJoin(users, eq(commissionTickets.userId, users.id)) .innerJoin(users, eq(commissionTickets.userId, users.id))
.orderBy(desc(commissionTickets.status), desc(commissionTickets.updatedAt), desc(commissionTickets.id)) .orderBy(desc(commissionTickets.status), desc(commissionTickets.updatedAt), desc(commissionTickets.id))
.limit(51).offset((page - 1) * 50), getCommissionAvailability()]); .limit(51).offset((page - 1) * 50), getCommissionAvailability()]);
return <div className="min-h-svh"><AdminHeader /><CommissionLiveRefresh endpoint="/api/commission/events?scope=admin" /> return <div className="min-h-svh"><AdminHeader /><CommissionLiveRefresh endpoint="/api/commission/events?scope=admin" userId={session.user.id} />
<main className="mx-auto flex max-w-5xl flex-col gap-5 p-4 py-10 sm:p-8"> <main className="mx-auto flex max-w-5xl flex-col gap-5 p-4 py-10 sm:p-8">
<h1 className="font-heading text-3xl font-semibold">Commission tickets</h1> <div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="font-heading text-3xl font-semibold">Commission tickets</h1>
<CommissionPushPermission scope="admin" />
</div>
<AdminCommissionAvailabilityControl key={`${availability.isOpen}:${availability.closureMessage}`} <AdminCommissionAvailabilityControl key={`${availability.isOpen}:${availability.closureMessage}`}
{...availability} /> {...availability} />
{tickets.length === 0 && <p>ยังไม่มี Ticket</p>} {tickets.length === 0 && <p>ยังไม่มี Ticket</p>}
@@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const requireCommissionUser = vi.fn();
const limitRequest = vi.fn();
const insert = vi.fn();
const values = vi.fn();
const onConflictDoUpdate = vi.fn();
vi.mock("@/lib/commission/server", () => ({ requireCommissionUser }));
vi.mock("@/lib/security/rate-limit", () => ({ limitRequest }));
vi.mock("@/db", () => ({ getDb: () => ({ insert }) }));
vi.mock("@/lib/commission/push", () => ({
pushPublicKey: () => "public-key",
validPushEndpoint: (value: string) => value.startsWith("https://fcm.googleapis.com/"),
}));
const { POST } = await import("./route");
const subscription = { endpoint: "https://fcm.googleapis.com/fcm/send/token",
keys: { p256dh: "a".repeat(87), auth: "b".repeat(22) } };
function request(scope: "customer" | "admin", endpoint = subscription.endpoint) {
return new Request("https://guide.sudloh.com/api/commission/push-subscription", {
method: "POST", headers: { Origin: "https://guide.sudloh.com", "Content-Type": "application/json" },
body: JSON.stringify({ ...subscription, endpoint, scope }),
});
}
describe("commission push subscriptions", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.BETTER_AUTH_URL = "https://guide.sudloh.com";
requireCommissionUser.mockResolvedValue({ id: "customer-1", role: "user", emailVerified: true });
insert.mockReturnValue({ values });
values.mockReturnValue({ onConflictDoUpdate });
onConflictDoUpdate.mockResolvedValue(undefined);
});
it("rejects customer registration for admin notifications", async () => {
expect((await POST(request("admin"))).status).toBe(403);
expect(insert).not.toHaveBeenCalled();
});
it("rejects a push endpoint outside browser push services", async () => {
expect((await POST(request("customer", "https://internal.example/push"))).status).toBe(400);
expect(insert).not.toHaveBeenCalled();
});
it("stores a subscription for the authenticated customer", async () => {
expect((await POST(request("customer"))).status).toBe(204);
expect(values).toHaveBeenCalledWith(expect.objectContaining({
endpoint: subscription.endpoint, userId: "customer-1", scope: "customer",
}));
});
});
@@ -0,0 +1,57 @@
import { and, eq } from "drizzle-orm";
import * as z from "zod";
import { getDb } from "@/db";
import { commissionPushSubscriptions } from "@/db/schema";
import { pushPublicKey, validPushEndpoint } from "@/lib/commission/push";
import { requireCommissionUser } from "@/lib/commission/server";
import { errorResponse, HttpError, readJson, requireSameOrigin } from "@/lib/security/http";
import { limitRequest } from "@/lib/security/rate-limit";
const subscriptionSchema = z.object({
endpoint: z.string().max(2048),
keys: z.object({ p256dh: z.string().regex(/^[A-Za-z0-9_-]{50,200}$/),
auth: z.string().regex(/^[A-Za-z0-9_-]{10,100}$/) }),
scope: z.enum(["customer", "admin"]),
});
export async function GET() {
try {
await requireCommissionUser();
const publicKey = pushPublicKey();
if (!publicKey) throw new HttpError(503, "push-not-configured");
return Response.json({ publicKey }, { headers: { "Cache-Control": "no-store" } });
} catch (cause) { return errorResponse(cause); }
}
export async function POST(request: Request) {
try {
requireSameOrigin(request);
const user = await requireCommissionUser();
if (!pushPublicKey()) throw new HttpError(503, "push-not-configured");
await limitRequest("commission-push-subscription", user.id, 60);
const parsed = subscriptionSchema.safeParse(await readJson(request, 4096));
if (!parsed.success || !validPushEndpoint(parsed.data.endpoint))
throw new HttpError(400, "invalid-push-subscription");
const { endpoint, keys, scope } = parsed.data;
if (scope === "admin" && (user.role !== "admin" || !user.emailVerified))
throw new HttpError(403, "forbidden");
await getDb().insert(commissionPushSubscriptions).values({
endpoint, userId: user.id, scope, p256dh: keys.p256dh, auth: keys.auth,
}).onConflictDoUpdate({ target: commissionPushSubscriptions.endpoint,
set: { userId: user.id, scope, p256dh: keys.p256dh, auth: keys.auth } });
return new Response(null, { status: 204 });
} catch (cause) { return errorResponse(cause); }
}
export async function DELETE(request: Request) {
try {
requireSameOrigin(request);
const user = await requireCommissionUser();
const parsed = z.object({ endpoint: z.string().max(2048) }).safeParse(await readJson(request, 4096));
if (!parsed.success) throw new HttpError(400, "invalid-push-subscription");
await getDb().delete(commissionPushSubscriptions).where(and(
eq(commissionPushSubscriptions.endpoint, parsed.data.endpoint),
eq(commissionPushSubscriptions.userId, user.id)));
return new Response(null, { status: 204 });
} catch (cause) { return errorResponse(cause); }
}
@@ -1,6 +1,9 @@
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { after } from "next/server";
import { getDb } from "@/db"; import { getDb } from "@/db";
import { commissionMessages, commissionTickets } from "@/db/schema"; import { commissionMessages, commissionTickets } from "@/db/schema";
import type { CommissionMessageEvent } from "@/lib/commission/message-event";
import { sendCommissionMessagePush } from "@/lib/commission/push";
import { notifyCommission } from "@/lib/commission/server"; import { notifyCommission } from "@/lib/commission/server";
import { authorizeTicket } from "@/lib/commission/tickets"; import { authorizeTicket } from "@/lib/commission/tickets";
import { getMediaStorage } from "@/lib/media/storage"; import { getMediaStorage } from "@/lib/media/storage";
@@ -48,7 +51,15 @@ export async function POST(request: Request, context: RouteContext<"/api/commiss
if (imageObjectKey) await (await getMediaStorage()).delete(imageObjectKey).catch(() => undefined); if (imageObjectKey) await (await getMediaStorage()).delete(imageObjectKey).catch(() => undefined);
throw cause; throw cause;
} }
await notifyCommission(id, ticket.userId); const message: CommissionMessageEvent = { messageId, ticketId: id,
ticketTitle: ticket.title || `Ticket #${id.slice(0, 8)}`,
authorId: user.id, authorName: user.name, text: body || null };
await notifyCommission(id, ticket.userId, `message:${JSON.stringify(message)}`);
after(() => sendCommissionMessagePush({ id: messageId, ticketId: id,
ticketTitle: message.ticketTitle, customerId: ticket.userId, authorId: user.id,
authorName: user.name, text: body || null }).catch(() => {
console.error("Commission push delivery failed");
}));
return Response.json({ ok: true, messageId }, { status: 201 }); return Response.json({ ok: true, messageId }, { status: 201 });
}); });
} catch (cause) { return errorResponse(cause); } } catch (cause) { return errorResponse(cause); }
+3 -1
View File
@@ -5,6 +5,7 @@ import { connection } from "next/server";
import { getDb } from "@/db"; import { getDb } from "@/db";
import { commissionCheckouts, commissionTickets } from "@/db/schema"; import { commissionCheckouts, commissionTickets } from "@/db/schema";
import { CommissionLiveRefresh } from "@/components/commission/live-refresh"; import { CommissionLiveRefresh } from "@/components/commission/live-refresh";
import { CommissionPushPermission } from "@/components/commission/push-permission";
import { CommissionSignOut } from "@/components/commission/sign-out"; import { CommissionSignOut } from "@/components/commission/sign-out";
import { SiteHeader } from "@/components/public/site-header"; import { SiteHeader } from "@/components/public/site-header";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -28,13 +29,14 @@ export default async function CommissionTicketsPage() {
gt(commissionCheckouts.createdAt, new Date(checkoutNow() - CHECKOUT_DURATION_MS)))) gt(commissionCheckouts.createdAt, new Date(checkoutNow() - CHECKOUT_DURATION_MS))))
.orderBy(desc(commissionCheckouts.createdAt)).limit(20), .orderBy(desc(commissionCheckouts.createdAt)).limit(20),
]); ]);
return <div className="min-h-svh"><SiteHeader /><CommissionLiveRefresh endpoint="/api/commission/events" /> return <div className="min-h-svh"><SiteHeader /><CommissionLiveRefresh endpoint="/api/commission/events" userId={session.user.id} />
<main className="mx-auto flex max-w-4xl flex-col gap-5 p-4 py-10 sm:p-8"> <main className="mx-auto flex max-w-4xl flex-col gap-5 p-4 py-10 sm:p-8">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<h1 className="font-heading text-3xl font-semibold">Ticket ของฉัน</h1> <h1 className="font-heading text-3xl font-semibold">Ticket ของฉัน</h1>
<CommissionSignOut /> <CommissionSignOut />
</div> </div>
<Link href="/commission" className="text-sm underline">สร้างคำขอเพิ่ม</Link> <Link href="/commission" className="text-sm underline">สร้างคำขอเพิ่ม</Link>
<CommissionPushPermission scope="customer" />
{pending.length > 0 && <h2 className="text-xl font-semibold">รอชำระเงิน</h2>} {pending.length > 0 && <h2 className="text-xl font-semibold">รอชำระเงิน</h2>}
{pending.map((checkout) => <Link key={checkout.id} href={`/commission/pay/${checkout.id}`}> {pending.map((checkout) => <Link key={checkout.id} href={`/commission/pay/${checkout.id}`}>
<Card><CardHeader><CardTitle>รอชำระเงิน - ฿{checkout.amount}</CardTitle></CardHeader> <Card><CardHeader><CardTitle>รอชำระเงิน - ฿{checkout.amount}</CardTitle></CardHeader>
+12
View File
@@ -0,0 +1,12 @@
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
id: "/",
name: "Buzz Sheet",
short_name: "Buzz Sheet",
start_url: "/commission/tickets",
display: "standalone",
icons: [{ src: "/icon.png", sizes: "any", type: "image/png" }],
};
}
+28
View File
@@ -48,6 +48,7 @@
"sonner": "^2.0.8", "sonner": "^2.0.8",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"web-push": "^3.6.7",
"zod": "^4.4.3", "zod": "^4.4.3",
}, },
"devDependencies": { "devDependencies": {
@@ -58,6 +59,7 @@
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@types/sanitize-html": "^2.16.1", "@types/sanitize-html": "^2.16.1",
"@types/web-push": "^3.6.4",
"@vitest/coverage-v8": "^4.1.11", "@vitest/coverage-v8": "^4.1.11",
"drizzle-kit": "0.31.11", "drizzle-kit": "0.31.11",
"eslint": "^9", "eslint": "^9",
@@ -707,6 +709,8 @@
"@types/validate-npm-package-name": ["@types/[email protected]", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], "@types/validate-npm-package-name": ["@types/[email protected]", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="],
"@types/web-push": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="],
"@types/ws": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], "@types/ws": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/[email protected]", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.68.0", "@typescript-eslint/type-utils": "8.68.0", "@typescript-eslint/utils": "8.68.0", "@typescript-eslint/visitor-keys": "8.68.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.68.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/[email protected]", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.68.0", "@typescript-eslint/type-utils": "8.68.0", "@typescript-eslint/utils": "8.68.0", "@typescript-eslint/visitor-keys": "8.68.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.68.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q=="],
@@ -797,6 +801,8 @@
"acorn-jsx": ["[email protected]", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], "acorn-jsx": ["[email protected]", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"agent-base": ["[email protected]", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ajv": ["[email protected]", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], "ajv": ["[email protected]", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
"ajv-formats": ["[email protected]", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], "ajv-formats": ["[email protected]", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
@@ -829,6 +835,8 @@
"arraybuffer.prototype.slice": ["[email protected]", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], "arraybuffer.prototype.slice": ["[email protected]", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
"asn1.js": ["[email protected]", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="],
"assertion-error": ["[email protected]", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "assertion-error": ["[email protected]", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"ast-types": ["[email protected]", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], "ast-types": ["[email protected]", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
@@ -859,6 +867,8 @@
"better-call": ["[email protected]", "", { "dependencies": { "@better-auth/utils": "^0.5.0", "@better-fetch/fetch": "^1.3.1", "rou3": "^0.9.1", "set-cookie-parser": "^3.1.2" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA=="], "better-call": ["[email protected]", "", { "dependencies": { "@better-auth/utils": "^0.5.0", "@better-fetch/fetch": "^1.3.1", "rou3": "^0.9.1", "set-cookie-parser": "^3.1.2" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA=="],
"bn.js": ["[email protected]", "", {}, "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ=="],
"body-parser": ["[email protected]", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], "body-parser": ["[email protected]", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="],
"brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="], "brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="],
@@ -867,6 +877,8 @@
"browserslist": ["[email protected]", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="], "browserslist": ["[email protected]", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="],
"buffer-equal-constant-time": ["[email protected]", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
"buffer-from": ["[email protected]", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "buffer-from": ["[email protected]", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], "bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="],
@@ -1043,6 +1055,8 @@
"dunder-proto": ["[email protected]", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "dunder-proto": ["[email protected]", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ecdsa-sig-formatter": ["[email protected]", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
"ee-first": ["[email protected]", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], "ee-first": ["[email protected]", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"electron-to-chromium": ["[email protected]", "", {}, "sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA=="], "electron-to-chromium": ["[email protected]", "", {}, "sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA=="],
@@ -1257,6 +1271,10 @@
"http-errors": ["[email protected]", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "http-errors": ["[email protected]", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"http_ece": ["[email protected]", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="],
"https-proxy-agent": ["[email protected]", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"human-signals": ["[email protected]", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], "human-signals": ["[email protected]", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
"iconv-lite": ["[email protected]", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], "iconv-lite": ["[email protected]", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="],
@@ -1403,6 +1421,10 @@
"jsx-ast-utils": ["[email protected]", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], "jsx-ast-utils": ["[email protected]", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
"jwa": ["[email protected]", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
"jws": ["[email protected]", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
"keyv": ["[email protected]", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], "keyv": ["[email protected]", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"kleur": ["[email protected]", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], "kleur": ["[email protected]", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
@@ -1489,6 +1511,8 @@
"mimic-function": ["[email protected]", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], "mimic-function": ["[email protected]", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
"minimalistic-assert": ["[email protected]", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
"minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"minimist": ["[email protected]", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], "minimist": ["[email protected]", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
@@ -1715,6 +1739,8 @@
"safe-array-concat": ["[email protected]", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="], "safe-array-concat": ["[email protected]", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="],
"safe-buffer": ["[email protected]", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safe-push-apply": ["[email protected]", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], "safe-push-apply": ["[email protected]", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="],
"safe-regex-test": ["[email protected]", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], "safe-regex-test": ["[email protected]", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
@@ -1915,6 +1941,8 @@
"w3c-keyname": ["[email protected]", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], "w3c-keyname": ["[email protected]", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
"web-push": ["[email protected]", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="],
"which": ["[email protected]", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which": ["[email protected]", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"which-boxed-primitive": ["[email protected]", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], "which-boxed-primitive": ["[email protected]", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
+6 -4
View File
@@ -17,6 +17,7 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth/client"; import { authClient } from "@/lib/auth/client";
import { unsubscribeCommissionPush } from "@/components/commission/push-permission";
export function AdminHeader() { export function AdminHeader() {
const pathname = usePathname(); const pathname = usePathname();
@@ -106,16 +107,17 @@ export function AdminHeader() {
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
aria-label="ออกจากระบบ" aria-label="ออกจากระบบ"
onClick={() => onClick={async () => {
authClient.signOut({ await unsubscribeCommissionPush().catch(() => undefined);
await authClient.signOut({
fetchOptions: { fetchOptions: {
onSuccess: () => { onSuccess: () => {
router.push("/admin/login"); router.push("/admin/login");
router.refresh(); router.refresh();
}, },
}, },
}) });
} }}
> >
<LogOutIcon /> <LogOutIcon />
</Button> </Button>
+21 -5
View File
@@ -2,15 +2,31 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { parseCommissionMessageEvent } from "@/lib/commission/message-event";
export function CommissionLiveRefresh({ endpoint }: { endpoint: string }) { export function CommissionLiveRefresh({ endpoint, userId }: { endpoint: string; userId: string }) {
const router = useRouter(); const router = useRouter();
useEffect(() => { useEffect(() => {
const originalTitle = document.title;
let managedTitle = "";
let unread = 0;
const source = new EventSource(endpoint); const source = new EventSource(endpoint);
const refresh = () => router.refresh(); const refresh = (event: Event) => {
const message = parseCommissionMessageEvent((event as MessageEvent<string>).data);
if (message && message.authorId !== userId) {
unread += 1;
managedTitle = `(${unread}) ${message.ticketTitle}`;
document.title = managedTitle;
}
router.refresh();
};
const opened = () => router.refresh();
source.addEventListener("changed", refresh); source.addEventListener("changed", refresh);
source.addEventListener("open", refresh); source.addEventListener("open", opened);
return () => { source.removeEventListener("changed", refresh); source.removeEventListener("open", refresh); source.close(); }; return () => {
}, [endpoint, router]); source.removeEventListener("changed", refresh); source.removeEventListener("open", opened); source.close();
if (managedTitle && document.title === managedTitle) document.title = originalTitle;
};
}, [endpoint, userId, router]);
return null; return null;
} }
+90
View File
@@ -0,0 +1,90 @@
"use client";
import { useEffect, useState } from "react";
import { Bell, BellOff } from "lucide-react";
import { Button } from "@/components/ui/button";
type State = "loading" | "off" | "on" | "blocked" | "unavailable";
export async function unsubscribeCommissionPush() {
if (!("serviceWorker" in navigator)) return;
const registration = await navigator.serviceWorker.getRegistration("/");
const subscription = await registration?.pushManager.getSubscription();
if (!subscription) return;
await fetch("/api/commission/push-subscription", { method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint: subscription.endpoint }) }).catch(() => undefined);
await subscription.unsubscribe();
}
export function CommissionPushPermission({ scope }: { scope: "customer" | "admin" }) {
const [state, setState] = useState<State>("loading");
const [publicKey, setPublicKey] = useState("");
const [error, setError] = useState("");
useEffect(() => {
let active = true;
void (async () => {
if (!("Notification" in window) || !("serviceWorker" in navigator) ||
!("PushManager" in window) || !window.isSecureContext) {
setState("unavailable"); return;
}
const response = await fetch("/api/commission/push-subscription", { cache: "no-store" });
if (!response.ok) throw new Error();
const result = await response.json() as { publicKey: string };
if (!active) return;
setPublicKey(result.publicKey);
if (Notification.permission === "denied") { setState("blocked"); return; }
if (Notification.permission !== "granted") { setState("off"); return; }
const registration = await navigator.serviceWorker.register("/commission-push-sw.js", { scope: "/" });
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
const saved = await fetch("/api/commission/push-subscription", { method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...subscription.toJSON(), scope }) });
if (!saved.ok) throw new Error();
}
if (active) setState(subscription ? "on" : "off");
})().catch(() => { if (active) setState("unavailable"); });
return () => { active = false; };
}, [scope]);
async function toggle() {
const previousState = state;
setError(""); setState("loading");
try {
if (Notification.permission === "granted" && state === "on") {
await unsubscribeCommissionPush();
setState("off");
return;
}
if (await Notification.requestPermission() !== "granted") {
setState(Notification.permission === "denied" ? "blocked" : "off");
return;
}
const registration = await navigator.serviceWorker.register("/commission-push-sw.js", { scope: "/" });
const subscription = 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) { await subscription.unsubscribe(); throw new Error(); }
setState("on");
} catch {
setState(previousState === "on" ? "on" : "off");
setError(`${previousState === "on" ? "ปิด" : "เปิด"}การแจ้งเตือนไม่สำเร็จ กรุณาลองอีกครั้ง`);
}
}
if (state === "unavailable") return <span className="text-xs text-muted-foreground">
การแจ้งเตือนไม่พร้อมใช้งานบนเบราว์เซอร์นี้ (iPhone/iPad: เพิ่มเว็บไซต์ไปยังหน้าจอโฮมก่อน)
</span>;
if (state === "blocked") return <span className="text-xs text-muted-foreground">เบราว์เซอร์บล็อกการแจ้งเตือนอยู่</span>;
return <div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={state === "loading"} onClick={() => void toggle()}>
{state === "on" ? <BellOff data-icon="inline-start" /> : <Bell data-icon="inline-start" />}
{state === "on" ? "ปิดการแจ้งเตือน" : "เปิดการแจ้งเตือน"}
</Button>
{error && <span role="alert" className="text-xs text-destructive">{error}</span>}
</div>;
}
+2
View File
@@ -4,10 +4,12 @@ import { useRouter } from "next/navigation";
import { LogOutIcon } from "lucide-react"; import { LogOutIcon } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth/client"; import { authClient } from "@/lib/auth/client";
import { unsubscribeCommissionPush } from "./push-permission";
export function CommissionSignOut() { export function CommissionSignOut() {
const router = useRouter(); const router = useRouter();
return <Button type="button" variant="ghost" size="icon-sm" aria-label="ออกจากระบบ" onClick={async () => { return <Button type="button" variant="ghost" size="icon-sm" aria-label="ออกจากระบบ" onClick={async () => {
await unsubscribeCommissionPush().catch(() => undefined);
await authClient.signOut(); await authClient.signOut();
router.push("/commission"); router.push("/commission");
router.refresh(); router.refresh();
+31 -5
View File
@@ -19,6 +19,8 @@ import { Message, MessageAvatar, MessageContent, MessageFooter, MessageGroup, Me
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { parseCommissionMessageEvent } from "@/lib/commission/message-event";
import { CommissionPushPermission } from "./push-permission";
type ChatMessage = { id: string; authorId: string; authorName: string; text: string | null; type ChatMessage = { id: string; authorId: string; authorName: string; text: string | null;
imageUrl: string | null; createdAt: Date; reactions: { userId: string; emoji: string }[] }; imageUrl: string | null; createdAt: Date; reactions: { userId: string; emoji: string }[] };
@@ -55,8 +57,8 @@ function CommissionMessageImage({ src }: { src: string }) {
</Dialog>; </Dialog>;
} }
export function CommissionTicketChat({ ticketId, userId, status, messages, admin }: { export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, messages, admin }: {
ticketId: string; userId: string; status: string; messages: ChatMessage[]; admin: boolean; ticketId: string; ticketTitle: string; userId: string; status: string; messages: ChatMessage[]; admin: boolean;
}) { }) {
const router = useRouter(); const router = useRouter();
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
@@ -102,6 +104,18 @@ export function CommissionTicketChat({ ticketId, userId, status, messages, admin
}, [messages, optimisticMessages]); }, [messages, optimisticMessages]);
useEffect(() => { useEffect(() => {
const originalTitle = document.title;
let managedTitle = ticketTitle;
let unread = 0;
document.title = ticketTitle;
const markRead = () => {
if (document.hidden || !document.hasFocus()) return;
unread = 0;
managedTitle = ticketTitle;
document.title = ticketTitle;
};
document.addEventListener("visibilitychange", markRead);
window.addEventListener("focus", markRead);
const source = new EventSource(`/api/commission/events?ticketId=${ticketId}`); const source = new EventSource(`/api/commission/events?ticketId=${ticketId}`);
let typingTimeout: ReturnType<typeof setTimeout> | undefined; let typingTimeout: ReturnType<typeof setTimeout> | undefined;
const refresh = () => { const refresh = () => {
@@ -110,7 +124,15 @@ export function CommissionTicketChat({ ticketId, userId, status, messages, admin
}; };
const receive = (event: Event) => { const receive = (event: Event) => {
const value = (event as MessageEvent<string>).data; const value = (event as MessageEvent<string>).data;
if (!value.startsWith("typing:")) { refresh(); return; } if (!value.startsWith("typing:")) {
const message = parseCommissionMessageEvent(value);
if (message && message.authorId !== userId && (document.hidden || !document.hasFocus())) {
unread += 1;
managedTitle = `(${unread}) ${ticketTitle}`;
document.title = managedTitle;
}
refresh(); return;
}
try { try {
const update = JSON.parse(value.slice(7)) as { userId: string; name: string; active: boolean }; const update = JSON.parse(value.slice(7)) as { userId: string; name: string; active: boolean };
if (update.userId === userId || typeof update.name !== "string" || typeof update.active !== "boolean") return; if (update.userId === userId || typeof update.name !== "string" || typeof update.active !== "boolean") return;
@@ -128,8 +150,11 @@ export function CommissionTicketChat({ ticketId, userId, status, messages, admin
source.removeEventListener("changed", receive); source.removeEventListener("changed", receive);
source.removeEventListener("open", refresh); source.removeEventListener("open", refresh);
source.close(); source.close();
document.removeEventListener("visibilitychange", markRead);
window.removeEventListener("focus", markRead);
if (document.title === managedTitle) document.title = originalTitle;
}; };
}, [ticketId, userId, router]); }, [ticketId, ticketTitle, userId, router]);
useEffect(() => { useEffect(() => {
const viewport = scrollRef.current; const viewport = scrollRef.current;
@@ -278,7 +303,8 @@ export function CommissionTicketChat({ ticketId, userId, status, messages, admin
<CardHeader className="border-b py-3"> <CardHeader className="border-b py-3">
<CardTitle>แชต{admin ? "กับลูกค้า" : "กับทีมงาน"}</CardTitle> <CardTitle>แชต{admin ? "กับลูกค้า" : "กับทีมงาน"}</CardTitle>
<CardDescription>พูดคุยและส่งรูปภาพเกี่ยวกับคำขอนี้</CardDescription> <CardDescription>พูดคุยและส่งรูปภาพเกี่ยวกับคำขอนี้</CardDescription>
<CardAction className="flex items-center gap-2"> <CardAction className="flex flex-wrap items-center gap-2">
<CommissionPushPermission scope={admin ? "admin" : "customer"} />
<Badge variant={status === "open" ? "secondary" : "outline"}>{status === "open" ? "เปิดอยู่" : "ปิดแล้ว"}</Badge> <Badge variant={status === "open" ? "secondary" : "outline"}>{status === "open" ? "เปิดอยู่" : "ปิดแล้ว"}</Badge>
{admin && <Button variant="outline" size="sm" onClick={toggleStatus}> {admin && <Button variant="outline" size="sm" onClick={toggleStatus}>
{status === "open" ? "ปิด Ticket" : "เปิดอีกครั้ง"} {status === "open" ? "ปิด Ticket" : "เปิดอีกครั้ง"}
+2 -1
View File
@@ -34,7 +34,8 @@ export async function CommissionTicketDetail({ id, admin }: { id: string; admin:
<p className="text-sm text-muted-foreground">การชำระเงิน: {data.payment.transRef} - {data.payment.transferredAt.toLocaleString("th-TH")}</p> <p className="text-sm text-muted-foreground">การชำระเงิน: {data.payment.transRef} - {data.payment.transferredAt.toLocaleString("th-TH")}</p>
<CommissionSlipDialog src={publicMediaUrl(data.payment.slipObjectKey)} /> <CommissionSlipDialog src={publicMediaUrl(data.payment.slipObjectKey)} />
</CardContent></Card> </CardContent></Card>
<CommissionTicketChat ticketId={id} userId={data.currentUserId} status={data.ticket.status} <CommissionTicketChat ticketId={id} ticketTitle={data.ticket.title || `Ticket #${id.slice(0, 8)}`}
userId={data.currentUserId} status={data.ticket.status}
messages={data.messages.map(({ imageObjectKey, ...message }) => ({ ...message, messages={data.messages.map(({ imageObjectKey, ...message }) => ({ ...message,
imageUrl: imageObjectKey ? publicMediaUrl(imageObjectKey) : null }))} admin={admin} /> imageUrl: imageObjectKey ? publicMediaUrl(imageObjectKey) : null }))} admin={admin} />
</div> </div>
+9
View File
@@ -201,6 +201,15 @@ export const commissionPayments = commissionSchema.table("payment", {
uniqueIndex("commission_payment_trans_ref_unique").on(table.transRef), uniqueIndex("commission_payment_trans_ref_unique").on(table.transRef),
]); ]);
export const commissionPushSubscriptions = commissionSchema.table("push_subscription", {
endpoint: text("endpoint").primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
scope: varchar("scope", { length: 8 }).notNull(),
p256dh: text("p256dh").notNull(),
auth: text("auth").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
}, (table) => [index("commission_push_subscription_user_idx").on(table.userId)]);
export const commissionMessages = commissionSchema.table("message", { export const commissionMessages = commissionSchema.table("message", {
id: uuid("id").defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
ticketId: uuid("ticket_id").notNull().references(() => commissionTickets.id), ticketId: uuid("ticket_id").notNull().references(() => commissionTickets.id),
+11
View File
@@ -0,0 +1,11 @@
CREATE TABLE "commission"."push_subscription" (
"endpoint" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"scope" varchar(8) NOT NULL,
"p256dh" text NOT NULL,
"auth" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "commission"."push_subscription" ADD CONSTRAINT "push_subscription_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "commission_push_subscription_user_idx" ON "commission"."push_subscription" USING btree ("user_id");
File diff suppressed because it is too large Load Diff
+7
View File
@@ -477,6 +477,13 @@
"when": 1790863872049, "when": 1790863872049,
"tag": "0067_nervous_psylocke", "tag": "0067_nervous_psylocke",
"breakpoints": true "breakpoints": true
},
{
"idx": 68,
"version": "7",
"when": 1790915901646,
"tag": "0068_concerned_union_jack",
"breakpoints": true
} }
] ]
} }
+18
View File
@@ -128,6 +128,24 @@ spec:
name: buzz-sheet-env name: buzz-sheet-env
key: COMMISSION_DISCORD_WEBHOOK_URL key: COMMISSION_DISCORD_WEBHOOK_URL
optional: true optional: true
- name: WEB_PUSH_PUBLIC_KEY
valueFrom:
secretKeyRef:
name: buzz-sheet-env
key: WEB_PUSH_PUBLIC_KEY
optional: true
- name: WEB_PUSH_PRIVATE_KEY
valueFrom:
secretKeyRef:
name: buzz-sheet-env
key: WEB_PUSH_PRIVATE_KEY
optional: true
- name: WEB_PUSH_SUBJECT
valueFrom:
secretKeyRef:
name: buzz-sheet-env
key: WEB_PUSH_SUBJECT
optional: true
- name: NEXT_SERVER_ACTIONS_ENCRYPTION_KEY - name: NEXT_SERVER_ACTIONS_ENCRYPTION_KEY
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
+12
View File
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { parseCommissionMessageEvent } from "./message-event";
describe("commission message events", () => {
it("accepts a new message and ignores other refresh events", () => {
const message = { messageId: "message-1", ticketId: "ticket-1", ticketTitle: "Build help",
authorId: "admin-1", authorName: "Admin", text: "Hello" };
expect(parseCommissionMessageEvent(`message:${JSON.stringify(message)}`)).toEqual(message);
expect(parseCommissionMessageEvent("changed")).toBeNull();
expect(parseCommissionMessageEvent("message:not-json")).toBeNull();
});
});
+15
View File
@@ -0,0 +1,15 @@
export type CommissionMessageEvent = {
messageId: string; ticketId: string; ticketTitle: string; authorId: string;
authorName: string; text: string | null;
};
export function parseCommissionMessageEvent(value: string): CommissionMessageEvent | null {
if (!value.startsWith("message:")) return null;
try {
const message = JSON.parse(value.slice(8)) as CommissionMessageEvent;
return typeof message.messageId === "string" && typeof message.ticketId === "string" &&
typeof message.ticketTitle === "string" && typeof message.authorId === "string" &&
typeof message.authorName === "string" &&
(typeof message.text === "string" || message.text === null) ? message : null;
} catch { return null; }
}
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("server-only", () => ({}));
const { validPushEndpoint } = await import("./push");
describe("commission push endpoints", () => {
it("accepts browser push services and rejects arbitrary destinations", () => {
expect(validPushEndpoint("https://fcm.googleapis.com/fcm/send/token")).toBe(true);
expect(validPushEndpoint("https://updates.push.services.mozilla.com/wpush/v2/token")).toBe(true);
expect(validPushEndpoint("https://web.push.apple.com/token")).toBe(true);
expect(validPushEndpoint("https://127.0.0.1/push")).toBe(false);
expect(validPushEndpoint("https://fcm.googleapis.com.evil.test/push")).toBe(false);
expect(validPushEndpoint("http://fcm.googleapis.com/push")).toBe(false);
});
});
+58
View File
@@ -0,0 +1,58 @@
import "server-only";
import { and, eq, ne, or } from "drizzle-orm";
import webPush from "web-push";
import { getDb } from "@/db";
import { commissionPushSubscriptions, users } from "@/db/schema";
export function pushPublicKey() {
return process.env.WEB_PUSH_PUBLIC_KEY && process.env.WEB_PUSH_PRIVATE_KEY && process.env.WEB_PUSH_SUBJECT
? process.env.WEB_PUSH_PUBLIC_KEY : null;
}
export function validPushEndpoint(value: string) {
try {
const url = new URL(value);
const host = url.hostname.toLowerCase();
return url.protocol === "https:" && !url.username && !url.password &&
(host === "fcm.googleapis.com" || host === "updates.push.services.mozilla.com" ||
host === "web.push.apple.com" || host.endsWith(".push.apple.com") ||
host.endsWith(".notify.windows.com"));
} catch { return false; }
}
export async function sendCommissionMessagePush(message: {
id: string; ticketId: string; ticketTitle: string; customerId: string; authorId: string;
authorName: string; text: string | null;
}) {
if (!pushPublicKey()) return;
const subscriptions = await getDb().select({ subscription: commissionPushSubscriptions })
.from(commissionPushSubscriptions)
.innerJoin(users, eq(users.id, commissionPushSubscriptions.userId))
.where(and(ne(commissionPushSubscriptions.userId, message.authorId), eq(users.banned, false),
or(and(eq(commissionPushSubscriptions.scope, "customer"),
eq(commissionPushSubscriptions.userId, message.customerId)),
and(eq(commissionPushSubscriptions.scope, "admin"), eq(users.role, "admin"),
eq(users.emailVerified, true)))));
webPush.setVapidDetails(process.env.WEB_PUSH_SUBJECT!, process.env.WEB_PUSH_PUBLIC_KEY!,
process.env.WEB_PUSH_PRIVATE_KEY!);
for (let offset = 0; offset < subscriptions.length; offset += 10) {
await Promise.all(subscriptions.slice(offset, offset + 10).map(async ({ subscription }) => {
const payload = JSON.stringify({
id: message.id, title: `${message.authorName} · ${message.ticketTitle}`,
body: message.text?.slice(0, 300) || "ส่งรูปภาพ",
url: `/${subscription.scope === "admin" ? "admin/commission" : "commission/tickets"}/${message.ticketId}`,
});
try {
await webPush.sendNotification({ endpoint: subscription.endpoint,
keys: { p256dh: subscription.p256dh, auth: subscription.auth } }, payload,
{ TTL: 60 * 60 * 24, timeout: 5000 });
} catch (cause) {
if (cause && typeof cause === "object" && "statusCode" in cause &&
(cause.statusCode === 404 || cause.statusCode === 410))
await getDb().delete(commissionPushSubscriptions)
.where(eq(commissionPushSubscriptions.endpoint, subscription.endpoint));
}
}));
}
}
+4 -4
View File
@@ -16,13 +16,13 @@ export async function requireCommissionUser() {
return user; return user;
} }
export async function notifyCommission(ticketId: string, userId: string) { export async function notifyCommission(ticketId: string, userId: string, payload = "changed") {
try { try {
const client = await getRedisClient(); const client = await getRedisClient();
await Promise.all([ await Promise.all([
client.publish(redisEventChannel(`commission:ticket:${ticketId}`), "changed"), client.publish(redisEventChannel(`commission:ticket:${ticketId}`), payload),
client.publish(redisEventChannel(`commission:user:${userId}`), "changed"), client.publish(redisEventChannel(`commission:user:${userId}`), payload),
client.publish(redisEventChannel("commission:admin"), "changed"), client.publish(redisEventChannel("commission:admin"), payload),
]); ]);
} catch { } catch {
// The database is authoritative; a reconnect or page refresh catches up. // The database is authoritative; a reconnect or page refresh catches up.
+2
View File
@@ -64,6 +64,7 @@
"sonner": "^2.0.8", "sonner": "^2.0.8",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"web-push": "^3.6.7",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
@@ -74,6 +75,7 @@
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@types/sanitize-html": "^2.16.1", "@types/sanitize-html": "^2.16.1",
"@types/web-push": "^3.6.4",
"@vitest/coverage-v8": "^4.1.11", "@vitest/coverage-v8": "^4.1.11",
"drizzle-kit": "0.31.11", "drizzle-kit": "0.31.11",
"eslint": "^9", "eslint": "^9",
+22
View File
@@ -0,0 +1,22 @@
self.addEventListener("push", (event) => {
if (!event.data) return;
const message = event.data.json();
event.waitUntil(self.registration.showNotification(message.title, {
body: message.body,
icon: "/icon.png",
tag: message.id,
data: { url: message.url },
}));
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = new URL(event.notification.data.url, self.location.origin);
if (url.origin !== self.location.origin) return;
event.waitUntil((async () => {
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
const existing = windows.find((client) => client.url === url.href);
if (existing) return existing.focus();
return self.clients.openWindow(url.href);
})());
});