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
+8 -3
View File
@@ -6,6 +6,7 @@ import { getDb } from "@/db";
import { commissionCheckouts, commissionTickets, users } from "@/db/schema";
import { AdminHeader } from "@/components/admin/admin-header";
import { CommissionLiveRefresh } from "@/components/commission/live-refresh";
import { CommissionPushPermission } from "@/components/commission/push-permission";
import { AdminCommissionAvailabilityControl } from "@/components/commission/admin-availability-control";
import { Badge } from "@/components/ui/badge";
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 default async function AdminCommissionPage({ searchParams }: PageProps<"/admin/commission">) {
await connection();
if (!(await getAdminSession())) redirect("/admin/login");
const session = await getAdminSession();
if (!session) redirect("/admin/login");
const query = await searchParams;
const rawPage = Number(query.page ?? 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))
.orderBy(desc(commissionTickets.status), desc(commissionTickets.updatedAt), desc(commissionTickets.id))
.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">
<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}`}
{...availability} />
{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 { after } from "next/server";
import { getDb } from "@/db";
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 { authorizeTicket } from "@/lib/commission/tickets";
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);
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 });
});
} catch (cause) { return errorResponse(cause); }
+3 -1
View File
@@ -5,6 +5,7 @@ import { connection } from "next/server";
import { getDb } from "@/db";
import { commissionCheckouts, commissionTickets } from "@/db/schema";
import { CommissionLiveRefresh } from "@/components/commission/live-refresh";
import { CommissionPushPermission } from "@/components/commission/push-permission";
import { CommissionSignOut } from "@/components/commission/sign-out";
import { SiteHeader } from "@/components/public/site-header";
import { Badge } from "@/components/ui/badge";
@@ -28,13 +29,14 @@ export default async function CommissionTicketsPage() {
gt(commissionCheckouts.createdAt, new Date(checkoutNow() - CHECKOUT_DURATION_MS))))
.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">
<div className="flex items-center justify-between gap-3">
<h1 className="font-heading text-3xl font-semibold">Ticket ของฉัน</h1>
<CommissionSignOut />
</div>
<Link href="/commission" className="text-sm underline">สร้างคำขอเพิ่ม</Link>
<CommissionPushPermission scope="customer" />
{pending.length > 0 && <h2 className="text-xl font-semibold">รอชำระเงิน</h2>}
{pending.map((checkout) => <Link key={checkout.id} href={`/commission/pay/${checkout.id}`}>
<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" }],
};
}