Design overhaul, stats page, internal notes

Design: Onest+Unbounded font pairing (Cyrillic-verified, replacing plain
Golos Text), manual light/dark toggle wired to the data-theme cascade
already built in MVP-1, refreshed palette + new chart-mark tokens
(CVD-validated via the dataviz skill's validator), colored avatar
initials, per-channel colored badges, favicon, nicer empty states.

Stats: /stats page — status/channel breakdown, 14-day ticket volume,
avg first-response time, per-agent open-ticket workload. Colors and
chart forms follow the dataviz skill's procedure (categorical order
re-stepped to clear the CVD adjacency check in both themes).

Internal notes: messages.visibility ("public"/"internal") column.
Agent-only notes never reach customer-facing reads (getTicketForCustomer)
or the customer SSE stream (/api/portal/events, reused by the widget) —
fixed at both the initial-fetch and live-delivery layers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-07-26 14:38:52 +00:00
1 parent 4efce5f307
commit 9f1413f0b9
23 files changed
+1315 -64

No files matched your search

+8 -1
View File
@@ -20,5 +20,12 @@ export async function GET(request: Request) {
return NextResponse.json({ error: "Invalid token" }, { status: 404 });
}
return createEventStream((event) => ticketEventCustomerId(event) === customer.id);
return createEventStream((event) => {
if (ticketEventCustomerId(event) !== customer.id) return false;
// Internal notes must never reach a customer-facing stream, even though
// the initial page-load fetch (getTicketForCustomer) already filters
// them out — SSE is a second, independent delivery path for the same data.
if (event.type === "message.created" && event.message.visibility === "internal") return false;
return true;
});
}
+12 -1
View File
@@ -3,7 +3,7 @@ export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/auth/require";
import { recordAgentReply, getEmailThreadContext } from "@/lib/tickets/service";
import { recordAgentReply, recordInternalNote, getEmailThreadContext } from "@/lib/tickets/service";
import { sendTelegramMessage } from "@/lib/telegram/bot";
import { sendTicketReplyEmail } from "@/lib/mail/smtp";
import { db } from "@/lib/db/client";
@@ -12,6 +12,7 @@ import { eq } from "drizzle-orm";
const messageSchema = z.object({
body: z.string().min(1).max(10_000),
visibility: z.enum(["public", "internal"]).default("public"),
});
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
@@ -30,6 +31,16 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
return NextResponse.json({ error: "Ticket not found" }, { status: 404 });
}
if (parsed.data.visibility === "internal") {
const result = await recordInternalNote({
ticketId: id,
agentId: session.user.id,
agentName: session.user.name,
body: parsed.data.body,
});
return NextResponse.json(result, { status: 201 });
}
let emailMessageId: string | undefined;
if (ticket.channel === "email") {
const context = await getEmailThreadContext(id);