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 afe861d1..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,31 +10,58 @@ 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, kbLinkedRes, 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?project_id=${event.params.id}`), - 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 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)); - return { matter, chats, files, kbs: { linked, available } }; + return { + matter, + chats, + files, + kbs: { linked, available }, + members, + directory, + authors: Object.fromEntries(authorById) + }; }; export const actions: Actions = { @@ -204,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 2ae44b94..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,61 +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([]), { status: 200 })) // GET /knowledge-bases?project_id=p1 - .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,39 +175,12 @@ 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 () => { - const matter = { - id: 'p1', - name: 'Acme', - privileged: false, - minimum_inference_tier: null, - attached_file_ids: [] - }; - 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: '' - }; + it('resolves linked KBs from attached_knowledge_base_ids and subtracts them from the picker list', async () => { 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 + .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,18 +188,11 @@ 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 () => { - const matter = { - id: 'p1', - name: 'Acme', - privileged: false, - minimum_inference_tier: null, - attached_file_ids: [] - }; + it('degrades gracefully when the KB list fetch fails (returns empty arrays)', async () => { lqFetch - .mockResolvedValueOnce(new Response(JSON.stringify(matter), { status: 200 })) - .mockResolvedValueOnce(new Response(JSON.stringify({ items: [] }), { status: 200 })) - .mockResolvedValueOnce(new Response('boom', { status: 502 })) + .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([]); @@ -249,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) {