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
@@ -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); }