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