From bb7dffab10eb7dbb04534aa5fcb8ff5fb369dec9 Mon Sep 17 00:00:00 2001 From: sergiomaldo <206754515+sergiomaldo@users.noreply.github.com> Date: Thu, 30 Jul 2026 07:00:02 -0400 Subject: [PATCH 1/2] fix(matters): read linked KBs from attached_knowledge_base_ids, not the legacy project_id filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/v1/knowledge-bases?project_id= filters on the legacy knowledge_bases.project_id column, while attach/detach (Wave D.1 T3) write the project_knowledge_bases junction table — so a freshly linked KB never appeared in the matter's Knowledge section. Resolve linked KBs from the project's attached_knowledge_base_ids against the full KB list instead. Co-Authored-By: Claude Fable 5 --- src/routes/(app)/matters/[id]/+page.server.ts | 11 ++++++++--- src/routes/(app)/matters/[id]/page.server.test.ts | 13 ++++++------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/routes/(app)/matters/[id]/+page.server.ts b/src/routes/(app)/matters/[id]/+page.server.ts index afe861d1..6d321e9c 100644 --- a/src/routes/(app)/matters/[id]/+page.server.ts +++ b/src/routes/(app)/matters/[id]/+page.server.ts @@ -18,19 +18,24 @@ export const load: PageServerLoad = async (event) => { const matter = (await mRes.json()) as Matter; const chats = cRes.ok ? (((await cRes.json()) as { items: Chat[] }).items ?? []) : []; - const [filesArr, kbLinkedRes, kbAllRes] = await Promise.all([ + const [filesArr, kbAllRes] = await Promise.all([ Promise.all( (matter.attached_file_ids ?? []).map(async (id) => { const r = await lqFetch(event, `/api/v1/files/${id}`); return r.ok ? ((await r.json()) as ProjectFile) : null; }) ), - lqFetch(event, `/api/v1/knowledge-bases?project_id=${event.params.id}`), lqFetch(event, '/api/v1/knowledge-bases') ]); const files = filesArr.filter((f): f is ProjectFile => f !== null); - const linked = kbLinkedRes.ok ? ((await kbLinkedRes.json()) as KnowledgeBase[]) : []; const allKbs = kbAllRes.ok ? ((await kbAllRes.json()) as KnowledgeBase[]) : []; + // Linked KBs come from the project's junction-table ids, NOT from + // `GET /knowledge-bases?project_id=` — that filter matches the legacy + // `knowledge_bases.project_id` column, so junction attaches never show up. + const byId = new Map(allKbs.map((k) => [k.id, k])); + const linked = (matter.attached_knowledge_base_ids ?? []) + .map((id) => byId.get(id)) + .filter((k): k is KnowledgeBase => k !== undefined); const linkedIds = new Set(linked.map((k) => k.id)); const available = allKbs.filter((k) => !linkedIds.has(k.id)); diff --git a/src/routes/(app)/matters/[id]/page.server.test.ts b/src/routes/(app)/matters/[id]/page.server.test.ts index 2ae44b94..3d6d7948 100644 --- a/src/routes/(app)/matters/[id]/page.server.test.ts +++ b/src/routes/(app)/matters/[id]/page.server.test.ts @@ -163,7 +163,6 @@ describe('/matters/[id] load — files + KBs', () => { ) ) .mockResolvedValueOnce(new Response('not found', { status: 404 })) // GET /files/gone → filtered - .mockResolvedValueOnce(new Response(JSON.stringify([]), { status: 200 })) // GET /knowledge-bases?project_id=p1 .mockResolvedValueOnce( new Response( JSON.stringify([ @@ -190,13 +189,14 @@ describe('/matters/[id] load — files + KBs', () => { expect(out.kbs.available.map((k) => k.id)).toEqual(['k1']); }); - it('subtracts linked KBs from the available picker list', async () => { + it('resolves linked KBs from attached_knowledge_base_ids and subtracts them from the picker list', async () => { const matter = { id: 'p1', name: 'Acme', privileged: false, minimum_inference_tier: null, - attached_file_ids: [] + attached_file_ids: [], + attached_knowledge_base_ids: ['k1'] }; const linkedKb = { id: 'k1', @@ -221,7 +221,6 @@ describe('/matters/[id] load — files + KBs', () => { lqFetch .mockResolvedValueOnce(new Response(JSON.stringify(matter), { status: 200 })) .mockResolvedValueOnce(new Response(JSON.stringify({ items: [] }), { status: 200 })) - .mockResolvedValueOnce(new Response(JSON.stringify([linkedKb]), { status: 200 })) // linked .mockResolvedValueOnce(new Response(JSON.stringify([linkedKb, otherKb]), { status: 200 })); // all const out = (await load(loadEv())) as { kbs: { linked: { id: string }[]; available: { id: string }[] }; @@ -230,18 +229,18 @@ describe('/matters/[id] load — files + KBs', () => { expect(out.kbs.available.map((k) => k.id)).toEqual(['k2']); }); - it('degrades gracefully when KB fetches fail (returns empty arrays)', async () => { + it('degrades gracefully when the KB list fetch fails (returns empty arrays)', async () => { const matter = { id: 'p1', name: 'Acme', privileged: false, minimum_inference_tier: null, - attached_file_ids: [] + attached_file_ids: [], + attached_knowledge_base_ids: ['k1'] }; lqFetch .mockResolvedValueOnce(new Response(JSON.stringify(matter), { status: 200 })) .mockResolvedValueOnce(new Response(JSON.stringify({ items: [] }), { status: 200 })) - .mockResolvedValueOnce(new Response('boom', { status: 502 })) .mockResolvedValueOnce(new Response('boom', { status: 502 })); const out = (await load(loadEv())) as { kbs: { linked: unknown[]; available: unknown[] } }; expect(out.kbs.linked).toEqual([]); From 369179254ffbb6ffaaca72ff53b5c1b8a145eede Mon Sep 17 00:00:00 2001 From: sergiomaldo <206754515+sergiomaldo@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:38:11 -0400 Subject: [PATCH 2/2] feat(matters): people on a matter, and who wrote which thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend counterpart: matter membership + `share_scope` over `projects` (`project_members`, migration 0067) and `GET /api/v1/users/directory`. Adds a People section to the matter page: the roster, a role picker per person, a people-picker for adding one, and the matter's share scope. A lead manages all of it; everyone else sees it read-only. Screened people (`role='blocked'`) are listed **apart** from the working team, under their own heading, with a "Lift screen" control. They are not members with a lesser role — they are the record of an ethical wall, and mixing them into the roster makes the wall easy to miss. The UI calls them *Screened* throughout; `blocked` is only ever the wire value. Chats in a shared matter now carry their author's name. An unattributed thread is exactly what privilege work cannot afford, and once three people work one matter, "who ran this" stops being obvious from context. Names resolve from the roster and fall back to the email. The matter list gains a chip distinguishing a matter somebody put you on ("Shared") from one nobody had to ("Firm-wide") — different facts, and a lawyer will want to tell them apart. Keyed on `caller_access_basis` rather than `owner_id`, so it stays right for a lead who is not the owner. Archive is hidden for non-leads, matching the backend, rather than offering a button that 403s. Degrades cleanly against an API that predates all of this: the People section is gated on the roster fetch succeeding, every new matter field is optional, and the directory round-trip is skipped for anyone who cannot staff the matter anyway. `activeMatters` is now generic so callers reading the sharing fields do not lose them to a widening return type. The new types are declared in `$lib/matters/types` rather than read from `backend.d.ts`, since those are generated from the pinned `vendor/lq-ai` sketch; they fold in at the next pin bump. Test note: the `load` path's positional mocks in `page.server.test.ts` now document the fetch order at the top of the file, because adding one request to `load` silently shifts every mock below it — which is how the three pre-existing KB tests broke while this was being written. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 35 +++ docs/GUIDE.md | 36 +++ src/lib/matters/SharedChip.svelte | 25 ++ src/lib/matters/SharedChip.svelte.test.ts | 18 ++ src/lib/matters/sections/TeamSection.svelte | 243 ++++++++++++++ .../sections/TeamSection.svelte.test.ts | 129 ++++++++ src/lib/matters/types.ts | 109 ++++++- src/routes/(app)/matters/+page.server.ts | 4 +- src/routes/(app)/matters/+page.svelte | 3 + src/routes/(app)/matters/[id]/+page.server.ts | 114 ++++++- src/routes/(app)/matters/[id]/+page.svelte | 36 ++- .../(app)/matters/[id]/page.server.test.ts | 296 ++++++++++++------ 12 files changed, 937 insertions(+), 111 deletions(-) create mode 100644 src/lib/matters/SharedChip.svelte create mode 100644 src/lib/matters/SharedChip.svelte.test.ts create mode 100644 src/lib/matters/sections/TeamSection.svelte create mode 100644 src/lib/matters/sections/TeamSection.svelte.test.ts diff --git a/README.md b/README.md index 2413537f..d4917b7c 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,41 @@ docker compose -f docker-compose.release.yml exec api \ Open **http://localhost:13002** — or whatever `DONNA_WEB_HOST_PORT` you set in `.env` (`13002` is the default in `.env.example`) — and sign in with `admin@lq.ai` / `DonnaE2ePassw0rd!`. +### Adding your colleagues + +The first-run admin is one account. To put other people on the deployment, create them from the +admin account — each gets a one-time password and is forced to change it on first login: + +```bash +TOKEN=... # an access token for the admin account +curl -X POST http://localhost:18000/api/v1/admin/users \ + -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"email":"colleague@yourfirm.example","display_name":"A Colleague","role":"member"}' +``` + +The response carries `initial_password` **once** — it is never stored or logged, so hand it over +then. If it is lost, `POST /api/v1/admin/users/{id}/reset-password` issues a new one and signs the +user out everywhere. + +Roles are `admin` (manage users and deployment settings), `member` (ordinary use), `viewer`, and +`auditor` (read-only cross-user review of citation ledgers and receipts). Change one with +`PATCH /api/v1/admin/users/{id}/role`. + +**A new matter reaches only its creator by default.** To share matters, add people to a matter's +**People** section in the app, or flip the deployment default so new matters are readable +firm-wide: + +```bash +LQ_AI_MATTER_DEFAULT_SHARE_SCOPE=org # default: personal +``` + +Firm-wide grants _reading_; contributing to a matter always needs an explicit place on its roster. +See [docs/GUIDE.md → People on a matter](docs/GUIDE.md#people-on-a-matter--working-it-together) for +the practitioner view, and lq-ai's +[`docs/security/matter-access-control.md`](https://github.com/LegalQuants/lq-ai/blob/main/docs/security/matter-access-control.md) +for the operator view — including ethical screens, which override administrator rights, and the +limits of what application-layer authorization guarantees. + Images are published from this repo to GHCR — `ghcr.io/legalquants/donna-web`, `donna-api`, and `donna-gateway` (multi-arch: Intel/AMD + Apple Silicon). This still needs a filled `.env`; it removes the _build_, not the _config_. For a fully free, no-cloud setup, leave the provider keys blank and run **Ollama on your host**, then set `OLLAMA_BASE_URL=http://host.docker.internal:11434` in `.env` (the pre-built stack does not bundle an Ollama container) — then pick a local model in the app's Models settings. Deploying beyond `localhost` still requires TLS in front of `donna-web` (see diff --git a/docs/GUIDE.md b/docs/GUIDE.md index ed6349c8..c892278c 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -225,6 +225,42 @@ so sensitive work can't silently route to an underpowered or lower-trust model. matter and it opens already scoped: standing context applied, knowledge and skills available, tier floor enforced. +### People on a matter — working it together + +A matter has a **People** section: who is on it, and in what capacity. Colleagues you add see the +matter's files, knowledge bases, standing context, and — importantly — **each other's chats in it**, +each labelled with its author. That is the point of sharing a matter: see the work already done on +it rather than repeat it. + +Four roles: + +- **Lead** — everything, plus deciding who else is on the matter and who can see it. +- **Contributor** — read the matter and add to it: edit the standing context, attach documents, + start chats. +- **Reader** — read it. Change nothing. +- **Screened** — an ethical wall. See below. + +Above the roster, **who can see this matter** sets the ambient reach: _Just me_, _Named people only_, +or _Everyone at the firm_. Firm-wide grants **reading** and nothing more — contributing still needs a +place on the list, so the roster stays a truthful record of who actually worked the matter. That +record is what you will want months later, when the question is who did what. + +**Screening someone off a matter.** Set their role to **Screened** and the matter disappears for +them: not a permission error, just gone, along with every chat in it. A screen overrides everything +else — firm-wide visibility, an explicit role, and **administrator rights**. An administrator who +must see a screened matter has to lift the screen, which is recorded against their name. That is +deliberate: a wall the administrator can walk through is not a wall, and in a small firm the +administrator is usually also a practising lawyer. Screened people are listed separately from the +working team, so the wall is visible rather than buried in a roster. + +Every change to the roster is written to the audit log — who was added, who changed a role, and +specifically when a screen went up and when it came down. + +**One thing you cannot do:** post into a colleague's chat. You can read every thread in a shared +matter; only its author can add to it — a matter lead included. Interleaving two lawyers' turns in +one conversation would blur which of them directed which answer, and that is exactly the record +that has to stay unambiguous. To act on a shared matter, start your own thread in it. + ### Knowledge bases — grounding answers in your documents A **knowledge base** is a named collection of documents Donna searches when answering — the mechanism diff --git a/src/lib/matters/SharedChip.svelte b/src/lib/matters/SharedChip.svelte new file mode 100644 index 00000000..730278f8 --- /dev/null +++ b/src/lib/matters/SharedChip.svelte @@ -0,0 +1,25 @@ + + + + diff --git a/src/lib/matters/SharedChip.svelte.test.ts b/src/lib/matters/SharedChip.svelte.test.ts new file mode 100644 index 00000000..b251fe83 --- /dev/null +++ b/src/lib/matters/SharedChip.svelte.test.ts @@ -0,0 +1,18 @@ +/// +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import SharedChip from './SharedChip.svelte'; + +describe('SharedChip', () => { + it('distinguishes a matter somebody put you on from one nobody had to', () => { + render(SharedChip, { props: { basis: 'member' } }); + expect(screen.getByText('Shared')).toBeInTheDocument(); + expect(screen.getByLabelText(/added to this matter by someone else/i)).toBeInTheDocument(); + }); + + it('labels firm-wide readability as such', () => { + render(SharedChip, { props: { basis: 'org' } }); + expect(screen.getByText('Firm-wide')).toBeInTheDocument(); + expect(screen.getByLabelText(/readable by everyone at the firm/i)).toBeInTheDocument(); + }); +}); diff --git a/src/lib/matters/sections/TeamSection.svelte b/src/lib/matters/sections/TeamSection.svelte new file mode 100644 index 00000000..82273247 --- /dev/null +++ b/src/lib/matters/sections/TeamSection.svelte @@ -0,0 +1,243 @@ + + +
+

+ People · {team.length} +

+ + {#if error} +

{error}

+ {/if} + + +
+ {#if canManage} +
+ + + {SHARE_SCOPE_HINTS[shareScope]} +
+ {#if privileged && shareScope === 'org'} +

+ This matter is marked privileged and readable firm-wide. If someone must be walled off it, + screen them below — a screen overrides firm-wide access. +

+ {/if} + {:else} +

+ {SHARE_SCOPE_LABELS[shareScope]} · + {SHARE_SCOPE_HINTS[shareScope]} +

+ {/if} +
+ + +
+ {#each team as m (m.user_id)} +
+
+ {label(m)} + {#if m.display_name} + {m.email} + {/if} +
+ + {#if m.is_owner} + Owner · Lead + {:else if canManage} +
+ + +
+
+ + +
+ {:else} + {MATTER_ROLE_LABELS[m.role]} + {/if} +
+ {/each} +
+ + {#if canManage} +
+ {#if addOpen} +
{ + return async ({ update }) => { + await update(); + addOpen = false; + addUserId = ''; + }; + }} + class="flex flex-wrap items-center gap-2" + > + + + + + + + {MATTER_ROLE_HINTS[addRole]} +
+ {:else if candidates.length > 0} + + {:else} +

Everyone in the firm is already on this matter.

+ {/if} +
+ {/if} + + {#if screened.length > 0} +

+ Screened · {screened.length} +

+
+ {#each screened as m (m.user_id)} +
+
+ {label(m)} + + Cannot see this matter, whatever else grants access. + +
+ {#if canManage} +
+ + +
+ {/if} +
+ {/each} +
+ {/if} +
diff --git a/src/lib/matters/sections/TeamSection.svelte.test.ts b/src/lib/matters/sections/TeamSection.svelte.test.ts new file mode 100644 index 00000000..af66f87a --- /dev/null +++ b/src/lib/matters/sections/TeamSection.svelte.test.ts @@ -0,0 +1,129 @@ +/// +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import TeamSection from './TeamSection.svelte'; +import type { DirectoryEntry, MatterMember, MatterRole } from '$lib/matters/types'; + +vi.mock('$app/forms', () => ({ enhance: () => ({}) })); + +const member = (over: Partial & { user_id: string }): MatterMember => ({ + email: `${over.user_id}@example.com`, + display_name: null, + role: 'contributor' as MatterRole, + is_owner: false, + added_by_user_id: 'u1', + created_at: '2026-08-20T00:00:00Z', + ...over +}); + +const owner = member({ user_id: 'u1', display_name: 'Dana Okafor', role: 'lead', is_owner: true }); +const ana = member({ user_id: 'u2', display_name: 'Ana', role: 'contributor' }); +const luis = member({ user_id: 'u3', display_name: 'Luis', role: 'blocked' }); + +const dir: DirectoryEntry[] = [ + { id: 'u1', email: 'u1@example.com', display_name: 'Dana Okafor' }, + { id: 'u4', email: 'u4@example.com', display_name: 'Marta' } +]; + +const props = (over: Record = {}) => ({ + members: [owner, ana], + directory: dir, + shareScope: 'personal' as const, + canManage: true, + ...over +}); + +describe('TeamSection', () => { + it('counts only the working team, not screened people', () => { + render(TeamSection, { props: props({ members: [owner, ana, luis] }) }); + expect(screen.getByRole('heading', { name: /people · 2/i })).toBeInTheDocument(); + }); + + it('marks the owner and does not offer to change or remove them', () => { + render(TeamSection, { props: props() }); + expect(screen.getByText(/owner · lead/i)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /remove dana okafor/i })).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/role for dana okafor/i)).not.toBeInTheDocument(); + }); + + it('gives a lead a role picker and a remove control per other member', () => { + render(TeamSection, { props: props() }); + expect(screen.getByLabelText(/role for ana/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /remove ana/i })).toBeInTheDocument(); + }); + + it('shows a non-lead the roster read-only', () => { + render(TeamSection, { props: props({ canManage: false }) }); + expect(screen.getByText('Contributor')).toBeInTheDocument(); + expect(screen.queryByLabelText(/role for ana/i)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /add someone/i })).not.toBeInTheDocument(); + }); + + it('lists screened people apart from the team, with a Lift screen control', () => { + render(TeamSection, { props: props({ members: [owner, ana, luis] }) }); + expect(screen.getByRole('heading', { name: /screened · 1/i })).toBeInTheDocument(); + expect(screen.getByText(/cannot see this matter/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /lift screen/i })).toBeInTheDocument(); + }); + + it('calls a screened person Screened, never blocked', () => { + render(TeamSection, { props: props({ members: [owner, ana, luis] }) }); + expect(screen.queryByText(/\bblocked\b/i)).not.toBeInTheDocument(); + }); + + it('offers the share scope as a lead-editable control', () => { + render(TeamSection, { props: props({ shareScope: 'org' }) }); + const select = screen.getByLabelText(/who can see this matter/i) as HTMLSelectElement; + expect(select.value).toBe('org'); + expect(screen.getByText(/everyone at the firm can read it/i)).toBeInTheDocument(); + }); + + it('shows a non-lead the share scope as text, not a control', () => { + render(TeamSection, { props: props({ shareScope: 'org', canManage: false }) }); + expect(screen.queryByLabelText(/who can see this matter/i)).not.toBeInTheDocument(); + expect(screen.getByText('Everyone at the firm')).toBeInTheDocument(); + }); + + it('points a lead at screening when a privileged matter is firm-wide', () => { + render(TeamSection, { props: props({ shareScope: 'org', privileged: true }) }); + expect(screen.getByText(/a screen overrides firm-wide access/i)).toBeInTheDocument(); + }); + + it('keeps people already on the roster out of the add picker', async () => { + const user = userEvent.setup(); + render(TeamSection, { props: props({ members: [owner, ana, luis] }) }); + await user.click(screen.getByRole('button', { name: /add someone/i })); + + const options = Array.from((screen.getByLabelText('Person') as HTMLSelectElement).options).map( + (o) => o.value + ); + // u1 is already the owner; u2 and u3 are on the roster (u3 screened). + expect(options).not.toContain('u1'); + expect(options).not.toContain('u3'); + expect(options).toContain('u4'); + }); + + it('says so plainly when there is nobody left to add', () => { + render(TeamSection, { props: props({ directory: [dir[0]] }) }); + expect(screen.getByText(/everyone in the firm is already on this matter/i)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /add someone/i })).not.toBeInTheDocument(); + }); + + it('explains what the chosen role permits before you commit to it', async () => { + const user = userEvent.setup(); + render(TeamSection, { props: props() }); + await user.click(screen.getByRole('button', { name: /add someone/i })); + expect(screen.getByText(/can read the matter and add to it/i)).toBeInTheDocument(); + }); + + it('surfaces an action error', () => { + render(TeamSection, { props: props({ error: 'Only a matter lead can change who is on it.' }) }); + expect(screen.getByText(/only a matter lead/i)).toBeInTheDocument(); + }); + + it('falls back to the email when someone has no display name', () => { + render(TeamSection, { props: props({ members: [owner, member({ user_id: 'u9' })] }) }); + expect(screen.getByText('u9@example.com')).toBeInTheDocument(); + }); +}); diff --git a/src/lib/matters/types.ts b/src/lib/matters/types.ts index f7657c15..83bc8c77 100644 --- a/src/lib/matters/types.ts +++ b/src/lib/matters/types.ts @@ -13,7 +13,112 @@ export interface MatterHeaderInfo { minimumTier: 1 | 2 | 3 | 4 | 5 | null; } -/** Drop the per-user sandbox project; the list/picker only show real matters. */ -export function activeMatters(projects: Matter[]): Matter[] { +/** Drop the per-user sandbox project; the list/picker only show real matters. + * + * Generic over the matter shape so callers that read the sharing fields + * (see `SharedMatter`) do not lose them to a widening return type. + */ +export function activeMatters(projects: T[]): T[] { return projects.filter((p) => !p.is_sandbox); } + +/** Roles on a matter's roster (`project_members.role`). + * + * `blocked` is a *negative* grant — an ethical screen. The backend resolver + * evaluates it before every allow, so it overrides firm-wide scope and + * operator-admin alike. The UI calls it "Screened", which is the term a + * lawyer will recognise; "blocked" is only ever the wire value. + */ +export type MatterRole = 'lead' | 'contributor' | 'reader' | 'blocked'; + +/** Ambient grant over a matter (`projects.share_scope`). */ +export type ShareScope = 'personal' | 'members' | 'org'; + +/** What the caller may do on a matter (`ProjectResponse.caller_access`). */ +export type MatterAccess = 'read' | 'write' | 'lead'; + +/** A colleague, as `GET /api/v1/users/directory` returns them. */ +export interface DirectoryEntry { + id: string; + email: string; + display_name: string | null; +} + +/** Display name if there is one, otherwise the email. */ +export function personLabel(p: { display_name?: string | null; email: string }): string { + return p.display_name?.trim() || p.email; +} + +/** One row of a matter's roster. */ +export interface MatterMember { + user_id: string; + email: string; + display_name: string | null; + role: MatterRole; + is_owner: boolean; + added_by_user_id: string; + created_at: string; +} + +/** Matter fields added by the membership work. + * + * Declared here rather than read from `backend.d.ts` because those types are + * generated from the pinned `vendor/lq-ai` OpenAPI sketch; they fold in at + * the next pin bump. Everything is optional so the UI degrades cleanly + * against an API that predates them. + */ +export interface MatterSharing { + share_scope?: ShareScope; + caller_access?: MatterAccess; + caller_access_basis?: 'owner' | 'member' | 'org' | 'no_grant'; +} + +export type SharedMatter = Matter & MatterSharing; + +/** True when the caller may manage the roster and the share scope. */ +export function canManageMatter(matter: SharedMatter): boolean { + return matter.caller_access === 'lead'; +} + +/** True when the caller may edit the matter's content. */ +export function canEditMatter(matter: SharedMatter): boolean { + return matter.caller_access === 'lead' || matter.caller_access === 'write'; +} + +/** True when the matter reached the caller through someone else's sharing. + * + * Drives the "Shared" chip on the matter list. Keyed on the basis rather + * than `owner_id` so the answer stays right for a lead who is not the owner. + */ +export function isSharedWithCaller(matter: SharedMatter): boolean { + return matter.caller_access_basis === 'member' || matter.caller_access_basis === 'org'; +} + +/** Human labels for roster roles. */ +export const MATTER_ROLE_LABELS: Record = { + lead: 'Lead', + contributor: 'Contributor', + reader: 'Reader', + blocked: 'Screened' +}; + +/** What each role actually permits, shown next to the picker so staffing a + * matter does not require reading the API docs. */ +export const MATTER_ROLE_HINTS: Record = { + lead: 'Full control, including who else is on the matter.', + contributor: 'Can read the matter and add to it.', + reader: 'Can read the matter. Cannot change it.', + blocked: 'Screened off. Cannot see the matter at all, whatever else grants access.' +}; + +export const SHARE_SCOPE_LABELS: Record = { + personal: 'Just me', + members: 'Named people only', + org: 'Everyone at the firm' +}; + +export const SHARE_SCOPE_HINTS: Record = { + personal: 'Only you and anyone you add below.', + members: 'Only the people listed below.', + org: 'Everyone at the firm can read it. Contributing still needs a place on the list.' +}; diff --git a/src/routes/(app)/matters/+page.server.ts b/src/routes/(app)/matters/+page.server.ts index e190fa05..e2a79826 100644 --- a/src/routes/(app)/matters/+page.server.ts +++ b/src/routes/(app)/matters/+page.server.ts @@ -1,13 +1,13 @@ import { error, fail, redirect, type Actions } from '@sveltejs/kit'; import { lqFetch } from '$lib/server/lqClient'; -import { activeMatters, type Matter } from '$lib/matters/types'; +import { activeMatters, type Matter, type SharedMatter } from '$lib/matters/types'; import { parsePrivilegeFields } from '$lib/matters/parseFormFields'; import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async (event) => { const res = await lqFetch(event, '/api/v1/projects'); if (!res.ok) throw error(502, 'Could not load matters.'); - return { matters: activeMatters((await res.json()) as Matter[]) }; + return { matters: activeMatters((await res.json()) as SharedMatter[]) }; }; export const actions: Actions = { diff --git a/src/routes/(app)/matters/+page.svelte b/src/routes/(app)/matters/+page.svelte index adc9264c..b3af1ca2 100644 --- a/src/routes/(app)/matters/+page.svelte +++ b/src/routes/(app)/matters/+page.svelte @@ -2,6 +2,8 @@ import { Plus, FolderKanban } from '@lucide/svelte'; import MatterForm from '$lib/matters/MatterForm.svelte'; import PrivilegedChip from '$lib/matters/PrivilegedChip.svelte'; + import SharedChip from '$lib/matters/SharedChip.svelte'; + import { isSharedWithCaller } from '$lib/matters/types'; let { data, form } = $props(); let showCreate = $state(false); @@ -47,6 +49,7 @@
{m.name} {#if m.privileged}{/if} + {#if isSharedWithCaller(m)}{/if}
{#if m.description}
{m.description} diff --git a/src/routes/(app)/matters/[id]/+page.server.ts b/src/routes/(app)/matters/[id]/+page.server.ts index 6d321e9c..8bc0a881 100644 --- a/src/routes/(app)/matters/[id]/+page.server.ts +++ b/src/routes/(app)/matters/[id]/+page.server.ts @@ -1,6 +1,6 @@ import { error, fail, redirect, type Actions } from '@sveltejs/kit'; import { lqFetch } from '$lib/server/lqClient'; -import type { Matter } from '$lib/matters/types'; +import type { DirectoryEntry, MatterMember, SharedMatter } from '$lib/matters/types'; import { parsePrivilegeFields } from '$lib/matters/parseFormFields'; import type { components } from '$lib/api/backend'; import type { PageServerLoad } from './$types'; @@ -10,23 +10,37 @@ type KnowledgeBase = components['schemas']['KnowledgeBase']; type ProjectFile = components['schemas']['File']; export const load: PageServerLoad = async (event) => { - const [mRes, cRes] = await Promise.all([ + const [mRes, cRes, memRes] = await Promise.all([ lqFetch(event, `/api/v1/projects/${event.params.id}`), - lqFetch(event, `/api/v1/chats?project_id=${event.params.id}`) + lqFetch(event, `/api/v1/chats?project_id=${event.params.id}`), + lqFetch(event, `/api/v1/projects/${event.params.id}/members`) ]); if (!mRes.ok) throw error(mRes.status === 404 ? 404 : 502, 'Could not load this matter.'); - const matter = (await mRes.json()) as Matter; + const matter = (await mRes.json()) as SharedMatter; const chats = cRes.ok ? (((await cRes.json()) as { items: Chat[] }).items ?? []) : []; + // An API that predates matter membership 404s here; the page still renders, + // just without the People section. + const members = memRes.ok ? ((await memRes.json()) as MatterMember[]) : []; - const [filesArr, kbAllRes] = await Promise.all([ + // Chats in a shared matter belong to whoever started them, so the list has + // to say so — an unattributed thread is exactly what privilege work cannot + // afford. Resolve author names from the roster; fall back to the email. + const authorById = new Map(members.map((m) => [m.user_id, m.display_name?.trim() || m.email])); + + // The people-picker only matters to someone who can staff the matter, so + // skip that round-trip otherwise. It rides the second batch rather than + // sitting between the two, so a lead does not pay an extra serial hop. + const [filesArr, kbAllRes, dirRes] = await Promise.all([ Promise.all( (matter.attached_file_ids ?? []).map(async (id) => { const r = await lqFetch(event, `/api/v1/files/${id}`); return r.ok ? ((await r.json()) as ProjectFile) : null; }) ), - lqFetch(event, '/api/v1/knowledge-bases') + lqFetch(event, '/api/v1/knowledge-bases'), + matter.caller_access === 'lead' ? lqFetch(event, '/api/v1/users/directory') : null ]); + const directory = dirRes?.ok ? ((await dirRes.json()) as DirectoryEntry[]) : []; const files = filesArr.filter((f): f is ProjectFile => f !== null); const allKbs = kbAllRes.ok ? ((await kbAllRes.json()) as KnowledgeBase[]) : []; // Linked KBs come from the project's junction-table ids, NOT from @@ -39,7 +53,15 @@ export const load: PageServerLoad = async (event) => { const linkedIds = new Set(linked.map((k) => k.id)); const available = allKbs.filter((k) => !linkedIds.has(k.id)); - return { matter, chats, files, kbs: { linked, available } }; + return { + matter, + chats, + files, + kbs: { linked, available }, + members, + directory, + authors: Object.fromEntries(authorById) + }; }; export const actions: Actions = { @@ -209,6 +231,84 @@ export const actions: Actions = { return { success: true }; }, + addMember: async (event) => { + const data = await event.request.formData(); + const user_id = String(data.get('user_id') ?? ''); + const role = String(data.get('role') ?? 'contributor'); + if (!user_id) return fail(400, { error: 'Choose someone to add.' }); + const res = await lqFetch(event, `/api/v1/projects/${event.params.id}/members`, { + method: 'POST', + body: JSON.stringify({ user_id, role }) + }); + if (!res.ok) { + if (res.status === 409) + return fail(409, { error: 'They already have a role on this matter.' }); + if (res.status === 403) + return fail(403, { error: 'Only a matter lead can change who is on it.' }); + if (res.status === 404) return fail(404, { error: 'That person no longer exists.' }); + return fail(502, { error: 'Could not add them to the matter.' }); + } + return { success: true }; + }, + + changeMemberRole: async (event) => { + const data = await event.request.formData(); + const user_id = String(data.get('user_id') ?? ''); + const role = String(data.get('role') ?? ''); + if (!user_id || !role) return fail(400, { error: 'Missing user or role.' }); + const res = await lqFetch(event, `/api/v1/projects/${event.params.id}/members/${user_id}`, { + method: 'PATCH', + body: JSON.stringify({ role }) + }); + if (!res.ok) { + if (res.status === 409) + return fail(409, { + error: 'The matter owner is always lead. Transfer ownership to change this.' + }); + if (res.status === 403) + return fail(403, { error: 'Only a matter lead can change who is on it.' }); + return fail(502, { error: 'Could not change their role.' }); + } + return { success: true }; + }, + + removeMember: async (event) => { + const data = await event.request.formData(); + const user_id = String(data.get('user_id') ?? ''); + if (!user_id) return fail(400, { error: 'Missing user.' }); + const res = await lqFetch(event, `/api/v1/projects/${event.params.id}/members/${user_id}`, { + method: 'DELETE' + }); + // 204 = removed; 404 = already gone → idempotent success. + if (!res.ok && res.status !== 404) { + if (res.status === 409) + return fail(409, { + error: 'The matter owner cannot be removed. Transfer ownership instead.' + }); + if (res.status === 403) + return fail(403, { error: 'Only a matter lead can change who is on it.' }); + return fail(502, { error: 'Could not remove them from the matter.' }); + } + return { success: true }; + }, + + setShareScope: async (event) => { + const data = await event.request.formData(); + const share_scope = String(data.get('share_scope') ?? ''); + if (!share_scope) return fail(400, { error: 'Missing share scope.' }); + const res = await lqFetch(event, `/api/v1/projects/${event.params.id}`, { + method: 'PATCH', + body: JSON.stringify({ share_scope }) + }); + if (!res.ok) { + if (res.status === 403) + return fail(403, { error: 'Only a matter lead can change who can see this matter.' }); + if (res.status === 422) return fail(422, { error: 'A sandbox matter cannot be shared.' }); + return fail(502, { error: 'Could not change who can see this matter.' }); + } + return { success: true }; + }, + createKb: async (event) => { const data = await event.request.formData(); const name = String(data.get('name') ?? '').trim(); diff --git a/src/routes/(app)/matters/[id]/+page.svelte b/src/routes/(app)/matters/[id]/+page.svelte index 354f66f0..d083a5c4 100644 --- a/src/routes/(app)/matters/[id]/+page.svelte +++ b/src/routes/(app)/matters/[id]/+page.svelte @@ -6,8 +6,11 @@ import KnowledgeSection from '$lib/matters/sections/KnowledgeSection.svelte'; import SkillsSection from '$lib/matters/sections/SkillsSection.svelte'; import ContextSection from '$lib/matters/sections/ContextSection.svelte'; + import TeamSection from '$lib/matters/sections/TeamSection.svelte'; + import { canManageMatter } from '$lib/matters/types'; let { data, form } = $props(); + const canManage = $derived(canManageMatter(data.matter)); let showRename = $state(false); let confirmArchive = $state(false); @@ -58,18 +61,30 @@ class="rounded-mlq-control border border-mlq-subtle px-3 py-1.5 text-xs text-mlq-text" >Rename - + {#if canManage} + + {/if}
+ {#if data.members.length > 0} + + {/if}

@@ -87,7 +102,14 @@ > {c.title} - {c.message_count ?? 0} msgs + {#if data.authors[c.owner_id]} + {data.authors[c.owner_id]} + · + {/if} + {c.message_count ?? 0} msgs {/each} diff --git a/src/routes/(app)/matters/[id]/page.server.test.ts b/src/routes/(app)/matters/[id]/page.server.test.ts index 3d6d7948..ba7cb07f 100644 --- a/src/routes/(app)/matters/[id]/page.server.test.ts +++ b/src/routes/(app)/matters/[id]/page.server.test.ts @@ -14,6 +14,17 @@ const loadEv = (id = 'p1') => ({ params: { id } }) as never; beforeEach(() => lqFetch.mockReset()); +/** Fetch order in `load`, for the positional mocks below: + * + * 1. GET /projects/{id} + * 2. GET /chats?project_id={id} + * 3. GET /projects/{id}/members + * 4..n GET /files/{id} (one per attached_file_id) + * n+1 GET /knowledge-bases + * n+2 GET /users/directory (leads only) + */ +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); + describe('/matters/[id] load', () => { it('loads the matter and its chats', async () => { lqFetch @@ -126,60 +137,35 @@ describe('/matters/[id] actions', () => { }); describe('/matters/[id] load — files + KBs', () => { + const matter = (over: Record = {}) => ({ + id: 'p1', + name: 'Acme', + privileged: false, + minimum_inference_tier: null, + attached_file_ids: [], + attached_knowledge_base_ids: [], + ...over + }); + const kb = (id: string, name: string) => ({ + id, + name, + owner_id: 'u', + hybrid_alpha: 0.5, + file_count: 0, + chunk_count: 0, + created_at: '', + updated_at: '' + }); + it('fans out file metadata for each attached_file_id and filters out 404s', async () => { - const matter = { - id: 'p1', - name: 'Acme', - description: 'd', - privileged: false, - minimum_inference_tier: null, - attached_file_ids: ['a', 'b', 'gone'] - }; lqFetch - .mockResolvedValueOnce(new Response(JSON.stringify(matter), { status: 200 })) // GET /projects/p1 - .mockResolvedValueOnce(new Response(JSON.stringify({ items: [] }), { status: 200 })) // GET /chats?project_id=p1 - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - id: 'a', - filename: 'a.pdf', - size_bytes: 1, - mime_type: 'application/pdf', - ingestion_status: 'ready' - }), - { status: 200 } - ) - ) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - id: 'b', - filename: 'b.pdf', - size_bytes: 2, - mime_type: 'application/pdf', - ingestion_status: 'pending' - }), - { status: 200 } - ) - ) - .mockResolvedValueOnce(new Response('not found', { status: 404 })) // GET /files/gone → filtered - .mockResolvedValueOnce( - new Response( - JSON.stringify([ - { - id: 'k1', - name: 'KB', - owner_id: 'u', - hybrid_alpha: 0.5, - file_count: 0, - chunk_count: 0, - created_at: '', - updated_at: '' - } - ]), - { status: 200 } - ) - ); // GET /knowledge-bases + .mockResolvedValueOnce(json(matter({ attached_file_ids: ['a', 'b', 'gone'] }))) + .mockResolvedValueOnce(json({ items: [] })) + .mockResolvedValueOnce(json([])) // members + .mockResolvedValueOnce(json({ id: 'a', filename: 'a.pdf', size_bytes: 1 })) + .mockResolvedValueOnce(json({ id: 'b', filename: 'b.pdf', size_bytes: 2 })) + .mockResolvedValueOnce(new Response('not found', { status: 404 })) // /files/gone + .mockResolvedValueOnce(json([kb('k1', 'KB')])); const out = (await load(loadEv())) as { files: { id: string }[]; kbs: { linked: unknown[]; available: { id: string }[] }; @@ -190,38 +176,11 @@ describe('/matters/[id] load — files + KBs', () => { }); it('resolves linked KBs from attached_knowledge_base_ids and subtracts them from the picker list', async () => { - const matter = { - id: 'p1', - name: 'Acme', - privileged: false, - minimum_inference_tier: null, - attached_file_ids: [], - attached_knowledge_base_ids: ['k1'] - }; - const linkedKb = { - id: 'k1', - name: 'Linked', - owner_id: 'u', - hybrid_alpha: 0.5, - file_count: 1, - chunk_count: 1, - created_at: '', - updated_at: '' - }; - const otherKb = { - id: 'k2', - name: 'Other', - owner_id: 'u', - hybrid_alpha: 0.5, - file_count: 0, - chunk_count: 0, - created_at: '', - updated_at: '' - }; lqFetch - .mockResolvedValueOnce(new Response(JSON.stringify(matter), { status: 200 })) - .mockResolvedValueOnce(new Response(JSON.stringify({ items: [] }), { status: 200 })) - .mockResolvedValueOnce(new Response(JSON.stringify([linkedKb, otherKb]), { status: 200 })); // all + .mockResolvedValueOnce(json(matter({ attached_knowledge_base_ids: ['k1'] }))) + .mockResolvedValueOnce(json({ items: [] })) + .mockResolvedValueOnce(json([])) // members + .mockResolvedValueOnce(json([kb('k1', 'Linked'), kb('k2', 'Other')])); const out = (await load(loadEv())) as { kbs: { linked: { id: string }[]; available: { id: string }[] }; }; @@ -230,17 +189,10 @@ describe('/matters/[id] load — files + KBs', () => { }); it('degrades gracefully when the KB list fetch fails (returns empty arrays)', async () => { - const matter = { - id: 'p1', - name: 'Acme', - privileged: false, - minimum_inference_tier: null, - attached_file_ids: [], - attached_knowledge_base_ids: ['k1'] - }; lqFetch - .mockResolvedValueOnce(new Response(JSON.stringify(matter), { status: 200 })) - .mockResolvedValueOnce(new Response(JSON.stringify({ items: [] }), { status: 200 })) + .mockResolvedValueOnce(json(matter({ attached_knowledge_base_ids: ['k1'] }))) + .mockResolvedValueOnce(json({ items: [] })) + .mockResolvedValueOnce(json([])) // members .mockResolvedValueOnce(new Response('boom', { status: 502 })); const out = (await load(loadEv())) as { kbs: { linked: unknown[]; available: unknown[] } }; expect(out.kbs.linked).toEqual([]); @@ -248,6 +200,164 @@ describe('/matters/[id] load — files + KBs', () => { }); }); +describe('/matters/[id] load — roster + attribution', () => { + const base = { + id: 'p1', + name: 'Acme', + privileged: false, + minimum_inference_tier: null, + attached_file_ids: [], + attached_knowledge_base_ids: [] + }; + const members = [ + { + user_id: 'u1', + email: 'dana@example.com', + display_name: 'Dana Okafor', + role: 'lead', + is_owner: true, + added_by_user_id: 'u1', + created_at: '' + }, + { + user_id: 'u2', + email: 'ana@example.com', + display_name: null, + role: 'contributor', + is_owner: false, + added_by_user_id: 'u1', + created_at: '' + } + ]; + + it('returns the roster and an author lookup for the chat list', async () => { + lqFetch + .mockResolvedValueOnce(json({ ...base, caller_access: 'lead', share_scope: 'org' })) + .mockResolvedValueOnce(json({ items: [{ id: 'c1', title: 'T', owner_id: 'u2' }] })) + .mockResolvedValueOnce(json(members)) + .mockResolvedValueOnce(json([])) // knowledge-bases + .mockResolvedValueOnce(json([{ id: 'u3', email: 'luis@example.com', display_name: 'Luis' }])); + const out = (await load(loadEv())) as { + members: unknown[]; + directory: { id: string }[]; + authors: Record; + }; + expect(lqFetch.mock.calls[2][1]).toBe('/api/v1/projects/p1/members'); + expect(lqFetch.mock.calls[4][1]).toBe('/api/v1/users/directory'); + expect(out.members).toHaveLength(2); + expect(out.directory.map((d) => d.id)).toEqual(['u3']); + // Falls back to the email when there is no display name — an + // unattributed thread is exactly what privilege work cannot afford. + expect(out.authors).toEqual({ u1: 'Dana Okafor', u2: 'ana@example.com' }); + }); + + it('skips the directory fetch for anyone who cannot staff the matter', async () => { + lqFetch + .mockResolvedValueOnce(json({ ...base, caller_access: 'write' })) + .mockResolvedValueOnce(json({ items: [] })) + .mockResolvedValueOnce(json(members)) + .mockResolvedValueOnce(json([])); // knowledge-bases + const out = (await load(loadEv())) as { directory: unknown[] }; + expect(out.directory).toEqual([]); + expect(lqFetch.mock.calls.some((c) => String(c[1]).includes('/users/directory'))).toBe(false); + }); + + it('still renders against an API that predates matter membership', async () => { + lqFetch + .mockResolvedValueOnce(json(base)) + .mockResolvedValueOnce(json({ items: [] })) + .mockResolvedValueOnce(new Response('not found', { status: 404 })) // no /members route + .mockResolvedValueOnce(json([])); // knowledge-bases + const out = (await load(loadEv())) as { members: unknown[]; authors: Record }; + expect(out.members).toEqual([]); + expect(out.authors).toEqual({}); + }); +}); + +describe('/matters/[id] roster actions', () => { + it('addMember POSTs the chosen person and role', async () => { + lqFetch.mockResolvedValue(new Response('{}', { status: 201 })); + const r = await actions.addMember(ev({ user_id: 'u2', role: 'contributor' })); + expect(lqFetch.mock.calls[0][1]).toBe('/api/v1/projects/p1/members'); + expect(lqFetch.mock.calls[0][2].method).toBe('POST'); + expect(JSON.parse(lqFetch.mock.calls[0][2].body)).toEqual({ + user_id: 'u2', + role: 'contributor' + }); + expect(r).toMatchObject({ success: true }); + }); + + it('addMember refuses an empty selection without calling the backend', async () => { + const r = await actions.addMember(ev({ user_id: '' })); + expect(r).toMatchObject({ status: 400 }); + expect(lqFetch).not.toHaveBeenCalled(); + }); + + it('addMember surfaces the duplicate case in words a user can act on', async () => { + lqFetch.mockResolvedValue(new Response('{}', { status: 409 })); + const r = (await actions.addMember(ev({ user_id: 'u2', role: 'reader' }))) as { + status: number; + data: { error: string }; + }; + expect(r.status).toBe(409); + expect(r.data.error).toMatch(/already have a role/i); + }); + + it('addMember surfaces the non-lead case', async () => { + lqFetch.mockResolvedValue(new Response('{}', { status: 403 })); + const r = (await actions.addMember(ev({ user_id: 'u2', role: 'reader' }))) as { + status: number; + data: { error: string }; + }; + expect(r.status).toBe(403); + expect(r.data.error).toMatch(/lead/i); + }); + + it('changeMemberRole PATCHes the membership row', async () => { + lqFetch.mockResolvedValue(new Response('{}', { status: 200 })); + const r = await actions.changeMemberRole(ev({ user_id: 'u2', role: 'blocked' })); + expect(lqFetch.mock.calls[0][1]).toBe('/api/v1/projects/p1/members/u2'); + expect(lqFetch.mock.calls[0][2].method).toBe('PATCH'); + expect(JSON.parse(lqFetch.mock.calls[0][2].body)).toEqual({ role: 'blocked' }); + expect(r).toMatchObject({ success: true }); + }); + + it('changeMemberRole explains why the owner cannot be demoted', async () => { + lqFetch.mockResolvedValue(new Response('{}', { status: 409 })); + const r = (await actions.changeMemberRole(ev({ user_id: 'u1', role: 'reader' }))) as { + status: number; + data: { error: string }; + }; + expect(r.data.error).toMatch(/owner is always lead/i); + }); + + it('removeMember DELETEs, and treats an already-gone row as success', async () => { + lqFetch.mockResolvedValue(new Response('', { status: 404 })); + const r = await actions.removeMember(ev({ user_id: 'u2' })); + expect(lqFetch.mock.calls[0][1]).toBe('/api/v1/projects/p1/members/u2'); + expect(lqFetch.mock.calls[0][2].method).toBe('DELETE'); + expect(r).toMatchObject({ success: true }); + }); + + it('setShareScope PATCHes the matter', async () => { + lqFetch.mockResolvedValue(new Response('{}', { status: 200 })); + const r = await actions.setShareScope(ev({ share_scope: 'org' })); + expect(lqFetch.mock.calls[0][1]).toBe('/api/v1/projects/p1'); + expect(JSON.parse(lqFetch.mock.calls[0][2].body)).toEqual({ share_scope: 'org' }); + expect(r).toMatchObject({ success: true }); + }); + + it('setShareScope surfaces the non-lead case', async () => { + lqFetch.mockResolvedValue(new Response('{}', { status: 403 })); + const r = (await actions.setShareScope(ev({ share_scope: 'org' }))) as { + status: number; + data: { error: string }; + }; + expect(r.status).toBe(403); + expect(r.data.error).toMatch(/lead/i); + }); +}); + const fileEvent = (files: { name: string; type: string; bytes?: number }[], id = 'p1') => { const fd = new FormData(); for (const f of files) {