@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.
- package/CLAUDE.md +1119 -0
- package/dist/api-errors.d.ts +5 -0
- package/dist/api-errors.js +33 -0
- package/dist/app-storage.d.ts +7 -0
- package/dist/app-storage.js +22 -0
- package/dist/assembly.d.ts +49 -0
- package/dist/assembly.js +235 -0
- package/dist/auth.d.ts +17 -0
- package/dist/auth.js +118 -0
- package/dist/client-companies.d.ts +16 -0
- package/dist/client-companies.js +23 -0
- package/dist/client.d.ts +5 -2
- package/dist/client.js +14 -3
- package/dist/labels.d.ts +9 -0
- package/dist/labels.js +17 -0
- package/dist/mint-bearer.d.ts +18 -0
- package/dist/mint-bearer.js +152 -0
- package/dist/nango.d.ts +174 -0
- package/dist/nango.js +430 -0
- package/dist/network-capture.d.ts +36 -0
- package/dist/network-capture.js +473 -0
- package/dist/params.d.ts +3 -0
- package/dist/params.js +20 -0
- package/dist/resource-resolution.d.ts +89 -0
- package/dist/resource-resolution.js +142 -0
- package/dist/server.d.ts +9 -2
- package/dist/server.js +15 -5
- package/dist/session-exchange.d.ts +20 -0
- package/dist/session-exchange.js +101 -0
- package/dist/theme.d.ts +5 -0
- package/dist/theme.js +20 -0
- package/package.json +9 -3
- package/skills/assembly-ai/SKILL.md +133 -0
- package/skills/assembly-app-bridge-header/SKILL.md +88 -0
- package/skills/assembly-app-bridge-toast/SKILL.md +79 -0
- package/skills/assembly-avatar-sub/SKILL.md +103 -0
- package/skills/assembly-client-users/SKILL.md +45 -0
- package/skills/assembly-clients-companies/SKILL.md +143 -0
- package/skills/assembly-companies/SKILL.md +45 -0
- package/skills/assembly-custom-fields/SKILL.md +86 -0
- package/skills/assembly-db-transactions/SKILL.md +79 -0
- package/skills/assembly-dev-server-logs/SKILL.md +26 -0
- package/skills/assembly-forms/SKILL.md +103 -0
- package/skills/assembly-internal-users/SKILL.md +69 -0
- package/skills/assembly-mutations/SKILL.md +58 -0
- package/skills/assembly-native-resources/SKILL.md +117 -0
- package/skills/assembly-route-auth/SKILL.md +223 -0
- package/skills/assembly-route-errors/SKILL.md +81 -0
- package/skills/assembly-semantic-colors/SKILL.md +76 -0
- package/skills/assembly-session-token/SKILL.md +153 -0
- package/skills/assembly-storage/SKILL.md +157 -0
- package/skills/assembly-user-company-selector/SKILL.md +98 -0
- package/skills/assembly-workspace-labels/SKILL.md +50 -0
- 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
|