CogniPrep has two support front doors. One is a plain form on the public help page that opens with "How urgent is this?" and three options. The other is an in-app widget: a small decision tree, then a predetermined answer, then an AI deflection step, and only then an escalation to a real ticket.
The widget is the interesting one, because when it escalates it does something that deserves a second look. It replays its own conversation into the new ticket:
const res = await fetch('/api/support/tickets', {
method: 'POST',
body: JSON.stringify({ type, subject, category, urgency, message, transcript, ... }),
});
That transcript is client state. It is an array the browser accumulated across the steps, and the server is being asked to persist it verbatim as the opening messages of a thread a human will later read.
Five author values, and who may claim them
A support message has an author:
export type MessageAuthor = 'user' | 'bot' | 'ai' | 'admin' | 'system';
user is the person. bot is the scripted answer the decision tree rendered. ai is the deflection step's answer. system is a line the widget writes about itself. admin is us, replying as a human.
The endpoint validates the payload with zod, and the schema accepts all five:
const transcriptEntrySchema = z.object({
author: z.enum(['user', 'bot', 'ai', 'admin', 'system']),
// Bounded like the escalation message, these bodies are persisted verbatim.
body: z.string().max(SUPPORT_LIMITS.messageMax),
});
And then the service layer, one call deeper, throws two of them away:
// Authors the chat may replay into a new ticket's transcript. An 'admin'/'system'
// line can only be created by us, never seeded from a client payload.
const SEEDABLE_AUTHORS: MessageAuthor[] = ['user', 'bot', 'ai'];
const seeded = input.transcript
.filter((e) => SEEDABLE_AUTHORS.includes(e.author) && e.body.trim().length > 0)
.slice(0, SUPPORT_LIMITS.transcriptMax)
.map((e) => ({ author: e.author, body: e.body.trim() }));
A reviewer's first instinct, and mine when I came back to it, is that the enum should have been narrowed to three values and the whole filter deleted. Then an admin line would be a 400 instead of a silent drop, which is the usual advice: fail loudly at the boundary.
I think the split is right, and the reason is specific rather than philosophical.
Why it accepts what it will not store
The widget genuinely holds system lines, and it writes them itself:
setTranscript((t) => [
...t,
{
author: 'system',
body: "I couldn't reach the assistant just now. Let's get a person to help.",
},
]);
go('escalate');
That is the AI deflection step failing. Look at what happens on the next line: it sends the user straight to the escalation step. So the single most likely transcript to contain a system entry is the transcript of someone escalating because the automated help just broke.
If the schema rejected the payload, that person's ticket would fail to open at the exact moment they had already been let down once, and the error they would see is a validation failure about a field they have never heard of and did not fill in.
So the two layers have different jobs. The schema's job is shape: is this an object with an author and a bounded string, and is the whole request small enough to persist. The service's job is provenance: of the things that are shaped like messages, which ones is a client allowed to claim were said. The layer that owns the table decides who may have spoken, and the only thing it does to a line it will not vouch for is leave it out.
What is actually at stake is small and worth naming. Nobody is defrauded by a forged admin line. But a support agent opening a thread has to be able to trust the attribution, and "we already told you this was fine" is not a sentence a user should be able to put in our mouth, even by accident, even in a thread only the two of us will ever read.
The timestamps have to be written by hand
A detail that cost me a confusing thread before I noticed it. The seeded lines plus the user's escalation message are inserted in one transaction, and each row gets an explicit timestamp:
const base = Date.now();
await tx.insert(supportMessagesTable).values(
lines.map((line, i) => ({ ...line, created_at: new Date(base + i) }))
);
One millisecond apart, by index. A column default of now() stamps every row in a batch with the same instant, because that is what a transaction timestamp is, and the thread then renders in whatever order the rows come back. For a replayed conversation that is not a cosmetic problem: it is a conversation whose question and answer can swap places.
The caps are about the row, not the attack
Three numbers bound what one authenticated request can persist:
transcriptMax: 40, // entries the client may seed a new ticket with
messageMax: 5000, // characters per body
aiHistoryMax: 8, // prior turns the client may replay to the AI route
None of those is a security boundary in the dramatic sense. They exist because the request is already authenticated, and an authenticated request should still not be able to write an arbitrarily large row into our database because somebody left a loop running in a console.
Two small things the same module gets for free
The human-facing reference is derived, not stored:
export function ticketRef(id: string): string {
return `TCK-${id.replace(/-/g, '').slice(0, 8).toUpperCase()}`;
}
No sequence, no counter, no second column that can disagree with the primary key. The same id always produces the same ref, so an email, an admin screen and a user's ticket list cannot drift.
And every read and write of both tables goes through one data-access module that returns DTOs rather than rows. That is why the user-facing endpoint and the admin one share the ownership checks, the status transitions and the ordering instead of each hand-rolling their own SQL. The author filter above is in that module, which is the point: there is exactly one place where a message becomes a row, so there is exactly one place where "who said this" has to be decided.
If you want to see the front door, the public form is on the help page, and what people mostly write in about is the practice itself: the provider hubs and the mock interviews.
Top comments (0)