feat(commission) : add push notifications and unread ticket titles
This commit is contained in:
@@ -35,6 +35,10 @@ DISCORD_BOT_TOKEN=replace-with-discord-bot-token
|
|||||||
DISCORD_CHANNEL_ID=replace-with-private-channel-id
|
DISCORD_CHANNEL_ID=replace-with-private-channel-id
|
||||||
DISCORD_LOG_CHANNEL_ID=1547193755772125184
|
DISCORD_LOG_CHANNEL_ID=1547193755772125184
|
||||||
COMMISSION_DISCORD_WEBHOOK_URL=replace-with-commission-discord-webhook-url
|
COMMISSION_DISCORD_WEBHOOK_URL=replace-with-commission-discord-webhook-url
|
||||||
|
# Generate a stable pair with: bunx web-push generate-vapid-keys --json
|
||||||
|
WEB_PUSH_PUBLIC_KEY=replace-with-vapid-public-key
|
||||||
|
WEB_PUSH_PRIVATE_KEY=replace-with-vapid-private-key
|
||||||
|
WEB_PUSH_SUBJECT=mailto:[email protected]
|
||||||
|
|
||||||
# S3-compatible media storage; guide and commission uploads use public CDN objects.
|
# S3-compatible media storage; guide and commission uploads use public CDN objects.
|
||||||
S3_ENDPOINT=https://s3.example.internal
|
S3_ENDPOINT=https://s3.example.internal
|
||||||
|
|||||||
@@ -212,6 +212,9 @@ COMMISSION_PROMPTPAY_VALUE
|
|||||||
COMMISSION_RECEIVER_ACCOUNT_NUMBER
|
COMMISSION_RECEIVER_ACCOUNT_NUMBER
|
||||||
SLIP2GO_VERIFY_URL
|
SLIP2GO_VERIFY_URL
|
||||||
SLIP2GO_API_SECRET
|
SLIP2GO_API_SECRET
|
||||||
|
WEB_PUSH_PUBLIC_KEY
|
||||||
|
WEB_PUSH_PRIVATE_KEY
|
||||||
|
WEB_PUSH_SUBJECT
|
||||||
DISCORD_BOT_TOKEN
|
DISCORD_BOT_TOKEN
|
||||||
DISCORD_CHANNEL_ID
|
DISCORD_CHANNEL_ID
|
||||||
```
|
```
|
||||||
@@ -235,6 +238,13 @@ view it. Each checkout accepts payment for one hour after creation. The outbox
|
|||||||
worker removes expired unpaid checkouts; paid tickets remain available. Apply
|
worker removes expired unpaid checkouts; paid tickets remain available. Apply
|
||||||
the commission database migration before enabling checkout.
|
the commission database migration before enabling checkout.
|
||||||
|
|
||||||
|
Commission message notifications require a stable VAPID key pair and subject
|
||||||
|
(`bunx web-push generate-vapid-keys --json`). Set the three `WEB_PUSH_*` values
|
||||||
|
in the web deployment secret and apply the push-subscription migration before
|
||||||
|
enabling notifications. Users opt in from their ticket page. Browser push can
|
||||||
|
arrive after tabs close; on iPhone and iPad, the site must be added to the Home
|
||||||
|
Screen before the browser offers push permission.
|
||||||
|
|
||||||
Traefik must overwrite `X-Real-Ip` for every request, and the web pods must be
|
Traefik must overwrite `X-Real-Ip` for every request, and the web pods must be
|
||||||
reachable only through Traefik before enabling the configured client IP based
|
reachable only through Traefik before enabling the configured client IP based
|
||||||
rate limits. The NetworkPolicy assumes Traefik runs in `kube-system` with the
|
rate limits. The NetworkPolicy assumes Traefik runs in `kube-system` with the
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getDb } from "@/db";
|
|||||||
import { commissionCheckouts, commissionTickets, users } from "@/db/schema";
|
import { commissionCheckouts, commissionTickets, users } from "@/db/schema";
|
||||||
import { AdminHeader } from "@/components/admin/admin-header";
|
import { AdminHeader } from "@/components/admin/admin-header";
|
||||||
import { CommissionLiveRefresh } from "@/components/commission/live-refresh";
|
import { CommissionLiveRefresh } from "@/components/commission/live-refresh";
|
||||||
|
import { CommissionPushPermission } from "@/components/commission/push-permission";
|
||||||
import { AdminCommissionAvailabilityControl } from "@/components/commission/admin-availability-control";
|
import { AdminCommissionAvailabilityControl } from "@/components/commission/admin-availability-control";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
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 const instant = false;
|
||||||
export default async function AdminCommissionPage({ searchParams }: PageProps<"/admin/commission">) {
|
export default async function AdminCommissionPage({ searchParams }: PageProps<"/admin/commission">) {
|
||||||
await connection();
|
await connection();
|
||||||
if (!(await getAdminSession())) redirect("/admin/login");
|
const session = await getAdminSession();
|
||||||
|
if (!session) redirect("/admin/login");
|
||||||
const query = await searchParams;
|
const query = await searchParams;
|
||||||
const rawPage = Number(query.page ?? 1);
|
const rawPage = Number(query.page ?? 1);
|
||||||
const page = Number.isInteger(rawPage) && rawPage > 0 ? Math.min(rawPage, 10000) : 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))
|
.innerJoin(users, eq(commissionTickets.userId, users.id))
|
||||||
.orderBy(desc(commissionTickets.status), desc(commissionTickets.updatedAt), desc(commissionTickets.id))
|
.orderBy(desc(commissionTickets.status), desc(commissionTickets.updatedAt), desc(commissionTickets.id))
|
||||||
.limit(51).offset((page - 1) * 50), getCommissionAvailability()]);
|
.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">
|
<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}`}
|
<AdminCommissionAvailabilityControl key={`${availability.isOpen}:${availability.closureMessage}`}
|
||||||
{...availability} />
|
{...availability} />
|
||||||
{tickets.length === 0 && <p>ยังไม่มี Ticket</p>}
|
{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 { eq } from "drizzle-orm";
|
||||||
|
import { after } from "next/server";
|
||||||
import { getDb } from "@/db";
|
import { getDb } from "@/db";
|
||||||
import { commissionMessages, commissionTickets } from "@/db/schema";
|
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 { notifyCommission } from "@/lib/commission/server";
|
||||||
import { authorizeTicket } from "@/lib/commission/tickets";
|
import { authorizeTicket } from "@/lib/commission/tickets";
|
||||||
import { getMediaStorage } from "@/lib/media/storage";
|
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);
|
if (imageObjectKey) await (await getMediaStorage()).delete(imageObjectKey).catch(() => undefined);
|
||||||
throw cause;
|
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 });
|
return Response.json({ ok: true, messageId }, { status: 201 });
|
||||||
});
|
});
|
||||||
} catch (cause) { return errorResponse(cause); }
|
} catch (cause) { return errorResponse(cause); }
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { connection } from "next/server";
|
|||||||
import { getDb } from "@/db";
|
import { getDb } from "@/db";
|
||||||
import { commissionCheckouts, commissionTickets } from "@/db/schema";
|
import { commissionCheckouts, commissionTickets } from "@/db/schema";
|
||||||
import { CommissionLiveRefresh } from "@/components/commission/live-refresh";
|
import { CommissionLiveRefresh } from "@/components/commission/live-refresh";
|
||||||
|
import { CommissionPushPermission } from "@/components/commission/push-permission";
|
||||||
import { CommissionSignOut } from "@/components/commission/sign-out";
|
import { CommissionSignOut } from "@/components/commission/sign-out";
|
||||||
import { SiteHeader } from "@/components/public/site-header";
|
import { SiteHeader } from "@/components/public/site-header";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -28,13 +29,14 @@ export default async function CommissionTicketsPage() {
|
|||||||
gt(commissionCheckouts.createdAt, new Date(checkoutNow() - CHECKOUT_DURATION_MS))))
|
gt(commissionCheckouts.createdAt, new Date(checkoutNow() - CHECKOUT_DURATION_MS))))
|
||||||
.orderBy(desc(commissionCheckouts.createdAt)).limit(20),
|
.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">
|
<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">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<h1 className="font-heading text-3xl font-semibold">Ticket ของฉัน</h1>
|
<h1 className="font-heading text-3xl font-semibold">Ticket ของฉัน</h1>
|
||||||
<CommissionSignOut />
|
<CommissionSignOut />
|
||||||
</div>
|
</div>
|
||||||
<Link href="/commission" className="text-sm underline">สร้างคำขอเพิ่ม</Link>
|
<Link href="/commission" className="text-sm underline">สร้างคำขอเพิ่ม</Link>
|
||||||
|
<CommissionPushPermission scope="customer" />
|
||||||
{pending.length > 0 && <h2 className="text-xl font-semibold">รอชำระเงิน</h2>}
|
{pending.length > 0 && <h2 className="text-xl font-semibold">รอชำระเงิน</h2>}
|
||||||
{pending.map((checkout) => <Link key={checkout.id} href={`/commission/pay/${checkout.id}`}>
|
{pending.map((checkout) => <Link key={checkout.id} href={`/commission/pay/${checkout.id}`}>
|
||||||
<Card><CardHeader><CardTitle>รอชำระเงิน - ฿{checkout.amount}</CardTitle></CardHeader>
|
<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" }],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@
|
|||||||
"sonner": "^2.0.8",
|
"sonner": "^2.0.8",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"zod": "^4.4.3",
|
"zod": "^4.4.3",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -58,6 +59,7 @@
|
|||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@types/sanitize-html": "^2.16.1",
|
"@types/sanitize-html": "^2.16.1",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"@vitest/coverage-v8": "^4.1.11",
|
"@vitest/coverage-v8": "^4.1.11",
|
||||||
"drizzle-kit": "0.31.11",
|
"drizzle-kit": "0.31.11",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
@@ -707,6 +709,8 @@
|
|||||||
|
|
||||||
"@types/validate-npm-package-name": ["@types/[email protected]", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="],
|
"@types/validate-npm-package-name": ["@types/[email protected]", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="],
|
||||||
|
|
||||||
|
"@types/web-push": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="],
|
||||||
|
|
||||||
"@types/ws": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
"@types/ws": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||||
|
|
||||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/[email protected]", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.68.0", "@typescript-eslint/type-utils": "8.68.0", "@typescript-eslint/utils": "8.68.0", "@typescript-eslint/visitor-keys": "8.68.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.68.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q=="],
|
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/[email protected]", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.68.0", "@typescript-eslint/type-utils": "8.68.0", "@typescript-eslint/utils": "8.68.0", "@typescript-eslint/visitor-keys": "8.68.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.68.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q=="],
|
||||||
@@ -797,6 +801,8 @@
|
|||||||
|
|
||||||
"acorn-jsx": ["[email protected]", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
"acorn-jsx": ["[email protected]", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||||
|
|
||||||
|
"agent-base": ["[email protected]", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||||
|
|
||||||
"ajv": ["[email protected]", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
|
"ajv": ["[email protected]", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
|
||||||
|
|
||||||
"ajv-formats": ["[email protected]", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
"ajv-formats": ["[email protected]", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||||
@@ -829,6 +835,8 @@
|
|||||||
|
|
||||||
"arraybuffer.prototype.slice": ["[email protected]", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
|
"arraybuffer.prototype.slice": ["[email protected]", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
|
||||||
|
|
||||||
|
"asn1.js": ["[email protected]", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="],
|
||||||
|
|
||||||
"assertion-error": ["[email protected]", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
"assertion-error": ["[email protected]", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||||
|
|
||||||
"ast-types": ["[email protected]", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
|
"ast-types": ["[email protected]", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
|
||||||
@@ -859,6 +867,8 @@
|
|||||||
|
|
||||||
"better-call": ["[email protected]", "", { "dependencies": { "@better-auth/utils": "^0.5.0", "@better-fetch/fetch": "^1.3.1", "rou3": "^0.9.1", "set-cookie-parser": "^3.1.2" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA=="],
|
"better-call": ["[email protected]", "", { "dependencies": { "@better-auth/utils": "^0.5.0", "@better-fetch/fetch": "^1.3.1", "rou3": "^0.9.1", "set-cookie-parser": "^3.1.2" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA=="],
|
||||||
|
|
||||||
|
"bn.js": ["[email protected]", "", {}, "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ=="],
|
||||||
|
|
||||||
"body-parser": ["[email protected]", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="],
|
"body-parser": ["[email protected]", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="],
|
||||||
|
|
||||||
"brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="],
|
"brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="],
|
||||||
@@ -867,6 +877,8 @@
|
|||||||
|
|
||||||
"browserslist": ["[email protected]", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="],
|
"browserslist": ["[email protected]", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="],
|
||||||
|
|
||||||
|
"buffer-equal-constant-time": ["[email protected]", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
||||||
|
|
||||||
"buffer-from": ["[email protected]", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
"buffer-from": ["[email protected]", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||||
|
|
||||||
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="],
|
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="],
|
||||||
@@ -1043,6 +1055,8 @@
|
|||||||
|
|
||||||
"dunder-proto": ["[email protected]", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
"dunder-proto": ["[email protected]", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||||
|
|
||||||
|
"ecdsa-sig-formatter": ["[email protected]", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
|
||||||
|
|
||||||
"ee-first": ["[email protected]", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
"ee-first": ["[email protected]", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||||
|
|
||||||
"electron-to-chromium": ["[email protected]", "", {}, "sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA=="],
|
"electron-to-chromium": ["[email protected]", "", {}, "sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA=="],
|
||||||
@@ -1257,6 +1271,10 @@
|
|||||||
|
|
||||||
"http-errors": ["[email protected]", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
"http-errors": ["[email protected]", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||||
|
|
||||||
|
"http_ece": ["[email protected]", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="],
|
||||||
|
|
||||||
|
"https-proxy-agent": ["[email protected]", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||||
|
|
||||||
"human-signals": ["[email protected]", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
|
"human-signals": ["[email protected]", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
|
||||||
|
|
||||||
"iconv-lite": ["[email protected]", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="],
|
"iconv-lite": ["[email protected]", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="],
|
||||||
@@ -1403,6 +1421,10 @@
|
|||||||
|
|
||||||
"jsx-ast-utils": ["[email protected]", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
|
"jsx-ast-utils": ["[email protected]", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
|
||||||
|
|
||||||
|
"jwa": ["[email protected]", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
|
||||||
|
|
||||||
|
"jws": ["[email protected]", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
|
||||||
|
|
||||||
"keyv": ["[email protected]", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
"keyv": ["[email protected]", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||||
|
|
||||||
"kleur": ["[email protected]", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
"kleur": ["[email protected]", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||||
@@ -1489,6 +1511,8 @@
|
|||||||
|
|
||||||
"mimic-function": ["[email protected]", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
|
"mimic-function": ["[email protected]", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
|
||||||
|
|
||||||
|
"minimalistic-assert": ["[email protected]", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
|
||||||
|
|
||||||
"minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
"minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||||
|
|
||||||
"minimist": ["[email protected]", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
"minimist": ["[email protected]", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||||
@@ -1715,6 +1739,8 @@
|
|||||||
|
|
||||||
"safe-array-concat": ["[email protected]", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="],
|
"safe-array-concat": ["[email protected]", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="],
|
||||||
|
|
||||||
|
"safe-buffer": ["[email protected]", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||||
|
|
||||||
"safe-push-apply": ["[email protected]", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="],
|
"safe-push-apply": ["[email protected]", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="],
|
||||||
|
|
||||||
"safe-regex-test": ["[email protected]", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
|
"safe-regex-test": ["[email protected]", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
|
||||||
@@ -1915,6 +1941,8 @@
|
|||||||
|
|
||||||
"w3c-keyname": ["[email protected]", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
|
"w3c-keyname": ["[email protected]", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
|
||||||
|
|
||||||
|
"web-push": ["[email protected]", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="],
|
||||||
|
|
||||||
"which": ["[email protected]", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
"which": ["[email protected]", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
|
|
||||||
"which-boxed-primitive": ["[email protected]", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
|
"which-boxed-primitive": ["[email protected]", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { authClient } from "@/lib/auth/client";
|
import { authClient } from "@/lib/auth/client";
|
||||||
|
import { unsubscribeCommissionPush } from "@/components/commission/push-permission";
|
||||||
|
|
||||||
export function AdminHeader() {
|
export function AdminHeader() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -106,16 +107,17 @@ export function AdminHeader() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
aria-label="ออกจากระบบ"
|
aria-label="ออกจากระบบ"
|
||||||
onClick={() =>
|
onClick={async () => {
|
||||||
authClient.signOut({
|
await unsubscribeCommissionPush().catch(() => undefined);
|
||||||
|
await authClient.signOut({
|
||||||
fetchOptions: {
|
fetchOptions: {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
router.push("/admin/login");
|
router.push("/admin/login");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}}
|
||||||
>
|
>
|
||||||
<LogOutIcon />
|
<LogOutIcon />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,15 +2,31 @@
|
|||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { parseCommissionMessageEvent } from "@/lib/commission/message-event";
|
||||||
|
|
||||||
export function CommissionLiveRefresh({ endpoint }: { endpoint: string }) {
|
export function CommissionLiveRefresh({ endpoint, userId }: { endpoint: string; userId: string }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const originalTitle = document.title;
|
||||||
|
let managedTitle = "";
|
||||||
|
let unread = 0;
|
||||||
const source = new EventSource(endpoint);
|
const source = new EventSource(endpoint);
|
||||||
const refresh = () => router.refresh();
|
const refresh = (event: Event) => {
|
||||||
|
const message = parseCommissionMessageEvent((event as MessageEvent<string>).data);
|
||||||
|
if (message && message.authorId !== userId) {
|
||||||
|
unread += 1;
|
||||||
|
managedTitle = `(${unread}) ${message.ticketTitle}`;
|
||||||
|
document.title = managedTitle;
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
};
|
||||||
|
const opened = () => router.refresh();
|
||||||
source.addEventListener("changed", refresh);
|
source.addEventListener("changed", refresh);
|
||||||
source.addEventListener("open", refresh);
|
source.addEventListener("open", opened);
|
||||||
return () => { source.removeEventListener("changed", refresh); source.removeEventListener("open", refresh); source.close(); };
|
return () => {
|
||||||
}, [endpoint, router]);
|
source.removeEventListener("changed", refresh); source.removeEventListener("open", opened); source.close();
|
||||||
|
if (managedTitle && document.title === managedTitle) document.title = originalTitle;
|
||||||
|
};
|
||||||
|
}, [endpoint, userId, router]);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Bell, BellOff } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
type State = "loading" | "off" | "on" | "blocked" | "unavailable";
|
||||||
|
|
||||||
|
export async function unsubscribeCommissionPush() {
|
||||||
|
if (!("serviceWorker" in navigator)) return;
|
||||||
|
const registration = await navigator.serviceWorker.getRegistration("/");
|
||||||
|
const subscription = await registration?.pushManager.getSubscription();
|
||||||
|
if (!subscription) return;
|
||||||
|
await fetch("/api/commission/push-subscription", { method: "DELETE",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ endpoint: subscription.endpoint }) }).catch(() => undefined);
|
||||||
|
await subscription.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommissionPushPermission({ scope }: { scope: "customer" | "admin" }) {
|
||||||
|
const [state, setState] = useState<State>("loading");
|
||||||
|
const [publicKey, setPublicKey] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
void (async () => {
|
||||||
|
if (!("Notification" in window) || !("serviceWorker" in navigator) ||
|
||||||
|
!("PushManager" in window) || !window.isSecureContext) {
|
||||||
|
setState("unavailable"); return;
|
||||||
|
}
|
||||||
|
const response = await fetch("/api/commission/push-subscription", { cache: "no-store" });
|
||||||
|
if (!response.ok) throw new Error();
|
||||||
|
const result = await response.json() as { publicKey: string };
|
||||||
|
if (!active) return;
|
||||||
|
setPublicKey(result.publicKey);
|
||||||
|
if (Notification.permission === "denied") { setState("blocked"); return; }
|
||||||
|
if (Notification.permission !== "granted") { setState("off"); return; }
|
||||||
|
const registration = await navigator.serviceWorker.register("/commission-push-sw.js", { scope: "/" });
|
||||||
|
const subscription = await registration.pushManager.getSubscription();
|
||||||
|
if (subscription) {
|
||||||
|
const saved = await fetch("/api/commission/push-subscription", { method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ...subscription.toJSON(), scope }) });
|
||||||
|
if (!saved.ok) throw new Error();
|
||||||
|
}
|
||||||
|
if (active) setState(subscription ? "on" : "off");
|
||||||
|
})().catch(() => { if (active) setState("unavailable"); });
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [scope]);
|
||||||
|
|
||||||
|
async function toggle() {
|
||||||
|
const previousState = state;
|
||||||
|
setError(""); setState("loading");
|
||||||
|
try {
|
||||||
|
if (Notification.permission === "granted" && state === "on") {
|
||||||
|
await unsubscribeCommissionPush();
|
||||||
|
setState("off");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (await Notification.requestPermission() !== "granted") {
|
||||||
|
setState(Notification.permission === "denied" ? "blocked" : "off");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const registration = await navigator.serviceWorker.register("/commission-push-sw.js", { scope: "/" });
|
||||||
|
const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true,
|
||||||
|
applicationServerKey: publicKey });
|
||||||
|
const response = await fetch("/api/commission/push-subscription", { method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ...subscription.toJSON(), scope }) });
|
||||||
|
if (!response.ok) { await subscription.unsubscribe(); throw new Error(); }
|
||||||
|
setState("on");
|
||||||
|
} catch {
|
||||||
|
setState(previousState === "on" ? "on" : "off");
|
||||||
|
setError(`${previousState === "on" ? "ปิด" : "เปิด"}การแจ้งเตือนไม่สำเร็จ กรุณาลองอีกครั้ง`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === "unavailable") return <span className="text-xs text-muted-foreground">
|
||||||
|
การแจ้งเตือนไม่พร้อมใช้งานบนเบราว์เซอร์นี้ (iPhone/iPad: เพิ่มเว็บไซต์ไปยังหน้าจอโฮมก่อน)
|
||||||
|
</span>;
|
||||||
|
if (state === "blocked") return <span className="text-xs text-muted-foreground">เบราว์เซอร์บล็อกการแจ้งเตือนอยู่</span>;
|
||||||
|
return <div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={state === "loading"} onClick={() => void toggle()}>
|
||||||
|
{state === "on" ? <BellOff data-icon="inline-start" /> : <Bell data-icon="inline-start" />}
|
||||||
|
{state === "on" ? "ปิดการแจ้งเตือน" : "เปิดการแจ้งเตือน"}
|
||||||
|
</Button>
|
||||||
|
{error && <span role="alert" className="text-xs text-destructive">{error}</span>}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -4,10 +4,12 @@ import { useRouter } from "next/navigation";
|
|||||||
import { LogOutIcon } from "lucide-react";
|
import { LogOutIcon } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { authClient } from "@/lib/auth/client";
|
import { authClient } from "@/lib/auth/client";
|
||||||
|
import { unsubscribeCommissionPush } from "./push-permission";
|
||||||
|
|
||||||
export function CommissionSignOut() {
|
export function CommissionSignOut() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
return <Button type="button" variant="ghost" size="icon-sm" aria-label="ออกจากระบบ" onClick={async () => {
|
return <Button type="button" variant="ghost" size="icon-sm" aria-label="ออกจากระบบ" onClick={async () => {
|
||||||
|
await unsubscribeCommissionPush().catch(() => undefined);
|
||||||
await authClient.signOut();
|
await authClient.signOut();
|
||||||
router.push("/commission");
|
router.push("/commission");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { Message, MessageAvatar, MessageContent, MessageFooter, MessageGroup, Me
|
|||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { parseCommissionMessageEvent } from "@/lib/commission/message-event";
|
||||||
|
import { CommissionPushPermission } from "./push-permission";
|
||||||
|
|
||||||
type ChatMessage = { id: string; authorId: string; authorName: string; text: string | null;
|
type ChatMessage = { id: string; authorId: string; authorName: string; text: string | null;
|
||||||
imageUrl: string | null; createdAt: Date; reactions: { userId: string; emoji: string }[] };
|
imageUrl: string | null; createdAt: Date; reactions: { userId: string; emoji: string }[] };
|
||||||
@@ -55,8 +57,8 @@ function CommissionMessageImage({ src }: { src: string }) {
|
|||||||
</Dialog>;
|
</Dialog>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CommissionTicketChat({ ticketId, userId, status, messages, admin }: {
|
export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, messages, admin }: {
|
||||||
ticketId: string; userId: string; status: string; messages: ChatMessage[]; admin: boolean;
|
ticketId: string; ticketTitle: string; userId: string; status: string; messages: ChatMessage[]; admin: boolean;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -102,6 +104,18 @@ export function CommissionTicketChat({ ticketId, userId, status, messages, admin
|
|||||||
}, [messages, optimisticMessages]);
|
}, [messages, optimisticMessages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const originalTitle = document.title;
|
||||||
|
let managedTitle = ticketTitle;
|
||||||
|
let unread = 0;
|
||||||
|
document.title = ticketTitle;
|
||||||
|
const markRead = () => {
|
||||||
|
if (document.hidden || !document.hasFocus()) return;
|
||||||
|
unread = 0;
|
||||||
|
managedTitle = ticketTitle;
|
||||||
|
document.title = ticketTitle;
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", markRead);
|
||||||
|
window.addEventListener("focus", markRead);
|
||||||
const source = new EventSource(`/api/commission/events?ticketId=${ticketId}`);
|
const source = new EventSource(`/api/commission/events?ticketId=${ticketId}`);
|
||||||
let typingTimeout: ReturnType<typeof setTimeout> | undefined;
|
let typingTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
@@ -110,7 +124,15 @@ export function CommissionTicketChat({ ticketId, userId, status, messages, admin
|
|||||||
};
|
};
|
||||||
const receive = (event: Event) => {
|
const receive = (event: Event) => {
|
||||||
const value = (event as MessageEvent<string>).data;
|
const value = (event as MessageEvent<string>).data;
|
||||||
if (!value.startsWith("typing:")) { refresh(); return; }
|
if (!value.startsWith("typing:")) {
|
||||||
|
const message = parseCommissionMessageEvent(value);
|
||||||
|
if (message && message.authorId !== userId && (document.hidden || !document.hasFocus())) {
|
||||||
|
unread += 1;
|
||||||
|
managedTitle = `(${unread}) ${ticketTitle}`;
|
||||||
|
document.title = managedTitle;
|
||||||
|
}
|
||||||
|
refresh(); return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const update = JSON.parse(value.slice(7)) as { userId: string; name: string; active: boolean };
|
const update = JSON.parse(value.slice(7)) as { userId: string; name: string; active: boolean };
|
||||||
if (update.userId === userId || typeof update.name !== "string" || typeof update.active !== "boolean") return;
|
if (update.userId === userId || typeof update.name !== "string" || typeof update.active !== "boolean") return;
|
||||||
@@ -128,8 +150,11 @@ export function CommissionTicketChat({ ticketId, userId, status, messages, admin
|
|||||||
source.removeEventListener("changed", receive);
|
source.removeEventListener("changed", receive);
|
||||||
source.removeEventListener("open", refresh);
|
source.removeEventListener("open", refresh);
|
||||||
source.close();
|
source.close();
|
||||||
|
document.removeEventListener("visibilitychange", markRead);
|
||||||
|
window.removeEventListener("focus", markRead);
|
||||||
|
if (document.title === managedTitle) document.title = originalTitle;
|
||||||
};
|
};
|
||||||
}, [ticketId, userId, router]);
|
}, [ticketId, ticketTitle, userId, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const viewport = scrollRef.current;
|
const viewport = scrollRef.current;
|
||||||
@@ -278,7 +303,8 @@ export function CommissionTicketChat({ ticketId, userId, status, messages, admin
|
|||||||
<CardHeader className="border-b py-3">
|
<CardHeader className="border-b py-3">
|
||||||
<CardTitle>แชต{admin ? "กับลูกค้า" : "กับทีมงาน"}</CardTitle>
|
<CardTitle>แชต{admin ? "กับลูกค้า" : "กับทีมงาน"}</CardTitle>
|
||||||
<CardDescription>พูดคุยและส่งรูปภาพเกี่ยวกับคำขอนี้</CardDescription>
|
<CardDescription>พูดคุยและส่งรูปภาพเกี่ยวกับคำขอนี้</CardDescription>
|
||||||
<CardAction className="flex items-center gap-2">
|
<CardAction className="flex flex-wrap items-center gap-2">
|
||||||
|
<CommissionPushPermission scope={admin ? "admin" : "customer"} />
|
||||||
<Badge variant={status === "open" ? "secondary" : "outline"}>{status === "open" ? "เปิดอยู่" : "ปิดแล้ว"}</Badge>
|
<Badge variant={status === "open" ? "secondary" : "outline"}>{status === "open" ? "เปิดอยู่" : "ปิดแล้ว"}</Badge>
|
||||||
{admin && <Button variant="outline" size="sm" onClick={toggleStatus}>
|
{admin && <Button variant="outline" size="sm" onClick={toggleStatus}>
|
||||||
{status === "open" ? "ปิด Ticket" : "เปิดอีกครั้ง"}
|
{status === "open" ? "ปิด Ticket" : "เปิดอีกครั้ง"}
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ export async function CommissionTicketDetail({ id, admin }: { id: string; admin:
|
|||||||
<p className="text-sm text-muted-foreground">การชำระเงิน: {data.payment.transRef} - {data.payment.transferredAt.toLocaleString("th-TH")}</p>
|
<p className="text-sm text-muted-foreground">การชำระเงิน: {data.payment.transRef} - {data.payment.transferredAt.toLocaleString("th-TH")}</p>
|
||||||
<CommissionSlipDialog src={publicMediaUrl(data.payment.slipObjectKey)} />
|
<CommissionSlipDialog src={publicMediaUrl(data.payment.slipObjectKey)} />
|
||||||
</CardContent></Card>
|
</CardContent></Card>
|
||||||
<CommissionTicketChat ticketId={id} userId={data.currentUserId} status={data.ticket.status}
|
<CommissionTicketChat ticketId={id} ticketTitle={data.ticket.title || `Ticket #${id.slice(0, 8)}`}
|
||||||
|
userId={data.currentUserId} status={data.ticket.status}
|
||||||
messages={data.messages.map(({ imageObjectKey, ...message }) => ({ ...message,
|
messages={data.messages.map(({ imageObjectKey, ...message }) => ({ ...message,
|
||||||
imageUrl: imageObjectKey ? publicMediaUrl(imageObjectKey) : null }))} admin={admin} />
|
imageUrl: imageObjectKey ? publicMediaUrl(imageObjectKey) : null }))} admin={admin} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -201,6 +201,15 @@ export const commissionPayments = commissionSchema.table("payment", {
|
|||||||
uniqueIndex("commission_payment_trans_ref_unique").on(table.transRef),
|
uniqueIndex("commission_payment_trans_ref_unique").on(table.transRef),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const commissionPushSubscriptions = commissionSchema.table("push_subscription", {
|
||||||
|
endpoint: text("endpoint").primaryKey(),
|
||||||
|
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
scope: varchar("scope", { length: 8 }).notNull(),
|
||||||
|
p256dh: text("p256dh").notNull(),
|
||||||
|
auth: text("auth").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
}, (table) => [index("commission_push_subscription_user_idx").on(table.userId)]);
|
||||||
|
|
||||||
export const commissionMessages = commissionSchema.table("message", {
|
export const commissionMessages = commissionSchema.table("message", {
|
||||||
id: uuid("id").defaultRandom().primaryKey(),
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
ticketId: uuid("ticket_id").notNull().references(() => commissionTickets.id),
|
ticketId: uuid("ticket_id").notNull().references(() => commissionTickets.id),
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE "commission"."push_subscription" (
|
||||||
|
"endpoint" text PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"scope" varchar(8) NOT NULL,
|
||||||
|
"p256dh" text NOT NULL,
|
||||||
|
"auth" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "commission"."push_subscription" ADD CONSTRAINT "push_subscription_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "commission_push_subscription_user_idx" ON "commission"."push_subscription" USING btree ("user_id");
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -477,6 +477,13 @@
|
|||||||
"when": 1790863872049,
|
"when": 1790863872049,
|
||||||
"tag": "0067_nervous_psylocke",
|
"tag": "0067_nervous_psylocke",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 68,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1790915901646,
|
||||||
|
"tag": "0068_concerned_union_jack",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -128,6 +128,24 @@ spec:
|
|||||||
name: buzz-sheet-env
|
name: buzz-sheet-env
|
||||||
key: COMMISSION_DISCORD_WEBHOOK_URL
|
key: COMMISSION_DISCORD_WEBHOOK_URL
|
||||||
optional: true
|
optional: true
|
||||||
|
- name: WEB_PUSH_PUBLIC_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: buzz-sheet-env
|
||||||
|
key: WEB_PUSH_PUBLIC_KEY
|
||||||
|
optional: true
|
||||||
|
- name: WEB_PUSH_PRIVATE_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: buzz-sheet-env
|
||||||
|
key: WEB_PUSH_PRIVATE_KEY
|
||||||
|
optional: true
|
||||||
|
- name: WEB_PUSH_SUBJECT
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: buzz-sheet-env
|
||||||
|
key: WEB_PUSH_SUBJECT
|
||||||
|
optional: true
|
||||||
- name: NEXT_SERVER_ACTIONS_ENCRYPTION_KEY
|
- name: NEXT_SERVER_ACTIONS_ENCRYPTION_KEY
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
|
|||||||
@@ -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;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function notifyCommission(ticketId: string, userId: string) {
|
export async function notifyCommission(ticketId: string, userId: string, payload = "changed") {
|
||||||
try {
|
try {
|
||||||
const client = await getRedisClient();
|
const client = await getRedisClient();
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
client.publish(redisEventChannel(`commission:ticket:${ticketId}`), "changed"),
|
client.publish(redisEventChannel(`commission:ticket:${ticketId}`), payload),
|
||||||
client.publish(redisEventChannel(`commission:user:${userId}`), "changed"),
|
client.publish(redisEventChannel(`commission:user:${userId}`), payload),
|
||||||
client.publish(redisEventChannel("commission:admin"), "changed"),
|
client.publish(redisEventChannel("commission:admin"), payload),
|
||||||
]);
|
]);
|
||||||
} catch {
|
} catch {
|
||||||
// The database is authoritative; a reconnect or page refresh catches up.
|
// The database is authoritative; a reconnect or page refresh catches up.
|
||||||
|
|||||||
@@ -64,6 +64,7 @@
|
|||||||
"sonner": "^2.0.8",
|
"sonner": "^2.0.8",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -74,6 +75,7 @@
|
|||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@types/sanitize-html": "^2.16.1",
|
"@types/sanitize-html": "^2.16.1",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"@vitest/coverage-v8": "^4.1.11",
|
"@vitest/coverage-v8": "^4.1.11",
|
||||||
"drizzle-kit": "0.31.11",
|
"drizzle-kit": "0.31.11",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
self.addEventListener("push", (event) => {
|
||||||
|
if (!event.data) return;
|
||||||
|
const message = event.data.json();
|
||||||
|
event.waitUntil(self.registration.showNotification(message.title, {
|
||||||
|
body: message.body,
|
||||||
|
icon: "/icon.png",
|
||||||
|
tag: message.id,
|
||||||
|
data: { url: message.url },
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("notificationclick", (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
const url = new URL(event.notification.data.url, self.location.origin);
|
||||||
|
if (url.origin !== self.location.origin) return;
|
||||||
|
event.waitUntil((async () => {
|
||||||
|
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||||
|
const existing = windows.find((client) => client.url === url.href);
|
||||||
|
if (existing) return existing.focus();
|
||||||
|
return self.clients.openWindow(url.href);
|
||||||
|
})());
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user