Indexing an array now yields `T | undefined`, which is what it always did — the compiler simply says so. Enabled in backend/tsconfig.json and frontend/tsconfig.json, and in tsconfig.sonar.json alongside it so the drift guard stays satisfied. The sequencing this issue insisted on was right, and the numbers show why. Before #159 typed the query results, `rows[0]` was `any` and `any` indexes to `any`: the flag would have found close to nothing and the count would have changed completely afterwards. With the rows typed it finds 57 — 50 in the backend, 4 in the frontend, 3 in the Playwright suite — and they fall into three kinds. Thirty are `rows[0]` after a `rows.length` guard. TypeScript cannot connect the two, and rewriting them as `const [row] = rows; if (!row) …` makes the guard and the use the same check, which is better code independently of the flag. Ten are rows a statement guarantees — `INSERT … RETURNING`, or a lookup for an id the session middleware has already matched. These get `requireRow(rows, what)`, a new helper in db.ts that throws naming the query. A thrown error rather than a non-null assertion: if the assumption is ever wrong, an assertion hands `undefined` to the next line and fails somewhere unrelated, whereas this fails at the query and says which. asyncRoute turns it into a 500, which is the right answer for "the database did not do what the statement says it does". It also states the assumption once instead of ten times. The rest is ordinary indexing the compiler cannot prove: a regex capture group that the pattern guarantees, `split('+')[0]`, a modulo kept in range, `hasOwnProperty` failing to narrow an index signature, and Express typing route params as an index signature so `req.params.itemId` is `string | undefined` on a route that cannot match without it. One correction to this issue's premise, which matters for what it was expected to find. The body says "in a handful it does not guard at all", and the unguarded-500 risk it describes was not found. Every `rows[0]` either sits behind a length check or behind a statement that guarantees a row. What the flag actually bought was the ten places where that guarantee was real but unstated, and those now say so. Two changes worth calling out because they are not mechanical. TAG_COLORS is typed `[string, ...string[]]` in both copies rather than `as const` — the first attempt used `as const`, which narrowed the elements to literals and broke adminTags, so the annotation keeps `string` while telling the compiler index 0 exists. And the filter drawer's slider falls back to the bounds it was given rather than to null, because null there reads as "no filter" and would widen the results rather than leave them unchanged. Test files needed changes too, since ts-jest compiles them against the same config: a regex destructure in the compose guard, and ten `mock.calls[0][0]` reads where the surrounding assertions already establish the call happened. Verified: tsc clean over backend, frontend src and the Playwright suite; unit 254/254; integration 238/238; frontend build clean; lint unchanged in both workspaces. Closes #101
76 lines
3.2 KiB
TypeScript
Executable File
76 lines
3.2 KiB
TypeScript
Executable File
export function toCents(price: string | number): number {
|
|
const n = typeof price === 'string' ? parseFloat(price) : price;
|
|
if (Number.isNaN(n) || n < 0) {
|
|
throw new Error('invalid price');
|
|
}
|
|
return Math.round(n * 100);
|
|
}
|
|
|
|
export function formatPrice(cents: number): string {
|
|
return `$${(cents / 100).toFixed(2)}`;
|
|
}
|
|
|
|
// RFC 5321 caps an address at 254 characters; reject anything longer up front so
|
|
// validation cost stays bounded regardless of what a client posts.
|
|
const MAX_EMAIL_LENGTH = 254;
|
|
|
|
// Both patterns are anchored single character classes with no overlapping
|
|
// alternatives, so they match in linear time. Splitting on '@' and '.' in code
|
|
// rather than in one combined pattern avoids the ambiguous (and backtracking)
|
|
// `[^\s@]+\.[^\s@]+` domain match.
|
|
const LOCAL_PART_RE = /^[^\s@]+$/;
|
|
const DOMAIN_LABEL_RE = /^[^\s@.]+$/;
|
|
|
|
export function isValidEmail(email: string): boolean {
|
|
const trimmed = email.trim();
|
|
if (trimmed.length === 0 || trimmed.length > MAX_EMAIL_LENGTH) {
|
|
return false;
|
|
}
|
|
|
|
const at = trimmed.indexOf('@');
|
|
if (at === -1 || at !== trimmed.lastIndexOf('@')) {
|
|
return false;
|
|
}
|
|
|
|
if (!LOCAL_PART_RE.test(trimmed.slice(0, at))) {
|
|
return false;
|
|
}
|
|
|
|
const labels = trimmed.slice(at + 1).split('.');
|
|
return labels.length >= 2 && labels.every((label) => DOMAIN_LABEL_RE.test(label));
|
|
}
|
|
|
|
// antd's preset Tag colours. Kept as the single source of truth for tag
|
|
// colours so the admin palette picker and the auto-assignment below can never
|
|
// drift apart — the frontend renders whatever string lands in tags.color.
|
|
export const TAG_COLORS: [string, ...string[]] = [
|
|
'magenta', 'red', 'volcano', 'orange', 'gold', 'lime',
|
|
'green', 'cyan', 'blue', 'geekblue', 'purple'
|
|
];
|
|
|
|
// Tags get a colour the moment they're created inline from the item form, with
|
|
// no prompt. Deriving it from the name (rather than picking at random or
|
|
// round-robining on insert order) means the same tag name always lands on the
|
|
// same colour, so a tag deleted and re-added doesn't silently change colour.
|
|
// The admin can still override it afterwards.
|
|
export function tagColorFor(name: string): string {
|
|
const normalized = name.trim().toLowerCase();
|
|
// djb2 — cheap, well-spread for short strings, and stable across Node
|
|
// versions. `| 0` keeps it in int32 range instead of drifting into float.
|
|
let hash = 5381;
|
|
for (let i = 0; i < normalized.length; i++) {
|
|
hash = ((hash << 5) + hash + normalized.charCodeAt(i)) | 0;
|
|
}
|
|
// The modulo keeps this in range, but an index signature cannot say so. The
|
|
// fallback is the first colour rather than a throw: a tag with an unexpected
|
|
// colour is not worth failing a request over.
|
|
// TAG_COLORS is typed as a non-empty tuple, so index 0 is known to exist —
|
|
// the annotation, rather than `as const`, because the elements must stay
|
|
// `string` for the callers that assign them. The modulo keeps the computed
|
|
// index in range; the fallback only exists because indexing cannot say so.
|
|
return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length] ?? TAG_COLORS[0];
|
|
}
|
|
|
|
export const MARKETING_CONSENT_TEXT =
|
|
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
|