feat(commission) : add push notifications and unread ticket titles
This commit is contained in:
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" }],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user