@assembly-js/app-foundation 0.0.0 → 0.0.1-rc.1

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.
Files changed (54) hide show
  1. package/CLAUDE.md +1119 -0
  2. package/dist/api-errors.d.ts +5 -0
  3. package/dist/api-errors.js +33 -0
  4. package/dist/app-storage.d.ts +7 -0
  5. package/dist/app-storage.js +22 -0
  6. package/dist/assembly.d.ts +49 -0
  7. package/dist/assembly.js +235 -0
  8. package/dist/auth.d.ts +17 -0
  9. package/dist/auth.js +118 -0
  10. package/dist/client-companies.d.ts +16 -0
  11. package/dist/client-companies.js +23 -0
  12. package/dist/client.d.ts +5 -2
  13. package/dist/client.js +14 -3
  14. package/dist/labels.d.ts +9 -0
  15. package/dist/labels.js +17 -0
  16. package/dist/mint-bearer.d.ts +18 -0
  17. package/dist/mint-bearer.js +152 -0
  18. package/dist/nango.d.ts +174 -0
  19. package/dist/nango.js +430 -0
  20. package/dist/network-capture.d.ts +36 -0
  21. package/dist/network-capture.js +473 -0
  22. package/dist/params.d.ts +3 -0
  23. package/dist/params.js +20 -0
  24. package/dist/resource-resolution.d.ts +89 -0
  25. package/dist/resource-resolution.js +142 -0
  26. package/dist/server.d.ts +9 -2
  27. package/dist/server.js +15 -5
  28. package/dist/session-exchange.d.ts +20 -0
  29. package/dist/session-exchange.js +101 -0
  30. package/dist/theme.d.ts +5 -0
  31. package/dist/theme.js +20 -0
  32. package/package.json +9 -3
  33. package/skills/assembly-ai/SKILL.md +133 -0
  34. package/skills/assembly-app-bridge-header/SKILL.md +88 -0
  35. package/skills/assembly-app-bridge-toast/SKILL.md +79 -0
  36. package/skills/assembly-avatar-sub/SKILL.md +103 -0
  37. package/skills/assembly-client-users/SKILL.md +45 -0
  38. package/skills/assembly-clients-companies/SKILL.md +143 -0
  39. package/skills/assembly-companies/SKILL.md +45 -0
  40. package/skills/assembly-custom-fields/SKILL.md +86 -0
  41. package/skills/assembly-db-transactions/SKILL.md +79 -0
  42. package/skills/assembly-dev-server-logs/SKILL.md +26 -0
  43. package/skills/assembly-forms/SKILL.md +103 -0
  44. package/skills/assembly-internal-users/SKILL.md +69 -0
  45. package/skills/assembly-mutations/SKILL.md +58 -0
  46. package/skills/assembly-native-resources/SKILL.md +117 -0
  47. package/skills/assembly-route-auth/SKILL.md +223 -0
  48. package/skills/assembly-route-errors/SKILL.md +81 -0
  49. package/skills/assembly-semantic-colors/SKILL.md +76 -0
  50. package/skills/assembly-session-token/SKILL.md +153 -0
  51. package/skills/assembly-storage/SKILL.md +157 -0
  52. package/skills/assembly-user-company-selector/SKILL.md +98 -0
  53. package/skills/assembly-workspace-labels/SKILL.md +50 -0
  54. package/skills/clearing-client-notifications/SKILL.md +100 -0
@@ -0,0 +1,103 @@
1
+ ---
2
+ name: assembly-avatar-sub
3
+ description: Use when building a list or table that shows clients alongside their companies and a client can belong to multiple companies. Covers the row-duplication + AvatarSub pattern for disambiguating which company a row represents.
4
+ ---
5
+
6
+ `AvatarSub` renders a single nested sub-avatar overlaid at the bottom-right of the parent avatar. It always contains exactly one `Avatar`.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ export PATH="$HOME/.bun/bin:$PATH" && bunx shadcn@latest add @assembly-ui/avatar --yes --overwrite
12
+ ```
13
+
14
+ ## Pre-built helper
15
+
16
+ `@assembly-js/app-foundation/client` exports `expandClientsByCompany` — import it, don't rewrite it. It does the row-duplication transform below, and the `/client` subpath means a `'use client'` list component can call it directly.
17
+
18
+ ## When to use
19
+
20
+ A client belongs to 2+ companies and the list needs to show which company each row represents. The pattern is **row duplication** — show the client once per company, each row with a single `AvatarSub` for that company.
21
+
22
+ - **0 or 1 company → no sub-avatar.** No disambiguation needed.
23
+ - **2+ companies → one row per company**, each with a single `AvatarSub`.
24
+
25
+ ## Anti-patterns — never do any of these
26
+
27
+ - Do NOT render multiple `AvatarSub` elements on the same avatar.
28
+ - Do NOT use `AvatarGroup` of small company avatars next to or on top of a client avatar.
29
+ - Do NOT show all of a client's companies on a single row with stacked badges.
30
+
31
+ The sub-avatar disambiguates *which* company a row represents, not *how many* companies a client has. The only correct multi-company pattern is row duplication.
32
+
33
+ ## Row-duplication pattern
34
+
35
+ Use `expandClientsByCompany` — do NOT hand-roll the expansion. It returns one row per company for multi-company clients (each with its own `displayCompanyId`) and a single `displayCompanyId: null` row for clients with 0 or 1 companies.
36
+
37
+ ```tsx
38
+ import {
39
+ Avatar, AvatarImage, AvatarFallback, AvatarSub,
40
+ type FallbackColorType,
41
+ } from "@/components/ui/avatar";
42
+ import { expandClientsByCompany } from "@assembly-js/app-foundation/client";
43
+
44
+ // Build a lookup map for companies
45
+ const companiesMap = new Map(companies.map(c => [c.id, c]));
46
+
47
+ // Expand multi-company clients into one row per company
48
+ const rows = expandClientsByCompany(clients);
49
+
50
+ // Render
51
+ {rows.map(row => {
52
+ const company = row.displayCompanyId
53
+ ? companiesMap.get(row.displayCompanyId)
54
+ : undefined;
55
+ const name = `${row.givenName} ${row.familyName}`;
56
+ const initials = `${row.givenName.charAt(0)}${row.familyName.charAt(0)}`;
57
+
58
+ return (
59
+ <div key={`${row.id}-${row.displayCompanyId ?? "none"}`}>
60
+ <Avatar>
61
+ <AvatarImage src={row.avatarImageUrl} alt={name} />
62
+ <AvatarFallback fallbackColor={row.fallbackColor as FallbackColorType}>
63
+ {initials}
64
+ </AvatarFallback>
65
+ {company && (
66
+ <AvatarSub>
67
+ <Avatar variant="square">
68
+ <AvatarImage src={company.iconImageUrl} alt={company.name} />
69
+ <AvatarFallback fallbackColor={company.fallbackColor as FallbackColorType}>
70
+ {company.name.charAt(0)}
71
+ </AvatarFallback>
72
+ </Avatar>
73
+ </AvatarSub>
74
+ )}
75
+ </Avatar>
76
+ <span>{name}</span>
77
+ {company && <span>{company.name}</span>}
78
+ </div>
79
+ );
80
+ })}
81
+ ```
82
+
83
+ ## Single avatar (no duplication)
84
+
85
+ When you just need a client or company avatar without the sub-avatar pattern:
86
+
87
+ ```tsx
88
+ // Person avatar (circle)
89
+ <Avatar>
90
+ <AvatarImage src={client.avatarImageUrl} alt={name} />
91
+ <AvatarFallback fallbackColor={client.fallbackColor as FallbackColorType}>
92
+ {initials}
93
+ </AvatarFallback>
94
+ </Avatar>
95
+
96
+ // Company avatar (square)
97
+ <Avatar variant="square">
98
+ <AvatarImage src={company.iconImageUrl} alt={company.name} />
99
+ <AvatarFallback fallbackColor={company.fallbackColor as FallbackColorType}>
100
+ {company.name.charAt(0)}
101
+ </AvatarFallback>
102
+ </Avatar>
103
+ ```
@@ -0,0 +1,45 @@
1
+ ---
2
+ name: assembly-client-users
3
+ description: Use when the app needs to read, list, create, or update client users — the customers of the business that owns the Assembly workspace. Clients sign into the branded Client Portal and see only their own data. Detect via tokenPayload.clientId.
4
+ ---
5
+
6
+ Client users are **customers of the business** that owns the Assembly workspace. They sign into the branded Client Portal at the workspace's domain. They see only their own data — their own messages, their own files, their own invoices.
7
+
8
+ A client belongs to one or more companies (see `assembly-companies` skill — the relationship is many-to-many).
9
+
10
+ ## Identify the current viewer
11
+
12
+ ```ts
13
+ const payload = await assembly.getTokenPayload();
14
+ if (payload.clientId) {
15
+ const me = await assembly.retrieveClient({ id: payload.clientId });
16
+ // ...
17
+ }
18
+ ```
19
+
20
+ (`internalUserId` is set instead for staff — see `assembly-internal-users` skill.)
21
+
22
+ ## SDK methods
23
+
24
+ - `createClient({ sendInvite, requestBody })` — create + optionally email an invite
25
+ - `listClients({ companyId, email, givenName, familyName, nextToken, limit })` — paged list with optional filters
26
+ - `retrieveClient({ id })`
27
+ - `updateClient({ id, sendInvite, requestBody })` — read `assembly-mutations` skill before calling, the requestBody rule is non-obvious
28
+ - `deleteClient({ id })`
29
+
30
+ ## Companies on a client
31
+
32
+ A client has `companyIds: string[]` (the canonical many-to-many). The `companyId: string` field is **deprecated since SDK v3.15.0** but still present for backward compat — read it as a fallback only:
33
+
34
+ ```ts
35
+ const ids = client.companyIds ?? (client.companyId ? [client.companyId] : []);
36
+ ```
37
+
38
+ ## UI guidance
39
+
40
+ When the viewer is a client, render a stripped-down view:
41
+
42
+ - **Hide actions clients can't perform.** Don't show "Create new" buttons for things only staff can create (forms, invoices, contracts) — they'll 403 on click.
43
+ - **Don't list other clients' records.** Filter to `payload.clientId` and `payload.companyIds` everywhere.
44
+ - **Multi-company is the default model.** A client can belong to many companies — read the `assembly-clients-companies` skill for all scoping and company-selector patterns.
45
+ - **Don't conflate with internal-user permissions.** A client can edit their own profile, not other profiles.
@@ -0,0 +1,143 @@
1
+ ---
2
+ name: assembly-clients-companies
3
+ description: Use when an app stores or scopes data by client or company — record keys, scope filters, company selectors, CRM-tab scoping. Clients belong to MANY companies (client.companyIds[]); a client viewer always carries BOTH a clientId and an active companyId, so scoped tables need both columns and must filter on the intersection; client.companyId (on the client object) is deprecated.
4
+ ---
5
+
6
+ Clients and companies are **many-to-many**: a client belongs to many companies via `client.companyIds: string[]`. In the portal, a client can switch between their companies — the viewer's `companyId` always reflects whichever company they currently have active. Apps that ignore this model show incomplete company pickers, or key and filter records on one dimension and then leak a company-A record into the same client's company-B view. This skill covers how to scope, key, and filter records correctly.
7
+
8
+ ## The viewer identity
9
+
10
+ ```ts
11
+ import { getSessionIdentity } from '@assembly-js/app-foundation/server';
12
+
13
+ const identity = await getSessionIdentity();
14
+ // → { workspaceId, clientId?, companyId?, internalUserId? } | null
15
+ ```
16
+
17
+ Read identity with `getSessionIdentity()` (server) or `useAssembly()` (client) — see `assembly-session-token`. Never call `assembly.getTokenPayload()` in app code.
18
+
19
+ **A client viewer always carries both ids.** `identity.clientId` is who they are; `identity.companyId` is the company they currently have active in the portal switcher. The platform guarantees the active company is populated for a client session, so **treat a client viewer with no `companyId` as an error, not as a case to fall back on** — do not widen the query to the client's other companies, and do not substitute `companyIds[0]`.
20
+
21
+ The three identity combinations an app must handle:
22
+
23
+ | `clientId` | `companyId` | Who | Scope |
24
+ | --- | --- | --- | --- |
25
+ | set | set | a client in the portal, or a CRM client-record tab | filter on **both** (see Pattern 2) |
26
+ | absent | set | a CRM company-record tab | filter on `companyId` |
27
+ | set | absent | — | error; do not guess a company |
28
+
29
+ Branching on one id alone mis-reads the viewer: `identity.companyId ? 'company' : 'client'` always takes the company branch for a portal client, because a client carries both.
30
+
31
+ ## The client object
32
+
33
+ ```ts
34
+ const client = await assembly.retrieveClient({ id: identity.clientId });
35
+ client.companyIds; // string[] — full list of companies this client belongs to; use for building the company switcher UI
36
+ client.companyId; // string — @deprecated since SDK v3.15.0, backward compat only; do not use
37
+ ```
38
+
39
+ `companyIds: string[]` is the authoritative full list. The singular `companyId` on the client object is deprecated and must not be written back in updates (see `assembly-mutations` skill).
40
+
41
+ ## Pattern 1 — company selectors
42
+
43
+ When the app shows a company picker for the current client, iterate the **full** `client.companyIds[]` — not just the active `companyId`, which is only the currently selected one:
44
+
45
+ ```ts
46
+ const client = await assembly.retrieveClient({ id: identity.clientId });
47
+ const companies = await Promise.all(
48
+ (client.companyIds ?? []).map((id) => assembly.retrieveCompany({ id }))
49
+ );
50
+ // render ALL of these as picker options; identity.companyId is the currently selected one
51
+ ```
52
+
53
+ Never collapse to `client.companyIds[0]` — this silently hides the client's other companies. And `companyIds[]` is for **rendering the picker only**, never for widening a data query (see Pattern 2).
54
+
55
+ For the picker UI itself, don't hand-roll a dropdown — use the pre-built `UserCompanySelector` component (see `assembly-user-company-selector` skill) and feed it the companies fetched above.
56
+
57
+ ## Pattern 2 — keying and filtering stored records
58
+
59
+ **Give every client- or company-scoped table both a `clientId` and a `companyId` column**, nullable, even when a given row populates only one:
60
+
61
+ ```ts
62
+ export const items = pgTable('items', {
63
+ id: uuid('id').defaultRandom().primaryKey(),
64
+ clientId: uuid('client_id'), // null ⇒ the row belongs to the whole company
65
+ companyId: uuid('company_id'), // the company the row lives under
66
+ // ...
67
+ });
68
+ ```
69
+
70
+ Two columns is not redundancy — it is what makes scope expressible at all. A row written by a client belongs to *that client within that company*, so both ids are part of its identity. A table with only one of the columns cannot be intersection-filtered later, and there is no way to repair it after the data exists.
71
+
72
+ How rows are keyed:
73
+
74
+ - **Client-owned row** — set `clientId` **and** the active `companyId`. Not `clientId` alone: the same client acting under a different company must not see it.
75
+ - **Company-wide row** (shared by everyone in the company) — set `companyId` and leave `clientId` null.
76
+
77
+ Filter on the **intersection**, never on one dimension:
78
+
79
+ ```ts
80
+ import { and, eq, isNull, or } from 'drizzle-orm';
81
+
82
+ // identity.companyId is required for a client viewer — see "The viewer identity"
83
+ if (!identity.companyId) throw new Error('Client session has no active company');
84
+
85
+ const rows = await db
86
+ .select()
87
+ .from(items)
88
+ .where(
89
+ and(
90
+ eq(items.companyId, identity.companyId),
91
+ or(eq(items.clientId, identity.clientId), isNull(items.clientId)),
92
+ ),
93
+ );
94
+ ```
95
+
96
+ That reads as: this client's rows **within the active company**, plus the company-wide rows. For an internal user on a company-record surface (`companyId` set, no `clientId`), drop the `clientId` leg and filter on `companyId` alone. For an internal user on the global Dashboard main view (neither id in the token), filter app-owned rows to companies they can access — see `assembly-internal-users` (Client Access). Full-access internal users need no extra company filter on global views.
97
+
98
+ ❌ Wrong — filters on one dimension:
99
+
100
+ ```ts
101
+ // Leaks: a row this client created under company A shows up in their company-B view.
102
+ .where(eq(items.clientId, identity.clientId))
103
+
104
+ // Leaks: every client in the company sees every other client's rows.
105
+ .where(eq(items.companyId, identity.companyId))
106
+
107
+ // Leaks: silently scopes to the client's first company, whichever that happens to be.
108
+ .where(eq(items.companyId, client.companyIds[0]))
109
+ ```
110
+
111
+ ✅ Right — the `and(company, or(client, isNull(client)))` intersection above.
112
+
113
+ **On writes, validate the target company.** Before storing a row under a `companyId` the request supplied rather than the viewer's own, confirm it is actually one of the client's: `client.companyIds.includes(companyId)`. Otherwise a client can write into a company they don't belong to.
114
+
115
+ **On `[id]` route handlers, `requireCompanyScope` is not enough by itself.** It authorizes the *company* leg; for a row that also has a `clientId`, add the client-ownership check yourself. See `assembly-route-auth`.
116
+
117
+ ## Pattern 3 — the placeholder pitfall
118
+
119
+ Assembly **auto-assigns a placeholder company to every client on creation**, so:
120
+
121
+ ```ts
122
+ client.companyIds.length > 0; // ALWAYS true — never a useful signal
123
+ ```
124
+
125
+ You cannot infer "this app should be company-scoped" (or "this client has a real employer") from the presence of `companyIds`. Scope is determined by **explicit app design intent**, never by inspecting `companyIds`. Placeholder companies are excluded from `listCompanies` by default for these apps, so user-facing lists won't show them; pass `isPlaceholder: true` only if you need them (see `assembly-companies` skill).
126
+
127
+ ## Scope-determination rule
128
+
129
+ 1. **Schema:** give the table both a `clientId` and a `companyId` column. Do this even for an app you believe is company-only — it costs one nullable column and is the only decision here that cannot be reversed later.
130
+ 2. **Writes:** a client-owned row gets `clientId` + the active `companyId`; a company-wide row gets `companyId` with `clientId` null. Whether a given record is client-owned or company-wide comes from the app's design intent.
131
+ 3. **Reads, by viewer identity:**
132
+ - `clientId` + `companyId` → filter `companyId = identity.companyId AND (clientId = identity.clientId OR clientId IS NULL)`.
133
+ - `companyId` only → filter on `companyId`.
134
+ - `clientId` with no `companyId` → error; the platform guarantees an active company for a client session, so do not fall back and do not union across `companyIds[]`.
135
+ 4. Never let `companyIds[0]`, `companyIds.length`, or the deprecated `client.companyId` field drive the scope decision **or the filter** — `companyIds[]` builds the picker, nothing more.
136
+
137
+ ## Related skills
138
+
139
+ - `assembly-companies` — company CRUD, placeholder companies (excluded from `listCompanies` by default for these apps).
140
+ - `assembly-client-users` — identifying the current client viewer and client identity in general.
141
+ - `assembly-session-token` — reading the viewer with `getSessionIdentity()` / `useAssembly()`.
142
+ - `assembly-route-auth` — `requireCompanyScope` for `[id]` route handlers; pass both scope columns there too.
143
+ - `assembly-user-company-selector` — pre-built `UserCompanySelector` component for picker UIs; its `Entity` type still accepts a singular `companyId` for grouping purposes, which is fine for the component API but is not a substitute for `companyIds[]` in scope/storage decisions.
@@ -0,0 +1,45 @@
1
+ ---
2
+ name: assembly-companies
3
+ description: Use when working with Company records — businesses that group Client users. Many-to-many with clients (any number of clients per company; up to ~50 companies per client). Includes placeholder companies that exist for backend logic and should be hidden from end-users by default.
4
+ ---
5
+
6
+ Companies group `Client` users. The relationship is **many-to-many**:
7
+
8
+ - A company can have an unlimited number of clients (no enforced cap).
9
+ - A client can belong to many companies — typically up to ~50 in practice.
10
+
11
+ ## Use `companyIds`, not `companyId`
12
+
13
+ The canonical relationship is the `companyIds: string[]` array on a Client. The singular `companyId` field is **deprecated since SDK v3.15.0** and exists only for backward compat. Always prefer the array:
14
+
15
+ ```ts
16
+ const ids = client.companyIds ?? (client.companyId ? [client.companyId] : []);
17
+ ```
18
+
19
+ Including the deprecated `companyId` in `updateClient` will trigger a re-assignment that 403s on client tokens — see `assembly-mutations` skill.
20
+
21
+ ## SDK methods
22
+
23
+ - `createCompany({ requestBody })`
24
+ - `listCompanies({ name, isPlaceholder, nextToken, limit })`
25
+ - `retrieveCompany({ id })`
26
+ - `updateCompany({ id, requestBody })` — see `assembly-mutations` skill
27
+ - `deleteCompany({ id })`
28
+
29
+ ## Placeholder companies — handle deliberately
30
+
31
+ Some `Company` records have `isPlaceholder: true`. They exist so the platform can attach a company-shaped reference to clients who don't have a real employer (solo customers, individuals). A workspace listing of "Acme Corp, Beta LLC, _placeholder_5fb3a..." — nameless placeholders — is the kind of bug that gets reported by customers as "ghost companies."
32
+
33
+ **For app-builder apps the platform API excludes placeholders from `listCompanies` by default**, so you don't need to filter them yourself — a plain `listCompanies()` already returns only real companies. (Outside the app builder, the public API still returns all companies unless you pass `isPlaceholder: false`.)
34
+
35
+ ### When to include placeholders
36
+
37
+ Pass `isPlaceholder: true` explicitly to fetch placeholders (this returns *only* placeholders, so merge with the default list if you need both). Useful for:
38
+
39
+ - Surfacing solo clients (whose only company is a placeholder) — e.g. an app that tracks work against individual customers
40
+ - Admin / reconciliation flows
41
+ - Linking a specific client to their own company record
42
+
43
+ ### Picker / dropdown UIs
44
+
45
+ When letting a user pick a company (filter, association, etc.), you don't need to filter — `listCompanies` already excludes placeholders for these apps. Don't make the user disambiguate "Real Company" from "Placeholder Company".
@@ -0,0 +1,86 @@
1
+ ---
2
+ name: assembly-custom-fields
3
+ description: Use any time the app reads or writes a workspace-defined custom field on a Client or Company. Two non-negotiable rules — never hardcode the field key (workspace owners pick their own), and option-bearing values must be arrays (multiSelect is the ONLY option-bearing type, even for single-pick UIs).
4
+ ---
5
+
6
+ ## The two non-negotiables
7
+
8
+ 1. **Never hardcode a field `key`.** Workspace owners pick their own keys (`favorite_pizza`, `fav_pizza`, `pizza`, anything). Wrong-guess reads silently return `undefined`; wrong-guess writes silently no-op. Discover dynamically by name.
9
+ 2. **Option-bearing values are arrays.** `multiSelect` is the ONLY option-bearing type in Assembly — there's no `singleSelect`, `dropdown`, or `select`. Anything that *looks like* a select is a `multiSelect`. Even single-pick UIs must wrap the value in a one-element array.
10
+
11
+ ## Discovery (server-side)
12
+
13
+ ```ts
14
+ const fieldsRes = await assembly.listCustomFields({ entityType: 'client' });
15
+ const pizzaField = fieldsRes.data?.find(
16
+ (f) => f.name?.toLowerCase().includes('pizza'), // match user's wording
17
+ );
18
+ if (!pizzaField?.key) {
19
+ return <div>The "favorite pizza" custom field hasn't been set up yet.</div>;
20
+ }
21
+
22
+ // For option-bearing fields:
23
+ const optionsRes = await assembly.listCustomFieldOptions({ id: pizzaField.id });
24
+ // optionsRes.data: [{ id, key, label }] — use `key` as the value, `label` for display.
25
+ ```
26
+
27
+ **Match by `name` with a case-insensitive substring of the user's wording.** Don't match on `key` — users know fields by display name, not internal key.
28
+
29
+ ## Read
30
+
31
+ ```ts
32
+ const value = (client.customFields as Record<string, unknown>)?.[pizzaField.key];
33
+ // For multiSelect: value is the option key array, e.g. ['pepperoni'], or undefined / [] if unset.
34
+ ```
35
+
36
+ ## Write — value MUST be an array, even single-pick
37
+
38
+ ```ts
39
+ await assembly.updateClient({
40
+ id: clientId,
41
+ requestBody: {
42
+ customFields: {
43
+ [pizzaField.key]: selectedKey ? [selectedKey] : [], // ← array, ALWAYS
44
+ },
45
+ // ↑ Pass a plain object. Do NOT JSON.stringify() it.
46
+ },
47
+ });
48
+ ```
49
+
50
+ ## Writing an `address` field — structured object, not a flattened string
51
+
52
+ ```ts
53
+ await assembly.updateClient({
54
+ id: clientId,
55
+ requestBody: {
56
+ customFields: {
57
+ [addressField.key]: {
58
+ addressLine1: '1 Main St',
59
+ addressLine2: 'Suite 400', // optional
60
+ city: 'Springfield',
61
+ region: 'IL',
62
+ postalCode: '62704',
63
+ country: 'US',
64
+ },
65
+ },
66
+ },
67
+ });
68
+ ```
69
+
70
+ Send the structured object — a bare string is accepted for backwards compatibility with older data, but it collapses to a single `fullAddress` string and loses city/region/postal code/country as separate values. Every property is optional except that the field is only considered set if `country` or `fullAddress` is non-empty; `fullAddress` is derived automatically from the other components if you omit it.
71
+
72
+ ## Symptoms when it goes wrong
73
+
74
+ | Mistake | What you'll see |
75
+ |---|---|
76
+ | Sent scalar (`'pepperoni'`) for multiSelect | `400 invalid custom field option pepperoni provided for custom field: <key>` |
77
+ | Sent option `label` (`'Pepperoni'`) instead of `key` (`'pepperoni'`) | Same 400 |
78
+ | Hardcoded the wrong field key | Reads return `undefined`; writes silently no-op |
79
+ | `JSON.stringify(customFields)` to satisfy the type | Request silently rejected or field ignored |
80
+
81
+ ## Edge cases
82
+
83
+ - **Multi-pick UI** (checkbox group): pass full array `[a.key, b.key]`.
84
+ - **Clearing a value**: empty array `[]`, not `null` or `undefined`.
85
+ - **Either `key` or `id` works** as the inner option value — use `key` by convention.
86
+ - Same shape applies to `createClient`, `createCompany`, `updateCompany`.
@@ -0,0 +1,79 @@
1
+ ---
2
+ name: assembly-db-transactions
3
+ description: Use whenever a route handler makes more than one database write. Covers the supported patterns for atomic and non-atomic multi-writes on the neon-http db client.
4
+ ---
5
+
6
+ The `db` exported from `@/lib/db` is a `drizzle-orm/neon-http` client. It is the right driver for this app (stateless HTTP, ideal for Vercel serverless), but it does **not** support interactive transactions.
7
+
8
+ ## Never do this
9
+
10
+ ```ts
11
+ // ❌ WRONG — throws "No transactions support in neon-http driver" and 500s the route.
12
+ await db.transaction(async (tx) => {
13
+ await tx.update(answers).set({ accepted: false }).where(eq(answers.questionId, questionId));
14
+ await tx.update(answers).set({ accepted: true }).where(eq(answers.id, answerId));
15
+ });
16
+ ```
17
+
18
+ `db.transaction(...)` is the instinctive reach for "make these writes atomic," but the http driver has no interactive transaction. There is no `tx` object. The call fails deterministically.
19
+
20
+ ## Atomic multi-write → `db.batch([...])`
21
+
22
+ For "commit A and B together, all-or-nothing," pass an array of statements to `db.batch(...)`. The neon-http driver sends them as a single atomic request — either all succeed or none do.
23
+
24
+ ```ts
25
+ // ✅ RIGHT — atomic accept/unaccept in one round trip.
26
+ await db.batch([
27
+ db.update(answers).set({ accepted: false }).where(eq(answers.questionId, questionId)),
28
+ db.update(answers).set({ accepted: true }).where(eq(answers.id, answerId)),
29
+ ]);
30
+ ```
31
+
32
+ Each element is an ordinary Drizzle query builder (`db.insert(...)`, `db.update(...)`, `db.delete(...)`) — do **not** `await` them individually; hand the unawaited builders to `db.batch` and await the batch. Anything you'd have put inside a `db.transaction` callback of pure writes goes in the array instead.
33
+
34
+ ## Writes that don't need atomicity → sequential awaits
35
+
36
+ If the writes are independent and a partial failure is acceptable (e.g. write the row, then bump a counter), plain sequential `await`s are fine — no batch, no transaction:
37
+
38
+ ```ts
39
+ // ✅ RIGHT — no atomicity requirement, just do them in order.
40
+ await db.insert(comments).values({ questionId, body });
41
+ await db.update(questions).set({ lastActivityAt: new Date() }).where(eq(questions.id, questionId));
42
+ ```
43
+
44
+ This is what sibling apps that never hit the transaction footgun do.
45
+
46
+ ## Read-then-conditional-write
47
+
48
+ `db.batch` cannot branch on a value it reads mid-batch, and there is no interactive transaction to hold a read and a dependent write together. Restructure: **read first, decide in JS, then batch the writes.**
49
+
50
+ Pass a **tuple literal** to `db.batch([...])` — its type is `[Query, ...Query[]]` (at least one element), so a plain `const writes = []` you `.push()` into does **not** typecheck (`bunx tsc --noEmit` fails). Only reach for `db.batch` when there are genuinely two or more writes; if the branch leaves you with a single write, just `await` it.
51
+
52
+ ```ts
53
+ // ✅ RIGHT — read, compute, then batch (2+ writes) or await (1 write).
54
+ const current = await db.query.answers.findFirst({
55
+ where: and(eq(answers.questionId, questionId), eq(answers.accepted, true)),
56
+ });
57
+
58
+ const acceptChosen = db.update(answers).set({ accepted: true }).where(eq(answers.id, answerId));
59
+
60
+ if (current && current.id !== answerId) {
61
+ // Two writes must be atomic → batch a tuple literal.
62
+ await db.batch([
63
+ db.update(answers).set({ accepted: false }).where(eq(answers.id, current.id)),
64
+ acceptChosen,
65
+ ]);
66
+ } else {
67
+ // Only one write needed → plain await, no batch.
68
+ await acceptChosen;
69
+ }
70
+ ```
71
+
72
+ The brief window between the read and the batch is acceptable for these apps. **Do not** swap in `drizzle-orm/neon-serverless`, a `Pool`, `pg`, or `postgres.js` to get an interactive transaction — neon-http stays the driver.
73
+
74
+ ## The rule
75
+
76
+ 1. Two or more writes that must be atomic → `db.batch([builder1, builder2, …])` — pass a tuple **literal** (not a pushed-into array), leave the builders unawaited, and await the batch.
77
+ 2. Writes with no atomicity requirement → sequential `await`s.
78
+ 3. A write that depends on a read → read first, compute in JS, then `db.batch([...])` when the branch yields 2+ writes, or plain `await` when it yields exactly one.
79
+ 4. **Never** call `db.transaction(...)` on the `db` from `@/lib/db`, and never switch drivers to get one.
@@ -0,0 +1,26 @@
1
+ ---
2
+ name: assembly-dev-server-logs
3
+ description: Use whenever the preview shows a broken page, a 500, or behavior that doesn't match the code — read the dev server's log to see the actual server-side error instead of guessing from the browser.
4
+ ---
5
+
6
+ The `bun dev` process backing the preview has its stdout and stderr redirected to `/tmp/dev-server.log`. This is the only place server-side errors, stack traces, and `console.log`/`console.error` output from route handlers, server actions, and server components show up — none of that reaches the browser.
7
+
8
+ ## When to check it
9
+
10
+ - The preview shows a blank page, a Next.js error overlay, or a generic 500.
11
+ - A `'use server'` action or route handler isn't behaving as the code suggests it should.
12
+ - Something crashed and you're not sure why before you start changing code.
13
+
14
+ ## How to use it
15
+
16
+ ```bash
17
+ # See recent output
18
+ tail -n 100 /tmp/dev-server.log
19
+
20
+ # Find the error for a specific route or message
21
+ grep -n "Error" /tmp/dev-server.log
22
+ ```
23
+
24
+ Read or grep the file directly — it's a plain text file on the sandbox's own filesystem, not something fetched over a tool call. The file is truncated and capped automatically, so `tail` always shows recent activity; it does not accumulate forever.
25
+
26
+ If the log doesn't explain the symptom (e.g. a client-side rendering issue), the problem is likely in the browser, not the server — this file only covers the server side.
@@ -0,0 +1,103 @@
1
+ ---
2
+ name: assembly-forms
3
+ description: Use when the app has multi-field forms that need client-side validation with per-field error messages. Covers react-hook-form + zod + Assembly UI Field component. NOT the Assembly Forms/intake module — for that (surveys, intake) use the SDK's listForms/retrieveForm/requestFormResponse instead.
4
+ ---
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ # Form validation libraries (not in the foundation).
10
+ export PATH="$HOME/.bun/bin:$PATH" && bun add react-hook-form @hookform/resolvers zod
11
+
12
+ # Assembly UI Field component + Spinner (used in submit button loading state)
13
+ export PATH="$HOME/.bun/bin:$PATH" && bunx shadcn@latest add @assembly-ui/field @assembly-ui/spinner --yes --overwrite
14
+ ```
15
+
16
+ ## Field component composition
17
+
18
+ ```tsx
19
+ import { Field, FieldLabel, FieldContent, FieldError, FieldGroup } from "@/components/ui/field";
20
+ ```
21
+
22
+ - `Field` — wrapper for a single form field. Pass `data-invalid={true}` to trigger destructive styling.
23
+ - `FieldLabel` — the label text
24
+ - `FieldContent` — wraps the input element
25
+ - `FieldError` — renders inline validation error text
26
+ - `FieldGroup` — groups multiple fields together
27
+
28
+ ## Example: react-hook-form + zod
29
+
30
+ ```tsx
31
+ 'use client';
32
+
33
+ import { useForm } from 'react-hook-form';
34
+ import { zodResolver } from '@hookform/resolvers/zod';
35
+ import { z } from 'zod';
36
+ import { Field, FieldLabel, FieldContent, FieldError } from "@/components/ui/field";
37
+ import { Input } from "@/components/ui/input";
38
+ import { Button } from "@/components/ui/button";
39
+ import { Spinner } from "@/components/ui/spinner";
40
+
41
+ const schema = z.object({
42
+ name: z.string().min(1, 'Name is required'),
43
+ email: z.email('Invalid email address'),
44
+ amount: z.number({ error: 'Must be a number' }).positive('Must be positive'),
45
+ });
46
+
47
+ type FormData = z.infer<typeof schema>;
48
+
49
+ export function CreateForm({ onSubmit }: { onSubmit: (data: FormData) => Promise<void> }) {
50
+ const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>({
51
+ resolver: zodResolver(schema),
52
+ });
53
+
54
+ return (
55
+ <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
56
+ <Field data-invalid={!!errors.name}>
57
+ <FieldLabel>Name</FieldLabel>
58
+ <FieldContent>
59
+ <Input {...register('name')} placeholder="Enter name" />
60
+ </FieldContent>
61
+ {errors.name && <FieldError>{errors.name.message}</FieldError>}
62
+ </Field>
63
+
64
+ <Field data-invalid={!!errors.email}>
65
+ <FieldLabel>Email</FieldLabel>
66
+ <FieldContent>
67
+ <Input {...register('email')} placeholder="email@example.com" />
68
+ </FieldContent>
69
+ {errors.email && <FieldError>{errors.email.message}</FieldError>}
70
+ </Field>
71
+
72
+ <Field data-invalid={!!errors.amount}>
73
+ <FieldLabel>Amount</FieldLabel>
74
+ <FieldContent>
75
+ <Input {...register('amount', { valueAsNumber: true })} type="number" placeholder="0.00" />
76
+ </FieldContent>
77
+ {errors.amount && <FieldError>{errors.amount.message}</FieldError>}
78
+ </Field>
79
+
80
+ <Button type="submit" disabled={isSubmitting}>
81
+ {isSubmitting && <Spinner />}
82
+ Save
83
+ </Button>
84
+ </form>
85
+ );
86
+ }
87
+ ```
88
+
89
+ ## zod v4 — use the v4 API, not v3
90
+
91
+ The installed zod is v4. Use the v4 idioms below; the v3 forms are deprecated (they still compile but emit warnings and will break on the v5 bump):
92
+
93
+ - Formats are **top-level**: `z.email()`, `z.url()`, `z.uuid()` — **not** `z.string().email()` / `z.string().url()`.
94
+ - Customize errors with the unified `{ error: '…' }` param — **not** `invalid_type_error` / `required_error` / `{ message: '…' }`.
95
+ - Flatten/format issues with the top-level `z.flattenError(err)` / `z.treeifyError(err)` — **not** `err.flatten()` / `err.format()`. Reading `err.issues` directly is fine.
96
+ - Import from `'zod'` (top-level) — **not** the `'zod/v4'` subpath.
97
+
98
+ ## Key patterns
99
+
100
+ - `data-invalid={!!errors.fieldName}` on `Field` triggers destructive (red) styling on the label and input border
101
+ - `FieldError` renders the error message text inline below the input
102
+ - Use `zodResolver(schema)` to connect zod validation to react-hook-form
103
+ - Follow the [shadcn react-hook-form pattern](https://ui.shadcn.com/docs/forms/react-hook-form) adapted for Assembly UI's Field components