@assembly-js/app-foundation 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -19,6 +19,15 @@ A patch release never changes what an app's build requires; a minor release may
19
19
  not by hand; see `changes/README.md`. CI fails a PR that changes a check script without a
20
20
  `bump: minor` change file.
21
21
 
22
+ ## 0.14.0
23
+ - [guidance] The build agent now counts related rows per record with `db.$count` or a joined subquery, instead of a raw correlated subselect that drizzle renders without table names and that reads 0 on every row.
24
+ - [behavior] Later edits to the app now catch a per-record count that would always show 0 (for example "0 answers" on every question) before the change is saved, and fix it in the same edit.
25
+
26
+ ## 0.13.0
27
+ - [new] `<ClientName client={client} />` and `<CompanyName company={company} />` from `@assembly-js/app-foundation/lib-client` render a name that opens the record's CRM page for staff and is plain text for a client viewer.
28
+ - [new] `bun run build` runs `check:linked-names`, which fails when a client's or company's name is rendered by hand instead of through those components. It only runs on apps that carry the platform-links skill.
29
+ - [guidance] `assembly-platform-links` leads with rendering client and company names through the components.
30
+
22
31
  ## 0.12.0
23
32
  - [behavior] `check:crons` on a platform update turn now only warns about a cron route the app already had without a schedule; a route added this turn, or one that lost its schedule, still fails.
24
33
 
package/CLAUDE.md CHANGED
@@ -648,6 +648,14 @@ export const items = pgTable('items', {
648
648
  - Use `drizzle-kit push` (not `migrate`) — no migration files; schema is pushed directly.
649
649
  - Use `@neondatabase/serverless` with `drizzle-orm/neon-http` — not `pg` or `postgres.js`. It's the standard driver for the Vercel serverless (Node) runtime this app deploys to; `lib/db.ts` already wires it up.
650
650
  - **No interactive transactions.** `db.transaction(async (tx) => …)` is unsupported on neon-http and throws (a 500). For atomic multi-writes use `db.batch([...])`; otherwise sequential `await`s. See the `assembly-db-transactions` skill.
651
+ - **Per-row counts: `db.$count` or a joined subquery, never a raw correlated subselect.** In a `.select({ … })` field of a query with no join, drizzle writes the columns inside a `sql` template without table names, so `(select count(*) from ${answers} where ${answers.questionId} = ${questions.id})` compares answers with itself and every row counts 0, with no error. (`.where()` is unaffected.) Use one of:
652
+ ```ts
653
+ db.select({ id: questions.id, answerCount: db.$count(answers, and(eq(answers.questionId, questions.id), eq(answers.kind, 'answer'))) }).from(questions);
654
+
655
+ const ac = db.select({ questionId: answers.questionId, c: count().as('c') }).from(answers).where(eq(answers.kind, 'answer')).groupBy(answers.questionId).as('ac');
656
+ db.select({ id: questions.id, answerCount: sql<number>`coalesce(${ac.c}, 0)` }).from(questions).leftJoin(ac, eq(ac.questionId, questions.id));
657
+ ```
658
+ `bun run build` fails on the raw form (`check:sql-subselects`).
651
659
  - **Create writes every field the create form submits.** If a later save writes nested rows (packages, add-ons, blocks), create calls that same writer with the submitted payload. A success toast and a redirect to the editor do not save. See `assembly-record-writes`.
652
660
  - `DATABASE_URL` is already in the shell env. Do NOT hardcode it or write it to `.env.local`.
653
661
  - Use `db` from `lib/db.ts` in **server components, server actions, and route handlers only** — never in client components. This is enforced: `lib/db.ts` imports `server-only`, so pulling it (or anything that imports it, e.g. a lib file re-exporting constants) into a `'use client'` module fails `bun run build`. Keep client-shared constants/types in their own file, not next to server queries.
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env node
2
+ // Fails the build when a client's or company's name is rendered by hand in a
3
+ // component instead of through `<ClientName>` / `<CompanyName>` from
4
+ // `@assembly-js/app-foundation/lib-client`. The components link the name to the
5
+ // record's CRM page for staff and render plain text for a client viewer; a
6
+ // hand-rendered name never links, and the prose guidance to call `linkTo` was
7
+ // not followed. The match favours precision, because a false positive blocks the
8
+ // build: it only looks at a name placed directly in JSX, or a full name built
9
+ // from `X.givenName` and `X.familyName`, where the object is called `client`
10
+ // or `customer` (and `company` for the company name).
11
+ // Dependency free on purpose: it runs inside the generated app's sandbox before
12
+ // any deps beyond Node are guaranteed.
13
+ //
14
+ // Inert unless the platform-links skill was written into the app (it ships only when
15
+ // the deep-link flag is on) and the app depends on the foundation package, which is
16
+ // where the components live: a vendored app has nothing to switch to.
17
+ // On a platform update turn (ASSEMBLY_UPDATE_TURN=1) it only warns, because an
18
+ // update never rewrites how an app renders names.
19
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
20
+ import { join, relative, sep } from 'node:path';
21
+
22
+ const ROOT = process.cwd();
23
+ const UPDATE_TURN = process.env.ASSEMBLY_UPDATE_TURN === '1';
24
+ const PLATFORM_LINKS_SKILL = join(ROOT, '.claude', 'skills', 'assembly-platform-links', 'SKILL.md');
25
+ const SOURCE_FILE = /\.(tsx|jsx)$/;
26
+ const PACKAGE_JSON = join(ROOT, 'package.json');
27
+ const OPT_OUT = /linked-names:\s*plain/;
28
+
29
+ const OBJECT_PATH = String.raw`(?:[\w$]+\??\.)*`;
30
+ const FALLBACK = String.raw`(?:\s*(?:\?\?|\|\|)\s*(?:'[^']*'|"[^"]*"))?`;
31
+ // `{client.givenName}` placed in JSX, not an attribute value (`=`) or a template (`$`).
32
+ const CLIENT_NAME_CHILD = new RegExp(
33
+ String.raw`(?<!=\s*)(?<!\$)\{\s*${OBJECT_PATH}(?:client|customer)\??\.(?:givenName|familyName)${FALLBACK}\s*\}`,
34
+ 'gi',
35
+ );
36
+ const COMPANY_NAME_CHILD = new RegExp(
37
+ String.raw`(?<!=\s*)(?<!\$)\{\s*${OBJECT_PATH}company\??\.name${FALLBACK}\s*\}`,
38
+ 'gi',
39
+ );
40
+ // `${client.givenName} ${client.familyName}`, `[client.givenName, client.familyName].join(' ')`
41
+ // and `client.givenName + ' ' + client.familyName`; a lone `[0]` initial is not a name.
42
+ const FULL_NAME = new RegExp(
43
+ String.raw`(?:` +
44
+ String.raw`\$\{\s*${OBJECT_PATH}(?:client|customer)\??\.givenName\s*\}[^\`]{0,20}\$\{\s*${OBJECT_PATH}(?:client|customer)\??\.familyName\s*\}` +
45
+ String.raw`|\[\s*${OBJECT_PATH}(?:client|customer)\??\.givenName\s*,\s*${OBJECT_PATH}(?:client|customer)\??\.familyName\s*\]` +
46
+ String.raw`|${OBJECT_PATH}(?:client|customer)\??\.givenName\s*\+\s*['"\`][^'"\`]*['"\`]\s*\+\s*${OBJECT_PATH}(?:client|customer)\??\.familyName` +
47
+ String.raw`)`,
48
+ 'gi',
49
+ );
50
+
51
+ function sourceFiles(dir) {
52
+ const out = [];
53
+ if (!existsSync(dir)) return out;
54
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
55
+ const full = join(dir, entry.name);
56
+ if (entry.isDirectory()) {
57
+ if (entry.name === 'node_modules' || entry.name === '.next') continue;
58
+ out.push(...sourceFiles(full));
59
+ } else if (SOURCE_FILE.test(entry.name)) {
60
+ out.push(full);
61
+ }
62
+ }
63
+ return out;
64
+ }
65
+
66
+ // An attribute value (`alt={...}`, `title={`...`}`) is not rendered text.
67
+ function inAttribute(src, index) {
68
+ return /=\s*\{\s*`?$/.test(src.slice(Math.max(0, index - 16), index));
69
+ }
70
+
71
+ // A built string that is not rendered as JSX text (an object label, a filter key) cannot
72
+ // be a component; only a name sitting in text, or returned/assigned for display, is flagged.
73
+ function inStringOnlyUse(src, index) {
74
+ const lineStart = src.lastIndexOf('\n', index) + 1;
75
+ return /(?:\blabel|\bvalue|\bkey|\bsearch\w*|\bfilter\w*|\bquery)\s*[:=]\s*[^\n]*$/i.test(src.slice(lineStart, index));
76
+ }
77
+
78
+ function lineOf(src, index) {
79
+ return src.slice(0, index).split('\n').length;
80
+ }
81
+
82
+ if (!existsSync(PLATFORM_LINKS_SKILL)) process.exit(0);
83
+ try {
84
+ if (!readFileSync(PACKAGE_JSON, 'utf8').includes('app-foundation')) process.exit(0);
85
+ } catch {
86
+ process.exit(0);
87
+ }
88
+
89
+ const violations = [];
90
+ for (const file of [...sourceFiles(join(ROOT, 'app')), ...sourceFiles(join(ROOT, 'components'))]) {
91
+ const src = readFileSync(file, 'utf8');
92
+ if (OPT_OUT.test(src)) continue;
93
+ const where = relative(ROOT, file).split(sep).join('/');
94
+ const found = new Map();
95
+ for (const [re, kind] of [
96
+ [CLIENT_NAME_CHILD, 'client name'],
97
+ [FULL_NAME, 'client name'],
98
+ [COMPANY_NAME_CHILD, 'company name'],
99
+ ]) {
100
+ for (const m of src.matchAll(re)) if (!inAttribute(src, m.index) && !(re === FULL_NAME && inStringOnlyUse(src, m.index))) found.set(m.index, `${where}:${lineOf(src, m.index)} (${kind}: ${m[0].trim()})`);
101
+ }
102
+ violations.push(...[...found.entries()].sort((a, b) => a[0] - b[0]).map(([, v]) => v));
103
+ }
104
+
105
+ if (violations.length > 0 && UPDATE_TURN) {
106
+ console.warn('');
107
+ console.warn('check-linked-names: names rendered by hand (this update turn is not failed for it):');
108
+ for (const v of violations) console.warn(' - ' + v);
109
+ console.warn('');
110
+ } else if (violations.length > 0) {
111
+ console.error('');
112
+ console.error('A client or company name is rendered by hand, so it never links to its record:');
113
+ console.error('');
114
+ for (const v of violations) console.error(' - ' + v);
115
+ console.error('');
116
+ console.error("Render it with the components from '@assembly-js/app-foundation/lib-client':");
117
+ console.error(' <ClientName client={client} className="..." /> // a client with givenName/familyName');
118
+ console.error(' <CompanyName company={company} className="..." /> // a company with name');
119
+ console.error('Staff get a link to the CRM record; a client viewer gets plain text.');
120
+ console.error('A name that must stay plain (the viewer\'s own greeting, a title attribute) can add the');
121
+ console.error('comment `// linked-names: plain` to the file.');
122
+ console.error('A string-only use (a `label:` or `filter` value) is not flagged.');
123
+ process.exit(1);
124
+ }
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env node
2
+ // Fails the build when a raw `sql` template in a drizzle `.select({ … })` field
3
+ // list runs a correlated subselect against another table and the statement has
4
+ // no join. drizzle renders columns embedded in a select field as bare names when
5
+ // the query has a single table, so `where ${answers.questionId} = ${questions.id}`
6
+ // becomes `where "question_id" = "id"`: the inner table is compared with itself,
7
+ // the SQL stays valid, and a per-row count is 0 for every row. A field that names
8
+ // the template through a const in the same file is followed too.
9
+ // Dependency free on purpose: it runs inside the generated app's sandbox before
10
+ // any deps beyond Node are guaranteed.
11
+ //
12
+ // On a platform update turn (ASSEMBLY_UPDATE_TURN=1) it only warns, because an
13
+ // update never rewrites an app's queries.
14
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
15
+ import { join, relative, sep } from 'node:path';
16
+
17
+ const ROOT = process.cwd();
18
+ const UPDATE_TURN = process.env.ASSEMBLY_UPDATE_TURN === '1';
19
+ const SOURCE_DIRS = ['app', 'lib', 'components', 'src'];
20
+ const SOURCE_FILE = /\.(tsx|ts|jsx|js|mjs)$/;
21
+ const OPT_OUT = /sql-subselects:\s*qualified/;
22
+ const SELECT_OBJECT = /\.select\s*\(\s*\{/g;
23
+ const SQL_TEMPLATE = /\bsql\s*(?:<[^`;]*?>)?\s*`/g;
24
+ // `const answerCountSql = sql\`…\`` or `const answerCountSql = (…) => sql\`…\``.
25
+ const NAMED_TEMPLATE =
26
+ /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=;]+)?=\s*(?:\([^)]*\)\s*(?::[^=;]+)?=>\s*)?sql\s*(?:<[^`;]*?>)?\s*`/g;
27
+ const DECLARATION = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)/g;
28
+ const FIELD_VALUE = /:\s*([A-Za-z_$][\w$]*)\b/g;
29
+ const SUBSELECT = /\(\s*select\b/i;
30
+ const FROM_TABLE = /\bfrom\s+\$\{\s*([A-Za-z_$][\w$]*)\s*\}/gi;
31
+ const COLUMN_REF = /\$\{\s*([A-Za-z_$][\w$]*)\.[A-Za-z_$]/g;
32
+ const JOIN = /\.(?:left|inner|right|full|cross)Join(?:Lateral)?\s*\(/;
33
+ // A line that starts a new statement; without semicolons the next query would
34
+ // otherwise be read as part of this one and its join would excuse this query.
35
+ const NEXT_STATEMENT = /^\n\s*(?:export|const|let|var|function|async|return|await|if|for|while|class|import)\b/;
36
+
37
+ function sourceFiles(dir) {
38
+ const out = [];
39
+ if (!existsSync(dir)) return out;
40
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
41
+ const full = join(dir, entry.name);
42
+ if (entry.isDirectory()) {
43
+ if (entry.name === 'node_modules' || entry.name === '.next') continue;
44
+ out.push(...sourceFiles(full));
45
+ } else if (SOURCE_FILE.test(entry.name)) {
46
+ out.push(full);
47
+ }
48
+ }
49
+ return out;
50
+ }
51
+
52
+ // Index just past a string, template literal or comment starting at `i`, or `i`
53
+ // when none starts there. Templates recurse through their `${…}` holes.
54
+ function skipLiteral(src, i) {
55
+ const c = src[i];
56
+ if (c === '/' && src[i + 1] === '/') {
57
+ const end = src.indexOf('\n', i);
58
+ return end === -1 ? src.length : end;
59
+ }
60
+ if (c === '/' && src[i + 1] === '*') {
61
+ const end = src.indexOf('*/', i + 2);
62
+ return end === -1 ? src.length : end + 2;
63
+ }
64
+ if (c === '"' || c === "'") {
65
+ let j = i + 1;
66
+ while (j < src.length && src[j] !== c && src[j] !== '\n') j += src[j] === '\\' ? 2 : 1;
67
+ return j + 1;
68
+ }
69
+ if (c === '`') return templateEnd(src, i);
70
+ return i;
71
+ }
72
+
73
+ // Index just past the template literal whose opening backtick is at `i`.
74
+ function templateEnd(src, i) {
75
+ let j = i + 1;
76
+ while (j < src.length) {
77
+ if (src[j] === '\\') j += 2;
78
+ else if (src[j] === '`') return j + 1;
79
+ else if (src[j] === '$' && src[j + 1] === '{') j = closingIndex(src, j + 1) + 1;
80
+ else j += 1;
81
+ }
82
+ return src.length;
83
+ }
84
+
85
+ // Index of the bracket that closes the one at `i`.
86
+ function closingIndex(src, i) {
87
+ let depth = 0;
88
+ let j = i;
89
+ while (j < src.length) {
90
+ const skipped = skipLiteral(src, j);
91
+ if (skipped !== j) {
92
+ j = skipped;
93
+ continue;
94
+ }
95
+ const c = src[j];
96
+ if (c === '(' || c === '[' || c === '{') depth += 1;
97
+ else if (c === ')' || c === ']' || c === '}') {
98
+ depth -= 1;
99
+ if (depth === 0) return j;
100
+ }
101
+ j += 1;
102
+ }
103
+ return src.length;
104
+ }
105
+
106
+ // The statement a `.select(` belongs to, from it to the next `;` at depth 0, a
107
+ // new statement on a later line, or the bracket that closes the expression around it.
108
+ function statementTail(src, i) {
109
+ let depth = 0;
110
+ let j = i;
111
+ while (j < src.length) {
112
+ const skipped = skipLiteral(src, j);
113
+ if (skipped !== j) {
114
+ j = skipped;
115
+ continue;
116
+ }
117
+ const c = src[j];
118
+ if (c === '(' || c === '[' || c === '{') depth += 1;
119
+ else if (c === ')' || c === ']' || c === '}') {
120
+ depth -= 1;
121
+ if (depth < 0) break;
122
+ } else if (depth === 0 && (c === ';' || (c === '\n' && NEXT_STATEMENT.test(src.slice(j, j + 40))))) break;
123
+ j += 1;
124
+ }
125
+ return src.slice(i, j);
126
+ }
127
+
128
+ // Bodies of every `sql` template in `text`, with their offset in it.
129
+ function templatesIn(text) {
130
+ const out = [];
131
+ for (const m of text.matchAll(SQL_TEMPLATE)) {
132
+ const start = m.index + m[0].length - 1;
133
+ out.push({ body: text.slice(start, templateEnd(text, start)), offset: m.index });
134
+ }
135
+ return out;
136
+ }
137
+
138
+ function correlatesAnotherTable(body) {
139
+ if (!SUBSELECT.test(body)) return false;
140
+ const inner = new Set([...body.matchAll(FROM_TABLE)].map((m) => m[1]));
141
+ if (inner.size === 0) return false;
142
+ return [...body.matchAll(COLUMN_REF)].some((m) => !inner.has(m[1]));
143
+ }
144
+
145
+ // The source with comments and quoted strings blanked out (newlines and quotes
146
+ // kept, so offsets and line numbers still match), so a query kept in a comment
147
+ // or an example string is never checked. Template literals stay: `sql` is one.
148
+ function withoutComments(src) {
149
+ let out = '';
150
+ let i = 0;
151
+ while (i < src.length) {
152
+ const end = skipLiteral(src, i);
153
+ if (end === i) {
154
+ out += src[i];
155
+ i += 1;
156
+ continue;
157
+ }
158
+ const chunk = src.slice(i, end);
159
+ if (src[i] === '/') out += chunk.replace(/[^\n]/g, ' ');
160
+ else if (src[i] === '`') out += chunk;
161
+ else out += chunk[0] + chunk.slice(1, -1).replace(/[^\n]/g, ' ') + chunk.slice(-1);
162
+ i = end;
163
+ }
164
+ return out;
165
+ }
166
+
167
+ function lineAt(src, index) {
168
+ return src.slice(0, index).split('\n').length;
169
+ }
170
+
171
+ const files = SOURCE_DIRS.flatMap((dir) => sourceFiles(join(ROOT, dir)));
172
+ const violations = [];
173
+ for (const file of files) {
174
+ const raw = readFileSync(file, 'utf8');
175
+ if (!raw.includes('.select(') || OPT_OUT.test(raw)) continue;
176
+ const src = withoutComments(raw);
177
+
178
+ // A name declared more than once in the file cannot be tied to one template
179
+ // without scope analysis, so it is not followed rather than guessed.
180
+ const declared = new Map();
181
+ for (const m of src.matchAll(DECLARATION)) declared.set(m[1], (declared.get(m[1]) ?? 0) + 1);
182
+ const named = new Map();
183
+ for (const m of src.matchAll(NAMED_TEMPLATE)) {
184
+ if (declared.get(m[1]) > 1) continue;
185
+ const start = m.index + m[0].length - 1;
186
+ named.set(m[1], src.slice(start, templateEnd(src, start)));
187
+ }
188
+
189
+ for (const m of src.matchAll(SELECT_OBJECT)) {
190
+ const objStart = m.index + m[0].length - 1;
191
+ const fields = src.slice(objStart, closingIndex(src, objStart) + 1);
192
+ const hits = templatesIn(fields)
193
+ .filter((t) => correlatesAnotherTable(t.body))
194
+ .map((t) => objStart + t.offset);
195
+ for (const v of fields.matchAll(FIELD_VALUE)) {
196
+ const body = named.get(v[1]);
197
+ if (body && correlatesAnotherTable(body)) hits.push(objStart + v.index);
198
+ }
199
+ if (hits.length === 0 || JOIN.test(statementTail(src, m.index))) continue;
200
+ const rel = relative(ROOT, file).split(sep).join('/');
201
+ for (const at of hits) violations.push(`${rel}:${lineAt(src, at)}`);
202
+ }
203
+ }
204
+
205
+ if (violations.length > 0 && UPDATE_TURN) {
206
+ console.warn('');
207
+ console.warn('check-sql-subselects: correlated sql subselect in a single-table select (this update turn is not failed for it):');
208
+ for (const v of violations) console.warn(' - ' + v);
209
+ console.warn('');
210
+ } else if (violations.length > 0) {
211
+ console.error('');
212
+ console.error('A raw sql subselect in a .select({ … }) field reads another table, and the query has no join:');
213
+ console.error('');
214
+ for (const v of violations) console.error(' - ' + v);
215
+ console.error('');
216
+ console.error('drizzle writes the columns in that template without table names when the query has');
217
+ console.error('one table, so `${answers.questionId} = ${questions.id}` compares answers with itself');
218
+ console.error('and the count is 0 on every row. Use db.$count as the field instead:');
219
+ console.error(' answerCount: db.$count(answers, and(eq(answers.questionId, questions.id), eq(answers.kind, "answer")))');
220
+ console.error('or group the counts in a subquery (.groupBy(...).as("ac")) and .leftJoin it.');
221
+ console.error('A template whose names are already qualified (sql.raw) can add the comment');
222
+ console.error('`// sql-subselects: qualified` to the file.');
223
+ process.exit(1);
224
+ }
@@ -1,4 +1,6 @@
1
+ import type { ReactElement } from 'react';
1
2
  import type { NavigateDestination, NavigateToOptions } from './bridge-vnext.js';
3
+ import { type ClientNameRecord, type CompanyNameRecord } from './record-names.js';
2
4
  export declare function resetRenewalStateForTests(): void;
3
5
  export declare function reexchangeBearer(): Promise<boolean>;
4
6
  export declare function authFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
@@ -28,3 +30,22 @@ export declare function handleVisibilityChangeForTests(): void;
28
30
  * </button>
29
31
  */
30
32
  export declare function linkTo(destination: NavigateDestination, options?: NavigateToOptions): () => void;
33
+ interface RecordNameProps {
34
+ className?: string;
35
+ viewer?: 'internal' | 'client';
36
+ }
37
+ /**
38
+ * A client's name that opens their CRM record for staff and is plain text for a
39
+ * client viewer, who cannot reach the destination.
40
+ *
41
+ * @example
42
+ * <ClientName client={client} className="font-medium" />
43
+ */
44
+ export declare function ClientName({ client, className, viewer }: RecordNameProps & {
45
+ client: ClientNameRecord;
46
+ }): ReactElement;
47
+ /** A company's name that opens its CRM record for staff and is plain text for a client viewer. */
48
+ export declare function CompanyName({ company, className, viewer }: RecordNameProps & {
49
+ company: CompanyNameRecord;
50
+ }): ReactElement;
51
+ export {};
@@ -5,7 +5,10 @@
5
5
  // never be dragged into the Edge middleware bundle that imports ./client
6
6
  // (proxy.ts pulls constants from that barrel).
7
7
  import { AssemblyBridge } from '@assembly-js/app-bridge';
8
+ import { createElement } from 'react';
9
+ import { usePathname } from 'next/navigation';
8
10
  import { APP_CSRF_HEADER, COOKIE_PROBE_NAME, logRenewOutcome, TOKEN_EXPIRED_HEADER, } from './session-exchange.js';
11
+ import { clientDisplayName, isClientSurfacePath, } from './record-names.js';
9
12
  import { readBearerExpiry, readCookie } from './origin-bootstrap.js';
10
13
  import { cookielessReexchangeHeaders, cookiesAreBlocked, getCookielessBearer, setCookielessBearer, } from './cookieless-bearer.js';
11
14
  const REEXCHANGE_PATH = '/api/assembly-reexchange';
@@ -338,3 +341,43 @@ export function linkTo(destination, options) {
338
341
  });
339
342
  };
340
343
  }
344
+ function isClientViewer(viewer, pathname) {
345
+ return viewer ? viewer === 'client' : isClientSurfacePath(pathname);
346
+ }
347
+ // A button so the link is keyboard reachable, reset to read as inline text; the
348
+ // colour is only inherited when the app gave no class, so its own colour wins.
349
+ // createElement keeps the package free of a jsx compiler setting.
350
+ function recordName(name, destination, className) {
351
+ if (!destination)
352
+ return createElement('span', { className }, name);
353
+ return createElement('button', {
354
+ type: 'button',
355
+ className,
356
+ onClick: linkTo(destination),
357
+ style: {
358
+ background: 'none',
359
+ border: 0,
360
+ padding: 0,
361
+ font: 'inherit',
362
+ textAlign: 'inherit',
363
+ cursor: 'pointer',
364
+ color: className ? undefined : 'inherit',
365
+ },
366
+ }, name);
367
+ }
368
+ /**
369
+ * A client's name that opens their CRM record for staff and is plain text for a
370
+ * client viewer, who cannot reach the destination.
371
+ *
372
+ * @example
373
+ * <ClientName client={client} className="font-medium" />
374
+ */
375
+ export function ClientName({ client, className, viewer }) {
376
+ const isClient = isClientViewer(viewer, usePathname());
377
+ return recordName(clientDisplayName(client), isClient ? null : { to: 'client', clientId: client.id }, className);
378
+ }
379
+ /** A company's name that opens its CRM record for staff and is plain text for a client viewer. */
380
+ export function CompanyName({ company, className, viewer }) {
381
+ const isClient = isClientViewer(viewer, usePathname());
382
+ return recordName(company.name ?? '', isClient ? null : { to: 'company', companyId: company.id }, className);
383
+ }
@@ -0,0 +1,16 @@
1
+ export interface ClientNameRecord {
2
+ id: string;
3
+ givenName?: string | null;
4
+ familyName?: string | null;
5
+ }
6
+ export interface CompanyNameRecord {
7
+ id: string;
8
+ name?: string | null;
9
+ }
10
+ export declare function clientDisplayName(client: ClientNameRecord): string;
11
+ /**
12
+ * Whether the page is rendered for a client in the portal. `client` and `company`
13
+ * are staff-only destinations, so an unknown surface also counts as a client:
14
+ * plain text is the safe failure, a dead link is not.
15
+ */
16
+ export declare function isClientSurfacePath(pathname: string): boolean;
@@ -0,0 +1,15 @@
1
+ import { getSurfacePrefix } from './location-prefix.js';
2
+ // The notification surface is staff-facing, so only the other prefixed surface is the client portal.
3
+ const NOTIFICATION_PREFIX = '/n';
4
+ export function clientDisplayName(client) {
5
+ return [client.givenName, client.familyName].filter(Boolean).join(' ');
6
+ }
7
+ /**
8
+ * Whether the page is rendered for a client in the portal. `client` and `company`
9
+ * are staff-only destinations, so an unknown surface also counts as a client:
10
+ * plain text is the safe failure, a dead link is not.
11
+ */
12
+ export function isClientSurfacePath(pathname) {
13
+ const prefix = getSurfacePrefix(pathname);
14
+ return prefix !== '' && prefix !== NOTIFICATION_PREFIX;
15
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
@@ -22,6 +22,10 @@ If it reports `unsupported`, upgrade the app in this same turn instead of skippi
22
22
  4. Run `bunx tsc --noEmit` and `bun run build`, and confirm the existing header and toast bridge calls still type-check. Fix any break before continuing.
23
23
  5. Re-run the check above, then proceed with this skill.
24
24
 
25
+ ## Client and company names: use the components
26
+
27
+ Render every client or company name with `<ClientName client={client} />` and `<CompanyName company={company} />` from `@assembly-js/app-foundation/lib-client`, in place of `{client.givenName} {client.familyName}` or `{company.name}`. They take the record objects the app already fetches and accept `className`. Staff get a link to the record's CRM page; a client viewer gets plain text. `bun run build` fails `check:linked-names` for a name rendered by hand. The rest of this skill covers other destinations.
28
+
25
29
  ## When to reach for this skill
26
30
 
27
31
  Any time the app renders a **name or reference to a platform entity** — a client name, a company badge, an invoice number, a contract, a form, a file — offer a link that opens the platform's canonical detail page, on surfaces where that destination is reachable (see the "Client portal" column in the destination catalog below). Internal/staff users can navigate to any destination. Clients in the portal can only reach the destinations marked available there — their own invoices, contracts, help articles, files, channels, and other installed apps — not `client`, `company`, `form`, or the other staff-only destinations, which the host rejects for a client viewer.