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
+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;
}
export async function notifyCommission(ticketId: string, userId: string) {
export async function notifyCommission(ticketId: string, userId: string, payload = "changed") {
try {
const client = await getRedisClient();
await Promise.all([
client.publish(redisEventChannel(`commission:ticket:${ticketId}`), "changed"),
client.publish(redisEventChannel(`commission:user:${userId}`), "changed"),
client.publish(redisEventChannel("commission:admin"), "changed"),
client.publish(redisEventChannel(`commission:ticket:${ticketId}`), payload),
client.publish(redisEventChannel(`commission:user:${userId}`), payload),
client.publish(redisEventChannel("commission:admin"), payload),
]);
} catch {
// The database is authoritative; a reconnect or page refresh catches up.