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