The previous commit widened the marketing consent sentence to cover the Brevo tracker, so one checkbox carried both purposes. That is the specific pattern GDPR rejects: consent has to be granular, and current EDPB guidance treats bundling tracking consent with subscription consent as invalid because the customer cannot accept one purpose and refuse the other. Quebec's Law 25 s.8.1 is stricter again — profiling technology has to be off until the person switches it on, with no pre-ticked box and no consent inherited from agreeing to something else. Building to both standards was the decision, since the storefront is publicly reachable and anyone can register. So the marketing sentence is restored to exactly what it was, which leaves every existing email consent valid and untouched, and analytics gets its own column, its own sentence, its own checkbox at registration, its own toggle in the account page and its own endpoint. A customer can now hold either, both, or neither, and withdrawing one does not disturb the other. The migration defaults analytics_consent to false, which is both the honest answer — none of the existing customers was ever asked — and what Law 25 requires. Nothing about this change opts anybody in. Two details that are compliance requirements rather than wording preferences. The sentence names Brevo instead of saying "our email provider", because informed consent means the customer can tell who receives their data and a description they cannot act on is not disclosure. And the account toggle is as prominent and as easy to switch off as it is to switch on, because withdrawal has to be as easy as consenting. The analytics endpoint is separate from the marketing one rather than a second field on it, so that a single call cannot change an answer the customer did not touch — the bundling problem moved from the form into the API. The unit tests now assert the two consents stay apart in both directions, including that the marketing sentence still says nothing about tracking, because re-bundling them would otherwise pass silently and is the mistake this project already made once. Verified: backend tsc clean, both lint suites 0 errors with no new warnings, 478 unit tests passing across 33 suites, frontend production build green. Not verified: the migration has not been run against a database, and integration and e2e need a Node this machine does not have active. None of this is legal advice and the wording is worth a lawyer's eye before it ships. Refs #56 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
142 lines
6.4 KiB
TypeScript
Executable File
142 lines
6.4 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];
|
|
}
|
|
|
|
/**
|
|
* Email marketing only. Deliberately says nothing about tracking.
|
|
*
|
|
* This was briefly widened during #56 to cover analytics as well, and that was
|
|
* wrong: GDPR requires consent to be granular, and current EDPB guidance treats
|
|
* bundling tracking consent with subscription consent as invalid because the
|
|
* customer cannot accept one purpose and refuse the other. Quebec's Law 25 is
|
|
* stricter still. Analytics has its own sentence and its own column below.
|
|
*
|
|
* Left exactly as it was so that every existing consent record stays valid and
|
|
* untouched — nobody has to be re-asked for something they already agreed to.
|
|
*/
|
|
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.';
|
|
|
|
/**
|
|
* Consent to the Brevo tracker (#56). Separate from marketing consent, and
|
|
* separately refusable, because they are two purposes with two recipients.
|
|
*
|
|
* Names Brevo rather than saying "our email provider": informed consent means
|
|
* the customer can tell who receives their data, and a description they cannot
|
|
* act on is not disclosure. Says what is shared and why, states that it is
|
|
* optional and independent of the emails, and states that it can be turned off
|
|
* — withdrawal has to be as easy as giving it.
|
|
*
|
|
* Stored verbatim in `analytics_consent_text` for the same reason the marketing
|
|
* sentence is: a record of consent that does not say what was consented to
|
|
* cannot be audited, and re-wording this later must not silently broaden
|
|
* anybody's agreement.
|
|
*/
|
|
export const ANALYTICS_CONSENT_TEXT =
|
|
'I agree that what I browse and buy on this site may be shared with Brevo, the service that sends our emails, so that what they contain is relevant to me. This is optional, separate from receiving the emails themselves, and I can turn it off at any time.';
|
|
|
|
/**
|
|
* Strips trailing slashes so a base URL can be joined with a stored path.
|
|
*
|
|
* A loop rather than `/\/+$/`, which backtracks: sonarjs flags that pattern as
|
|
* super-linear, and the input here is an environment variable rather than
|
|
* anything hostile, but the cheap version is no harder to read.
|
|
*
|
|
* Shared because two callers now need it — `/api/config` sends
|
|
* `uploadsBaseUrl` this way, and the upload-link routes build a submission URL
|
|
* from PUBLIC_URL. Stored paths always begin with a slash, so trimming the
|
|
* base is what stops the join producing a double.
|
|
*/
|
|
export function trimTrailingSlashes(value: string): string {
|
|
let trimmed = value;
|
|
while (trimmed.endsWith('/')) trimmed = trimmed.slice(0, -1);
|
|
return trimmed;
|
|
}
|
|
|
|
/**
|
|
* A route's `:id` as a positive integer, or null when it is not one.
|
|
*
|
|
* Guarding this is not cosmetic. `Number('abc')` is NaN, which the driver sends
|
|
* to Postgres as the text "NaN"; Postgres raises 22P02 for an integer column,
|
|
* the route's catch turns that into a 500, and a caller asking for an item that
|
|
* cannot exist is told the server broke. Returning null lets the route answer
|
|
* 404, which is what "/items/abc" actually means. See #207.
|
|
*
|
|
* Rejects 0 and negatives as well as fractions: every id in this schema is a
|
|
* positive serial, so anything else identifies nothing.
|
|
*/
|
|
export function readId(value: string | undefined): number | null {
|
|
if (value === undefined || value.trim() === '') return null;
|
|
const parsed = Number(value);
|
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
|
}
|