diff --git a/.claude/skills/cognito-user/SKILL.md b/.claude/skills/cognito-user/SKILL.md new file mode 100644 index 00000000..e9e3d602 --- /dev/null +++ b/.claude/skills/cognito-user/SKILL.md @@ -0,0 +1,110 @@ +--- +name: cognito-user +description: Create, list, reset, or delete Chorus login accounts in the AWS Cognito user pool backing the chorus.tonyhh.people.aws.dev deployment. Use when the user asks to "open an account", "add a user", "create a login", reset someone's password, or remove a user. +license: AGPL-3.0 +metadata: + author: chorus + version: "0.1.0" + category: operations +--- + +# Cognito User Management (chorus.tonyhh.people.aws.dev) + +Manage human login accounts for the live Chorus deployment. Users log in via +Cognito OIDC; only `@amazon.com` emails are accepted (Chorus matches the email +domain to the "Amazon" company). Self-signup is disabled — an admin creates each +account here. + +## Deployment constants + +| Thing | Value | +|---|---| +| Region | `us-east-1` | +| Cognito User Pool | `us-east-1_w34zYdjVl` | +| App URL | https://chorus.tonyhh.people.aws.dev | +| Allowed email domain | `amazon.com` only | + +## CRITICAL — AWS CLI v1 url-follow gotcha + +This box runs aws-cli v1 with `cli_follow_urlparam` ON: any arg value starting +with `http(s)://` gets auto-fetched and replaced by the URL's CONTENT. Cognito +user commands don't take URLs so they're usually fine, but to be safe and +consistent, **always prefix Cognito CLI calls with the config override**: + +``` +AWS_CONFIG_FILE=/tmp/awscfg/config aws ... +``` + +If `/tmp/awscfg/config` is missing (fresh box), recreate it first: + +```bash +mkdir -p /tmp/awscfg && printf '[default]\ncli_follow_urlparam = false\n' > /tmp/awscfg/config +``` + +## Create an account + +1. Confirm the email ends in `@amazon.com`. If not, STOP and tell the user only + amazon.com emails can log in. +2. Generate a temporary password that meets Cognito complexity (upper+lower+ + digit+symbol). Pattern used in this deployment: `Chorus@<6hex>1A`. +3. Run: + +```bash +REGION=us-east-1 +POOL=us-east-1_w34zYdjVl +EMAIL="@amazon.com" +TMPPW="Chorus@$(openssl rand -hex 3)1A" +AWS_CONFIG_FILE=/tmp/awscfg/config aws cognito-idp admin-create-user \ + --region "$REGION" --user-pool-id "$POOL" \ + --username "$EMAIL" \ + --user-attributes Name=email,Value="$EMAIL" Name=email_verified,Value=true \ + --temporary-password "$TMPPW" \ + --message-action SUPPRESS \ + --query 'User.UserStatus' --output text +echo "TEMP_PW=$TMPPW" +``` + +4. Report the email + temporary password to the user, and tell them the login + flow: open the app URL → enter the email → redirected to Cognito → enter the + temporary password → **forced to set a new password** on first login → lands + in the "Amazon" workspace. The new Chorus User row is auto-provisioned on + first successful login (no extra step needed in Chorus itself). + +Notes: +- `--message-action SUPPRESS` means Cognito does NOT email the user — you hand + them the temp password manually. To have Cognito email an invite instead, + drop that flag (requires SES email sending to be configured on the pool; + default Cognito email has a low daily cap). +- New users have status `FORCE_CHANGE_PASSWORD` until they complete first login. + +## List accounts + +```bash +AWS_CONFIG_FILE=/tmp/awscfg/config aws cognito-idp list-users \ + --region us-east-1 --user-pool-id us-east-1_w34zYdjVl \ + --query 'Users[].[Username,UserStatus]' --output table +``` + +## Reset someone's password + +```bash +AWS_CONFIG_FILE=/tmp/awscfg/config aws cognito-idp admin-set-user-password \ + --region us-east-1 --user-pool-id us-east-1_w34zYdjVl \ + --username "" --password "" --no-permanent +``` +`--no-permanent` forces a change on next login; use `--permanent` to set a final +password that won't require changing. + +## Delete an account + +```bash +AWS_CONFIG_FILE=/tmp/awscfg/config aws cognito-idp admin-delete-user \ + --region us-east-1 --user-pool-id us-east-1_w34zYdjVl --username "" +``` +This removes the Cognito login. The corresponding Chorus User row (and their +authored data) remains in the app DB; remove it separately via the app if needed. + +## Related + +Full deployment details (resource IDs, ops access via SSM, the OIDC company +config) live in the project memory note `chorus-aws-deployment`. diff --git a/PR_BODY_project_visibility.md b/PR_BODY_project_visibility.md new file mode 100644 index 00000000..c54166ee --- /dev/null +++ b/PR_BODY_project_visibility.md @@ -0,0 +1,121 @@ +# Project Visibility (Private / Shared) + Two-Level Group Inheritance + +> This branch contains **two stacked features**: (1) per-**project** visibility, and (2) per-**project-group** visibility that projects **inherit** via a dynamic union. They share the `project-access.ts` authz core and ship together. A mid-stream regression (empty groups vanishing from the list) was also fixed (commit `4500008`). + +--- + +## Part 1 — Project Visibility (Private / Shared) + +## Summary + +Adds a second access dimension to projects on top of multi-tenancy. A project is now either: + +- **`shared`** — visible to the whole company (the historical behavior), or +- **`private`** — visible only to its **owner** and an explicit **member list** (users *and* agents). + +Membership — not the permission bitset — is what grants access to a private project. Holding `project:admin` does **not** bypass it; only the **super admin** platform role sees everything (for governance). + +New projects default to **private** (owner = creating actor, who is auto-added as the first member). A data migration sets all **pre-existing** projects to **shared**, so no current work becomes inaccessible. + +## Why + +Previously every project was visible to everyone in the company (services scoped only by `companyUuid`). Teams asked for private workspaces that a subset of people/agents can collaborate in. + +## How it works + +A single authz module — `src/lib/authz/project-access.ts` — is the source of truth: + +- `getAccessibleProjectUuids(auth)` → the set of project UUIDs the actor may see (or an `ALL` sentinel for super admin) +- `canAccessProject(auth, projectUuid)` → read **and** write gate +- `canManageProject(auth, projectUuid)` → owner-only gate (visibility / membership / delete) +- `applyProjectFilter(where, accessible)` → injects `projectUuid: { in: [...] }` into existing (company-scoped) queries + +Access is enforced **across the whole cascade** — the project and all of its ideas, proposals, documents, tasks, activity, comments, notifications, and search results are filtered for non-members. Both **reads and writes** are gated (e.g. a non-member agent cannot claim/update a private task or post a comment on it). + +## Surfaces changed + +- **Schema**: `Project.visibility` / `ownerType` / `ownerUuid`, new `ProjectMember` table, migration with `shared` backfill. +- **Services**: project, project-group, idea, proposal, document, task, activity, notification, comment, search, assignment, idea-tracker — all gated. +- **REST API**: `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/[uuid]`, new `/api/projects/[uuid]/members` (GET/POST/DELETE, owner-only), and `canAccessProject` guards on every nested route. Leak rule: inaccessible → `404`, accessible-but-not-owner manage → `403`. +- **MCP tools**: `chorus_admin_create_project` gains `visibility` + `memberUuids`; new `chorus_list_project_members` (`project:read`), `chorus_admin_add_project_member` / `chorus_admin_remove_project_member` (`project:admin`); list/get project & group tools and every projectUuid-taking tool gated. +- **Frontend**: Lock badge on private projects; project settings modal gains a visibility toggle + owner-only members manager (shadcn-only, i18n en/zh, IME-safe). +- **Docs**: `docs/MCP_TOOLS.md` + both skill doc sets. + +## Testing + +- Unit tests for the authz core (full actor × visibility matrix, incl. `project:admin`-non-member denied and `projectUuids[]` header does **not** grant access). +- Read- and write-gating tests across every affected service. +- A dedicated **end-to-end privacy integration test** (`src/__tests__/integration/project-visibility.integration.test.ts`) that drives the real authz + services over an in-memory Prisma and asserts the full boundary: non-member deny (reads + writes), owner/member allow, super_admin all-access, `project:admin`-non-member deny, shared-project regression. +- Full gate green: `tsc` ✓, `pnpm test` (1889 pass / 1 skip) ✓, coverage **95.06% stmts / 87.94% branches / 95.95% funcs / 96.82% lines** (≥ thresholds) ✓, `pnpm build` ✓. + +## Migration / rollout + +The migration adds the columns (default `private`) **and** runs `UPDATE "Project" SET visibility='shared'` for all pre-existing rows, so production data stays fully visible. The standalone Docker entrypoint runs `prisma migrate deploy` automatically on container start. + +## Known follow-ups (out of scope) + +- Per-member roles (viewer/editor/admin) — currently a single `member` role. +- `project_group` entity **names** are still searchable in global search (group containers aren't visibility-gated); private-project *entities* never leak. +- `docs/design.pen` not updated in this environment (Pencil MCP tooling unavailable) — to refresh when design tooling is available. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) + +--- + +## Part 2 — Two-Level Visibility (ProjectGroup → Project inheritance) + +### Summary + +Project **groups** gain the same `shared`/`private` + owner + member model, and a project's effective access becomes the **dynamic union** of its own accessors and its group's accessors. + +- **Inheritance = dynamic union**: a project's accessors = (project owner + members) ∪ (its group's owner + members). Add someone to a private group → they instantly reach **every project in it** (and all cascaded entities). No snapshot; computed at query time. +- **"项目级 > 项目组" (project-level is authoritative)**: a project's own `visibility` flag wins. A `shared` project inside a `private` group is still company-wide; a **`private` project inside a `shared` group is still restricted** — a shared group never exposes its private projects. (Enforced by using *only owner/member* groups for the project-union, never shared groups.) +- New groups default **private** (creator = owner + first member); existing groups migrate to **shared**. A new project created with a `groupUuid` defaults to its group's visibility. +- Group management (visibility, members, update, delete) is **owner + super-admin only** — no `project:admin` bypass. + +### Surfaces + +- **Schema**: `ProjectGroup.visibility`/`ownerType`/`ownerUuid`, new `ProjectGroupMember` table, migration with `shared` backfill. +- **Authz core** (`project-access.ts`): `getAccessibleProjectUuids` + `canAccessProject` fold in owned/member groups; new `getAccessibleGroupUuids` / `canAccessGroup` / `canManageGroup`. Two **distinct** group-sets kept rigorously separate (project-union = owner/member only; group-visibility = shared∪owned∪member). +- **Service / REST**: group visibility + member CRUD, `listProjectGroups` gated by `canAccessGroup` (preserving the empty-group fix), new `/api/project-groups/[uuid]/members`, project inherits group visibility default. +- **MCP**: `chorus_admin_create_project_group` gains `visibility`/`memberUuids`; new `chorus_list_project_group_members` (`project:read`), `chorus_admin_add/remove_project_group_member` (`project:admin`); `update`/`delete` group tools now owner-gated. +- **Frontend**: Lock badge on private groups; manage-group dialog visibility toggle + owner-only members manager (i18n en/zh). +- **Docs**: MCP_TOOLS.md + both skill doc sets. + +### Testing (whole branch) + +- Authz unit matrix extended for group inheritance incl. **both cross-case invariants** (shared-in-private-group still company-wide; private-in-shared-group still restricted) and `project:admin`-non-member-denied. +- End-to-end integration test extended: a group member gains read+write across the group's private project + cascade purely via group membership; **dynamic revocation** (remove from group → access flips); non-member + `project:admin`-non-member denied. +- Full gate green: `tsc` ✓, **1954 tests pass / 1 skip** ✓, coverage **95.16% stmts / 88.5% branches / 96.03% funcs / 96.92% lines** (≥ thresholds) ✓, `pnpm build` ✓. + +### Migration / rollout + +Both migrations (`add_project_visibility`, `add_project_group_visibility`) run automatically via the Docker entrypoint and backfill existing rows to `shared`. Already deployed to the live standalone instance for validation. + +### Known follow-ups (out of scope) + +- Per-member roles (viewer/editor/admin) — single `member` role for both projects and groups. +- Project cannot NARROW/remove inherited group members (union only — by design). +- Nested groups (single level, unchanged). +- `docs/design.pen` not updated (Pencil MCP tooling unavailable in this environment). + +--- + +## Part 3 — Visibility UX fixes (claim-on-manage, group DELETE gate, member UX) + +Addresses three user-reported bugs, all rooted in **legacy null-owner entities** (the migration set pre-existing projects/groups to `shared` with no owner, so `isOwner` was false for everyone → manage controls hidden). + +- **Claim-on-first-manage**: the first actor who can **access AND manage** an owner-less project/group (via any manage action — set visibility, add/remove member, update, delete) **claims ownership** and is seeded as a member. Strictly **access-gated** (a non-member of a *private* owner-less entity can never claim it — no privacy hole), **never reassigns** an existing owner, race-safe (guarded `updateMany where ownerUuid:null` + lost-race re-read), and super_admin manages without claiming. +- **Security fix**: `DELETE /api/project-groups/[uuid]` was **ungated** — added `canAccessGroup`(→404) then `claimOrCanManageGroup`(→403), matching PATCH. +- **Member UX**: member lists now resolve **display names** (`getActorName`) instead of raw UUIDs; the member-add search surfaces **human users** (and a default list on empty input) via a `forMembers` flag on `/api/mentionables` (the @mention autocomplete default is unchanged). +- **Dashboards** show manage controls for claimable legacy entities via a **pure** `canManageOrClaimable*` predicate (no write on a GET; the real claim happens on the manage action). + +Pure read gates (`canManageProject/Group`) stay side-effect-free; all MCP mutating tools + REST manage routes use the claim-aware variant. + +### Testing +- Authz unit matrix extended: access-gated claim (non-member of private owner-less → denied, no write), never-reassign, lost-race, super_admin no-claim, pure-predicate no-write. +- E2E integration: legacy null-owner project & group claimed by first accessible manager (owner set + member seeded), a different user then denied, private owner-less non-member denied (owner stays null), super_admin no-claim. +- Full gate green: `tsc` ✓, **1990 tests pass / 1 skip** ✓, coverage **95.23% stmts / 88.77% branches / 96.06% funcs / 96.97% lines** ✓, `pnpm build` ✓. + +### Migration / rollout +No schema change (owner columns already exist). Pure logic/UX + the DELETE gate fix. Already deployed to the live standalone instance. diff --git a/docs/MCP_TOOLS.md b/docs/MCP_TOOLS.md index 76e57fdc..c77c1949 100644 --- a/docs/MCP_TOOLS.md +++ b/docs/MCP_TOOLS.md @@ -58,6 +58,12 @@ The following table summarizes every permission-gated MCP tool. Each tool has ex | `chorus_admin_update_project_group` | `project:write` | | `chorus_admin_delete_project_group` | `project:write` | | `chorus_admin_move_project_to_group` | `project:write` | +| `chorus_list_project_members` | `project:read` | +| `chorus_admin_add_project_member` | `project:admin` | +| `chorus_admin_remove_project_member` | `project:admin` | +| `chorus_list_project_group_members` | `project:read` | +| `chorus_admin_add_project_group_member` | `project:admin` | +| `chorus_admin_remove_project_group_member` | `project:admin` | | `chorus_admin_approve_proposal` | `proposal:admin` | | `chorus_admin_close_proposal` | `proposal:admin` | | `chorus_admin_verify_task` | `task:admin` | @@ -78,9 +84,33 @@ For agents that rely on the preset alone (no custom permissions), this is the re | PM Agent | Public + Session + PM (`idea:write` + `proposal:write` + `document:write` + `task:write` + `project:write` tools — includes Developer's `task:write` tools and `project:write` project-management tools) | | Admin Agent | Public + Session + PM + Developer + Admin (all 15 permissions) | +## Project Visibility (Private / Shared) + +Every project is either **`shared`** (visible to the whole company — the historical default) or **`private`** (visible only to its owner and explicit members). Membership is the *only* way into a private project: + +- **Access is by membership, not permission.** Holding `project:admin` does **not** let an agent see or touch a private project it is not a member of. The `X-Chorus-Project` / `X-Chorus-Project-Group` headers are convenience filters and likewise do **not** grant access. +- **Cascade.** A private project hides itself *and* all of its ideas, proposals, documents, tasks, activity, comments, notifications, and search hits from non-members. Every list is filtered to the caller's accessible set; every single-entity read or write on an inaccessible project is rejected as not-found. +- **Owner & members.** Members can be users *or* agents (AI+human parity). The owner (set to the creating actor) can manage visibility and membership. +- **Defaults.** New projects created via `chorus_admin_create_project` default to `private`. The creating agent becomes the owner and first member. Pass `visibility: "shared"` to opt into company-wide visibility, and `memberUuids` to seed additional members. +- **Super admin** retains full visibility for governance. + +Member-management tools: `chorus_list_project_members` (`project:read`), `chorus_admin_add_project_member` and `chorus_admin_remove_project_member` (`project:admin`, owner-gated). + +### Two-level visibility (ProjectGroup → Project inheritance) + +Project **groups** carry the same `shared`/`private` + owner + member model, and a project **inherits** its group's accessors as a **dynamic union**: + +- A project's effective accessors = (its own owner + members) **∪** (its group's owner + members). Adding someone to a private group instantly grants them access to **every project in that group** (and all those projects' cascaded entities) — no snapshot. +- **The project's own visibility flag stays authoritative** ("项目级 > 项目组"): a `shared` project inside a `private` group is still company-wide; a `private` project inside a `shared` group is still restricted. The group only *adds* accessors — a shared group never exposes its private projects to everyone. +- Group management (`chorus_admin_create_project_group` with `visibility`/`memberUuids`, `chorus_admin_add_project_group_member`, `chorus_admin_remove_project_group_member`, `chorus_admin_update_project_group`, `chorus_admin_delete_project_group`) is owner-gated; `chorus_list_project_group_members` requires `project:read`. New groups default to `private` (creating agent = owner + first member). A new project with a `groupUuid` defaults to its group's visibility unless `visibility` is passed explicitly. + +### Claim-on-first-manage (legacy / owner-less entities) + +Projects and groups that existed before visibility shipped (or were otherwise created without an owner) have **no owner**. The **first actor who can access and manage** such an entity — via any manage action (set visibility, add/remove member, update, delete) — **claims ownership** of it and is added as a member. This is access-gated: a non-member of a *private* owner-less entity can never claim it (so it opens no privacy hole), and an entity that already has an owner is never reassigned. Member-list tools (`chorus_list_project_members`, `chorus_list_project_group_members`) return a resolved display `name` per member alongside the UUID. + ## Project Filtering -Agents can filter results by project(s) using HTTP headers during MCP connection. This is useful when an agent works on multiple projects and wants to focus on a specific subset. +Agents can filter results by project(s) using HTTP headers during MCP connection. This is useful when an agent works on multiple projects and wants to focus on a specific subset. Note: filtering narrows results *within* the caller's accessible (visibility-permitted) set — it never widens access to private projects the agent is not a member of. ### Available Headers diff --git a/messages/en.json b/messages/en.json index b2df88fa..f1d2bac0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -323,6 +323,23 @@ "gridView": "Grid", "empty": "Empty", "complete": "Complete", + "visibility": "Visibility", + "visibilityShared": "Shared", + "visibilityPrivate": "Private", + "visibilitySharedDesc": "Everyone in your company can access this project.", + "visibilityPrivateDesc": "Only the owner and invited members can access this project.", + "members": "Members", + "addMember": "Add member", + "addMemberPlaceholder": "Search by name or paste a UUID", + "removeMember": "Remove member", + "noMembers": "No members yet.", + "memberUser": "User", + "memberAgent": "Agent", + "visibilityUpdated": "Visibility updated", + "visibilityUpdateFailed": "Failed to update visibility", + "memberAddFailed": "Failed to add member", + "memberRemoveFailed": "Failed to remove member", + "onlyOwnerCanManage": "Only the project owner can manage visibility and members.", "createNew": { "title": "Create New Project", "subtitle": "Set up your project details, then assign ideas to PM Agent later", @@ -934,7 +951,24 @@ "deleteKeepProjects": "Move {count, plural, one {# project} other {# projects}} to Ungrouped", "deleteWithProjects": "Delete {count, plural, one {# project} other {# projects}} permanently", "confirmDelete": "Delete Group", - "deleting": "Deleting..." + "deleting": "Deleting...", + "visibility": "Visibility", + "visibilityShared": "Shared", + "visibilityPrivate": "Private", + "visibilitySharedDesc": "Everyone in your company can access this group.", + "visibilityPrivateDesc": "Only the owner and invited members can access this group.", + "members": "Members", + "addMember": "Add member", + "addMemberPlaceholder": "Search by name or paste a UUID", + "removeMember": "Remove member", + "noMembers": "No members yet.", + "memberUser": "User", + "memberAgent": "Agent", + "visibilityUpdated": "Visibility updated", + "visibilityUpdateFailed": "Failed to update visibility", + "memberAddFailed": "Failed to add member", + "memberRemoveFailed": "Failed to remove member", + "onlyOwnerCanManage": "Only the group owner can manage visibility and members." }, "groupDashboard": { "subtitle": "{count, plural, one {# project} other {# projects}} · Aggregated dashboard", diff --git a/messages/zh.json b/messages/zh.json index 7fe1d26c..575eca5e 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -323,6 +323,23 @@ "gridView": "网格", "empty": "空", "complete": "完成", + "visibility": "可见性", + "visibilityShared": "共享", + "visibilityPrivate": "私有", + "visibilitySharedDesc": "公司内的所有人都可以访问此项目。", + "visibilityPrivateDesc": "仅所有者和受邀成员可以访问此项目。", + "members": "成员", + "addMember": "添加成员", + "addMemberPlaceholder": "按名称搜索或粘贴 UUID", + "removeMember": "移除成员", + "noMembers": "暂无成员。", + "memberUser": "用户", + "memberAgent": "智能体", + "visibilityUpdated": "可见性已更新", + "visibilityUpdateFailed": "更新可见性失败", + "memberAddFailed": "添加成员失败", + "memberRemoveFailed": "移除成员失败", + "onlyOwnerCanManage": "只有项目所有者才能管理可见性和成员。", "createNew": { "title": "创建新项目", "subtitle": "设置项目详情,之后再将想法分配给 产品经理智能体", @@ -935,7 +952,24 @@ "deleteKeepProjects": "将 {count, plural, one {# 个项目} other {# 个项目}} 移至未分组", "deleteWithProjects": "永久删除 {count, plural, one {# 个项目} other {# 个项目}}", "confirmDelete": "删除分组", - "deleting": "删除中..." + "deleting": "删除中...", + "visibility": "可见性", + "visibilityShared": "共享", + "visibilityPrivate": "私有", + "visibilitySharedDesc": "公司内的所有人都可以访问此分组。", + "visibilityPrivateDesc": "仅所有者和受邀成员可以访问此分组。", + "members": "成员", + "addMember": "添加成员", + "addMemberPlaceholder": "按名称搜索或粘贴 UUID", + "removeMember": "移除成员", + "noMembers": "暂无成员。", + "memberUser": "用户", + "memberAgent": "智能体", + "visibilityUpdated": "可见性已更新", + "visibilityUpdateFailed": "更新可见性失败", + "memberAddFailed": "添加成员失败", + "memberRemoveFailed": "移除成员失败", + "onlyOwnerCanManage": "只有分组所有者才能管理可见性和成员。" }, "groupDashboard": { "subtitle": "{count, plural, one {# 个项目} other {# 个项目}} · 聚合仪表盘", diff --git a/prisma/migrations/20260611152319_add_project_visibility/migration.sql b/prisma/migrations/20260611152319_add_project_visibility/migration.sql new file mode 100644 index 00000000..ec7cd95c --- /dev/null +++ b/prisma/migrations/20260611152319_add_project_visibility/migration.sql @@ -0,0 +1,43 @@ +-- AlterTable +-- New column defaults to 'private' so NEWLY created projects are private. +ALTER TABLE "Project" ADD COLUMN "ownerType" TEXT, +ADD COLUMN "ownerUuid" TEXT, +ADD COLUMN "visibility" TEXT NOT NULL DEFAULT 'private'; + +-- Backfill: all PRE-EXISTING projects become 'shared' so no current work +-- becomes inaccessible after this migration. This UPDATE runs once against rows +-- that existed before the column was added; the column DEFAULT keeps future +-- inserts 'private'. +UPDATE "Project" SET "visibility" = 'shared'; + +-- CreateTable +CREATE TABLE "ProjectMember" ( + "id" SERIAL NOT NULL, + "uuid" TEXT NOT NULL, + "companyUuid" TEXT NOT NULL, + "projectUuid" TEXT NOT NULL, + "memberType" TEXT NOT NULL, + "memberUuid" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'member', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ProjectMember_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectMember_uuid_key" ON "ProjectMember"("uuid"); + +-- CreateIndex +CREATE INDEX "ProjectMember_companyUuid_idx" ON "ProjectMember"("companyUuid"); + +-- CreateIndex +CREATE INDEX "ProjectMember_projectUuid_idx" ON "ProjectMember"("projectUuid"); + +-- CreateIndex +CREATE INDEX "ProjectMember_memberType_memberUuid_idx" ON "ProjectMember"("memberType", "memberUuid"); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectMember_projectUuid_memberType_memberUuid_key" ON "ProjectMember"("projectUuid", "memberType", "memberUuid"); + +-- CreateIndex +CREATE INDEX "Project_visibility_idx" ON "Project"("visibility"); diff --git a/prisma/migrations/20260612030604_add_project_group_visibility/migration.sql b/prisma/migrations/20260612030604_add_project_group_visibility/migration.sql new file mode 100644 index 00000000..8a3bf227 --- /dev/null +++ b/prisma/migrations/20260612030604_add_project_group_visibility/migration.sql @@ -0,0 +1,43 @@ +-- AlterTable +-- New column defaults to 'private' so NEWLY created groups are private. +ALTER TABLE "ProjectGroup" ADD COLUMN "ownerType" TEXT, +ADD COLUMN "ownerUuid" TEXT, +ADD COLUMN "visibility" TEXT NOT NULL DEFAULT 'private'; + +-- Backfill: all PRE-EXISTING groups become 'shared' so no current grouping +-- becomes inaccessible after this migration. Runs once against rows that +-- existed before the column was added; the column DEFAULT keeps future +-- inserts 'private'. +UPDATE "ProjectGroup" SET "visibility" = 'shared'; + +-- CreateTable +CREATE TABLE "ProjectGroupMember" ( + "id" SERIAL NOT NULL, + "uuid" TEXT NOT NULL, + "companyUuid" TEXT NOT NULL, + "projectGroupUuid" TEXT NOT NULL, + "memberType" TEXT NOT NULL, + "memberUuid" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'member', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ProjectGroupMember_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectGroupMember_uuid_key" ON "ProjectGroupMember"("uuid"); + +-- CreateIndex +CREATE INDEX "ProjectGroupMember_companyUuid_idx" ON "ProjectGroupMember"("companyUuid"); + +-- CreateIndex +CREATE INDEX "ProjectGroupMember_projectGroupUuid_idx" ON "ProjectGroupMember"("projectGroupUuid"); + +-- CreateIndex +CREATE INDEX "ProjectGroupMember_memberType_memberUuid_idx" ON "ProjectGroupMember"("memberType", "memberUuid"); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectGroupMember_projectGroupUuid_memberType_memberUuid_key" ON "ProjectGroupMember"("projectGroupUuid", "memberType", "memberUuid"); + +-- CreateIndex +CREATE INDEX "ProjectGroup_visibility_idx" ON "ProjectGroup"("visibility"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ccf40cd4..4be907da 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -106,10 +106,37 @@ model ProjectGroup { companyUuid String name String description String? @default("") + // Visibility & ownership (project-access.ts gates on these; projects inherit + // a group's owner+members as additional accessors via dynamic union). + visibility String @default("private") // "shared" (whole company) | "private" (owner + members) + ownerType String? // "user" | "agent" | null (null = legacy/migrated shared group) + ownerUuid String? // User or Agent UUID of the owner createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + members ProjectGroupMember[] + + @@index([companyUuid]) + @@index([visibility]) +} + +// Project Group membership — who can access a private group (and, by dynamic +// union, all projects in that group). Mirrors ProjectMember. +model ProjectGroupMember { + id Int @id @default(autoincrement()) + uuid String @unique @default(uuid()) + companyUuid String // multi-tenancy defense-in-depth + projectGroupUuid String + group ProjectGroup @relation(fields: [projectGroupUuid], references: [uuid], onDelete: Cascade) + memberType String // "user" | "agent" + memberUuid String // User or Agent UUID + role String @default("member") // reserved for future per-member RBAC + createdAt DateTime @default(now()) + + @@unique([projectGroupUuid, memberType, memberUuid]) @@index([companyUuid]) + @@index([projectGroupUuid]) + @@index([memberType, memberUuid]) } // Project @@ -121,6 +148,10 @@ model Project { name String description String? groupUuid String? // nullable FK → ProjectGroup.uuid + // Visibility & ownership (project-access.ts gates on these) + visibility String @default("private") // "shared" (whole company) | "private" (owner + members) + ownerType String? // "user" | "agent" | null (null = legacy/migrated shared project) + ownerUuid String? // User or Agent UUID of the owner createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -129,9 +160,29 @@ model Project { tasks Task[] proposals Proposal[] activities Activity[] + members ProjectMember[] @@index([companyUuid]) @@index([groupUuid]) + @@index([visibility]) +} + +// Project membership — who can access a private project (users or agents) +model ProjectMember { + id Int @id @default(autoincrement()) + uuid String @unique @default(uuid()) + companyUuid String // multi-tenancy defense-in-depth + projectUuid String + project Project @relation(fields: [projectUuid], references: [uuid], onDelete: Cascade) + memberType String // "user" | "agent" + memberUuid String // User or Agent UUID + role String @default("member") // reserved for future per-member RBAC + createdAt DateTime @default(now()) + + @@unique([projectUuid, memberType, memberUuid]) + @@index([companyUuid]) + @@index([projectUuid]) + @@index([memberType, memberUuid]) // reverse lookup: "projects this actor belongs to" } // Idea (raw human input) diff --git a/public/chorus-plugin/skills/chorus/SKILL.md b/public/chorus-plugin/skills/chorus/SKILL.md index 7e3ceaab..d10c90b7 100644 --- a/public/chorus-plugin/skills/chorus/SKILL.md +++ b/public/chorus-plugin/skills/chorus/SKILL.md @@ -128,9 +128,19 @@ Projects can be organized into **Project Groups** — a single-level grouping th | Tool | Purpose | |------|---------| -| `chorus_list_projects` | List all projects (paginated, with entity counts) | +| `chorus_list_projects` | List all projects you can access (paginated, with entity counts) | | `chorus_get_project` | Get project details | | `chorus_get_activity` | Get project activity stream (paginated) | +| `chorus_list_project_members` | List a project's members (`project:read`) | +| `chorus_admin_add_project_member` | Add a user/agent to a project (`project:admin`, owner-gated) | +| `chorus_admin_remove_project_member` | Remove a member (`project:admin`, owner-gated) | +| `chorus_list_project_group_members` | List a project group's members (`project:read`) | +| `chorus_admin_add_project_group_member` | Add a user/agent to a group (`project:admin`, owner-gated) | +| `chorus_admin_remove_project_group_member` | Remove a group member (`project:admin`, owner-gated) | + +### Project Visibility (Private / Shared) + two-level inheritance + +A project (and a project **group**) is **`shared`** (whole company) or **`private`** (owner + explicit members). Membership — not permission — grants access: a private project hides itself **and all its ideas, proposals, documents, tasks, activity, comments, notifications, and search results** from non-members, and `project:admin` does NOT bypass this. `chorus_admin_create_project` / `chorus_admin_create_project_group` default new entities to **private** (creating agent = owner + first member); pass `visibility: "shared"` for company-wide entities and `memberUuids` to seed members (users or agents). **Two-level inheritance (dynamic union)**: a project inherits its group's owner+members as additional accessors (add someone to a private group → they reach every project in it, instantly); the project's own visibility flag stays authoritative (`shared`-in-`private`-group is still company-wide; `private`-in-`shared`-group is still restricted). **Claim-on-first-manage**: a legacy/owner-less project or group is claimed by the first actor who can access + manage it (becomes owner + member); access-gated (no claiming a private owner-less entity you can't access) and never reassigns an existing owner. Member lists resolve a display name per member. ### Ideas diff --git a/public/skill/chorus/SKILL.md b/public/skill/chorus/SKILL.md index 6a75d007..58a80453 100644 --- a/public/skill/chorus/SKILL.md +++ b/public/skill/chorus/SKILL.md @@ -172,9 +172,25 @@ Results can be filtered by project(s) using optional HTTP headers in your MCP co | Tool | Purpose | |------|---------| -| `chorus_list_projects` | List all projects (paginated, with entity counts) | +| `chorus_list_projects` | List all projects you can access (paginated, with entity counts) | | `chorus_get_project` | Get project details | | `chorus_get_activity` | Get project activity stream (paginated) | +| `chorus_list_project_members` | List a project's members (`project:read`) | +| `chorus_admin_add_project_member` | Add a user/agent to a project (`project:admin`, owner-gated) | +| `chorus_admin_remove_project_member` | Remove a member (`project:admin`, owner-gated) | +| `chorus_list_project_group_members` | List a project group's members (`project:read`) | +| `chorus_admin_add_project_group_member` | Add a user/agent to a group (`project:admin`, owner-gated) | +| `chorus_admin_remove_project_group_member` | Remove a group member (`project:admin`, owner-gated) | + +### Project Visibility (Private / Shared) + two-level inheritance + +A project (and a project **group**) is **`shared`** (whole company) or **`private`** (owner + explicit members). Membership — not permission — is what grants access: + +- A private project hides itself **and all its ideas, proposals, documents, tasks, activity, comments, notifications, and search results** from non-members. Holding `project:admin` does NOT bypass this; you must be a member. +- `chorus_admin_create_project` / `chorus_admin_create_project_group` default new entities to **private**, with the creating agent as owner + first member. Pass `visibility: "shared"` for company-wide entities, and `memberUuids` to seed members. +- Members may be users *or* agents — an agent must be added as a member to work a private project's tasks. +- **Two-level inheritance (dynamic union)**: a project inherits its group's owner+members as additional accessors — add someone to a private group and they instantly reach every project in it. The project's own visibility stays authoritative: a `shared` project in a `private` group is still company-wide; a `private` project in a `shared` group is still restricted. A new project created with a `groupUuid` defaults to its group's visibility. +- **Claim-on-first-manage**: a legacy/owner-less project or group is claimed by the first actor who can access + manage it (any manage action: set visibility, add/remove member, update, delete) — they become owner and a member. Access-gated (a non-member of a private owner-less entity can't claim it) and never reassigns an existing owner. Member lists return a resolved display name per member. ### Ideas diff --git a/src/__tests__/integration/acceptance-criteria-enforcement.integration.test.ts b/src/__tests__/integration/acceptance-criteria-enforcement.integration.test.ts index cdad731c..9f0119b1 100644 --- a/src/__tests__/integration/acceptance-criteria-enforcement.integration.test.ts +++ b/src/__tests__/integration/acceptance-criteria-enforcement.integration.test.ts @@ -90,6 +90,8 @@ vi.mock("@/services/checkin.service", () => ({})); // Real modules under test — NOT mocked. import { addTaskDraft, updateTaskDraft } from "@/services/proposal.service"; +import type { SuperAdminAuthContext } from "@/types/auth"; +const adminAuth: SuperAdminAuthContext = { type: "super_admin", email: "root@chorus.local" }; import { registerPublicTools } from "@/mcp/tools/public"; import { normalizeAcceptanceCriteria } from "@/lib/acceptance-criteria"; import type { AgentAuthContext } from "@/types/auth"; @@ -171,14 +173,14 @@ describe("AC enforcement — cross-layer integration", () => { // Missing AC → rejected by the shared helper, nothing written. await expect( - addTaskDraft("prop-1", COMPANY, { title: "No AC" }), + addTaskDraft("prop-1", COMPANY, { title: "No AC" }, adminAuth), ).rejects.toThrow("acceptance criterion"); // Non-empty AC (with a blank dropped) → persisted normalized. await addTaskDraft("prop-1", COMPANY, { title: "With AC", acceptanceCriteriaItems: [{ description: " real " }, { description: " " }], - }); + }, adminAuth); const drafts = (proposalStore.current as { taskDrafts: Array<{ title: string; acceptanceCriteriaItems: unknown }> }).taskDrafts; expect(drafts).toHaveLength(1); expect(drafts[0].acceptanceCriteriaItems).toEqual([{ description: "real", required: true }]); @@ -263,7 +265,7 @@ describe("AC enforcement — cross-layer integration", () => { const result = await toolHandlers["chorus_update_task"]({ taskUuid: "task-1", addDependsOn: ["dep-1"] }); expect(isError(result)).toBe(false); - expect(mockTaskService.addTaskDependency).toHaveBeenCalledWith(COMPANY, "task-1", "dep-1"); + expect(mockTaskService.addTaskDependency).toHaveBeenCalledWith(COMPANY, "task-1", "dep-1", expect.anything()); expect(mockTaskService.replaceAcceptanceCriteria).not.toHaveBeenCalled(); expect(acStore).toEqual([expect.objectContaining({ description: "keep" })]); }); @@ -277,7 +279,7 @@ describe("AC enforcement — cross-layer integration", () => { createdAt: new Date("2026-06-02T00:00:00Z"), updatedAt: new Date("2026-06-02T00:00:00Z"), }; - await updateTaskDraft("prop-1", COMPANY, "td-1", { title: "Renamed" }); + await updateTaskDraft("prop-1", COMPANY, "td-1", { title: "Renamed" }, adminAuth); const drafts = (proposalStore.current as { taskDrafts: Array<{ title: string; acceptanceCriteriaItems: unknown }> }).taskDrafts; expect(drafts[0].title).toBe("Renamed"); diff --git a/src/__tests__/integration/cascade-move.integration.test.ts b/src/__tests__/integration/cascade-move.integration.test.ts index f810c7ba..528107c3 100644 --- a/src/__tests__/integration/cascade-move.integration.test.ts +++ b/src/__tests__/integration/cascade-move.integration.test.ts @@ -85,6 +85,8 @@ vi.mock("@/services/activity.service", () => ({ })); import { moveIdea, moveIdeaPreview } from "@/services/idea.service"; +import type { SuperAdminAuthContext } from "@/types/auth"; +const cascadeAdminAuth: SuperAdminAuthContext = { type: "super_admin", email: "root@chorus.local" }; // ===== Tests ===== @@ -99,13 +101,13 @@ describe("cross-project Idea cascade move (integration)", () => { seedFullPipelineFixture(); // ----- preview ----- - const preview = await moveIdeaPreview(FULL_COMPANY_A, FULL_IDEA_UUID, FULL_P_NEW); + const preview = await moveIdeaPreview(FULL_COMPANY_A, FULL_IDEA_UUID, FULL_P_NEW, cascadeAdminAuth); // 3 proposals (approved + draft + rejected), 1 document, 3 tasks, 8 // historical activity rows (1 idea + 3 proposals + 1 document + 3 tasks). expect(preview.moved).toEqual({ proposals: 3, documents: 1, tasks: 3, activities: 8 }); // ----- real move ----- - const result = await moveIdea(FULL_COMPANY_A, FULL_IDEA_UUID, FULL_P_NEW, "user-1", "user"); + const result = await moveIdea(FULL_COMPANY_A, FULL_IDEA_UUID, FULL_P_NEW, "user-1", "user", cascadeAdminAuth); // Counts match the preview exactly (no concurrent writes scenario). expect(result.moved).toEqual(preview.moved); @@ -163,7 +165,7 @@ describe("cross-project Idea cascade move (integration)", () => { notifications: JSON.stringify(cascadeMoveStore.notifications), }; - await moveIdea(FULL_COMPANY_A, FULL_IDEA_UUID, FULL_P_NEW, "user-1", "user"); + await moveIdea(FULL_COMPANY_A, FULL_IDEA_UUID, FULL_P_NEW, "user-1", "user", cascadeAdminAuth); expect(JSON.stringify(cascadeMoveStore.comments)).toBe(beforeSnapshot.comments); expect(JSON.stringify(cascadeMoveStore.taskDependencies)).toBe(beforeSnapshot.taskDependencies); @@ -186,7 +188,7 @@ describe("cross-project Idea cascade move (integration)", () => { const foreign = cascadeMoveStore.proposals.find((p) => p.companyUuid === FULL_COMPANY_B)!; const foreignBefore = JSON.stringify(foreign); - const result = await moveIdea(FULL_COMPANY_A, FULL_IDEA_UUID, FULL_P_NEW, "user-1", "user"); + const result = await moveIdea(FULL_COMPANY_A, FULL_IDEA_UUID, FULL_P_NEW, "user-1", "user", cascadeAdminAuth); // Foreign-company row is byte-equal pre/post. expect(JSON.stringify(cascadeMoveStore.proposals.find((p) => p.companyUuid === FULL_COMPANY_B)!)).toBe( diff --git a/src/__tests__/integration/idea-completion-report.integration.test.ts b/src/__tests__/integration/idea-completion-report.integration.test.ts index 8565af66..eec9cb09 100644 --- a/src/__tests__/integration/idea-completion-report.integration.test.ts +++ b/src/__tests__/integration/idea-completion-report.integration.test.ts @@ -100,6 +100,19 @@ const mockPrisma = vi.hoisted(() => { // Defined lazily so each call sees the live mutated `store`. return { prisma: { + // Project-visibility gate: canAccessProject() resolves project access via + // these models. A "shared" project is accessible to any actor, so the + // cascade gate does not block the report write/read paths under test. + project: { + findFirst: vi.fn(async () => ({ + visibility: "shared", + ownerType: "user", + ownerUuid: "owner-x", + })), + }, + projectMember: { + findUnique: vi.fn(async () => ({ id: 1 })), + }, proposal: { findFirst: vi.fn(async ({ where }: { where: { uuid: string; companyUuid: string } }) => { // store is captured via closure — use globalThis to dodge hoisting init order diff --git a/src/__tests__/integration/permissions.test.ts b/src/__tests__/integration/permissions.test.ts index b732429f..1b1c3aed 100644 --- a/src/__tests__/integration/permissions.test.ts +++ b/src/__tests__/integration/permissions.test.ts @@ -288,6 +288,28 @@ const PM_AGENT_ADDED_IN_0_9_0 = [ "chorus_create_report", ]; +// Project-visibility feature: chorus_list_project_members is gated on +// project:read (pm_agent carries it). The two mutating member tools are gated +// on project:admin, which pm_agent does NOT carry — so only the list tool +// appears in the pm visibility set. admin_agent (carries project:admin) gets +// all three (see ADMIN_AGENT_ADDED_PROJECT_VISIBILITY below). +// The group-visibility feature mirrors the project member tools: the list tool +// is project:read-gated (pm_agent + developer_agent see it), the two mutators +// are project:admin-gated (only admin_agent sees them). +const PM_AGENT_ADDED_PROJECT_VISIBILITY = [ + "chorus_list_project_members", + "chorus_list_project_group_members", +]; + +const ADMIN_AGENT_ADDED_PROJECT_VISIBILITY = [ + "chorus_list_project_members", + "chorus_admin_add_project_member", + "chorus_admin_remove_project_member", + "chorus_list_project_group_members", + "chorus_admin_add_project_group_member", + "chorus_admin_remove_project_group_member", +]; + // ===== Shared beforeEach ===== beforeEach(() => { @@ -441,10 +463,11 @@ describe("Scenario 1: custom permissions agent end-to-end (AC1)", () => { // ============================================================ describe("Scenario 2: preset parity with 0.6.x baseline (AC2)", () => { - it("developer_agent preset registers exactly the 0.6.x developer tool set", () => { + it("developer_agent preset registers exactly the 0.6.x developer tool set plus the project-visibility list-members tool", () => { const auth = makeAgentAuth([...ROLE_PRESETS.developer_agent], ["developer_agent"]); const tools = enumerateGatedMcpTools(auth); - expect(tools).toEqual(new Set(OLD_DEVELOPER_TOOLS)); + // developer_agent carries project:read, so it sees chorus_list_project_members. + expect(tools).toEqual(new Set([...OLD_DEVELOPER_TOOLS, ...PM_AGENT_ADDED_PROJECT_VISIBILITY])); }); it("admin_agent preset registers exactly the 0.6.x admin ∪ pm ∪ developer tool set plus 0.9.0 chorus_create_report and 0.9.4 chorus_pm_validate_elaboration", () => { @@ -459,6 +482,9 @@ describe("Scenario 2: preset parity with 0.6.x baseline (AC2)", () => { // 0.9.4 (simplify-elaboration-flow): chorus_pm_validate_elaboration is // re-gated to idea:admin. admin_agent carries idea:admin. "chorus_pm_validate_elaboration", + // project-visibility feature: member-management tools (project:read + + // project:admin gated). admin_agent carries both. + ...ADMIN_AGENT_ADDED_PROJECT_VISIBILITY, ]); expect(tools).toEqual(expected); }); @@ -482,7 +508,7 @@ describe("Scenario 2: preset parity with 0.6.x baseline (AC2)", () => { const tools = enumerateGatedMcpTools(auth); const baseline = new Set(OLD_PM_TOOLS); const diff = Array.from(tools).filter((t) => !baseline.has(t)).sort(); - expect(diff).toEqual([...PM_AGENT_ADDED_IN_0_7_0, ...PM_AGENT_ADDED_IN_0_9_0].sort()); + expect(diff).toEqual([...PM_AGENT_ADDED_IN_0_7_0, ...PM_AGENT_ADDED_IN_0_9_0, ...PM_AGENT_ADDED_PROJECT_VISIBILITY].sort()); }); it("pm_agent preset does not leak any *:admin-gated tool", () => { @@ -501,6 +527,14 @@ describe("Scenario 2: preset parity with 0.6.x baseline (AC2)", () => { // 0.9.4: chorus_pm_validate_elaboration is now idea:admin-gated; pm_agent // (idea:write only) must not see it. "chorus_pm_validate_elaboration", + // project-visibility: member mutation tools are project:admin-gated; + // pm_agent (project:write only) must not see them. + "chorus_admin_add_project_member", + "chorus_admin_remove_project_member", + // group-visibility: group member mutation tools are project:admin-gated; + // pm_agent (project:write only) must not see them. + "chorus_admin_add_project_group_member", + "chorus_admin_remove_project_group_member", ]) { expect(tools.has(adminOnly)).toBe(false); } diff --git a/src/__tests__/integration/project-visibility.integration.test.ts b/src/__tests__/integration/project-visibility.integration.test.ts new file mode 100644 index 00000000..807068f1 --- /dev/null +++ b/src/__tests__/integration/project-visibility.integration.test.ts @@ -0,0 +1,579 @@ +// src/__tests__/integration/project-visibility.integration.test.ts +// +// BLOCKER-2 safeguard (Tech Design §8.1): proves the project-visibility privacy +// boundary actually holds end-to-end across the whole cascade, driving the REAL +// authz layer (src/lib/authz/project-access.ts) and the REAL service functions +// against a faithful in-memory Prisma stub. Nothing about access control is +// mocked — only the database is in-memory. +// +// Scenario: +// - PRIVATE project P (owner = user A; member = agent M) +// - SHARED project S (no explicit members) +// - Non-members: user B, agent N (N carries project:admin to prove the +// permission bitset grants NO bypass) +// - super_admin SA (sees everything) +// Each project has one idea / proposal / document / task / activity row, a +// notification for each recipient, and a comment on the task. +// +// Assertions: non-members are denied reads AND writes on P across +// project/idea/proposal/document/task/activity/notification/search/comment; +// owner + member + super_admin are allowed; the shared project S stays visible +// to everyone (regression); and a projectUuids[] header does not grant access. + +import { describe, it, expect, beforeEach, vi } from "vitest"; + +// ---- in-memory store (hoisted so the vi.mock factory can reach it) ---- +const { db } = vi.hoisted(() => ({ + db: { + project: [] as any[], + projectMember: [] as any[], + idea: [] as any[], + proposal: [] as any[], + document: [] as any[], + task: [] as any[], + activity: [] as any[], + notification: [] as any[], + comment: [] as any[], + user: [] as any[], + agent: [] as any[], + projectGroup: [] as any[], + projectGroupMember: [] as any[], + taskDependency: [] as any[], + acceptanceCriterion: [] as any[], + } as Record, +})); + +// Generic where-matcher supporting the operators the gated services use: +// equality, { in: [...] }, { not: x }, { contains, mode }, and top-level OR. +function matchWhere(row: any, where: any): boolean { + if (!where) return true; + for (const [key, cond] of Object.entries(where)) { + if (key === "OR") { + if (!(cond as any[]).some((sub) => matchWhere(row, sub))) return false; + continue; + } + if (key === "AND") { + if (!(cond as any[]).every((sub) => matchWhere(row, sub))) return false; + continue; + } + const val = row[key]; + if (cond && typeof cond === "object" && !Array.isArray(cond)) { + if ("in" in cond) { + if (!(cond.in as any[]).includes(val)) return false; + } else if ("not" in cond) { + if (cond.not === null ? val === null || val === undefined : val === cond.not) return false; + } else if ("contains" in cond) { + const hay = String(val ?? ""); + const needle = String(cond.contains); + const ok = cond.mode === "insensitive" + ? hay.toLowerCase().includes(needle.toLowerCase()) + : hay.includes(needle); + if (!ok) return false; + } else { + // nested object equality not used by these queries + if (val !== cond) return false; + } + } else { + if (val !== cond) return false; + } + } + return true; +} + +// Hydrate the `project` relation (select { name }/{ uuid, name }) that task +// reads/searches `include`/`select`. Other relations are pre-seeded as []. +function hydrate(name: string, row: any): any { + if (!row) return row; + if (name === "task" || name === "idea" || name === "proposal" || name === "document") { + const proj = db.project.find((p) => p.uuid === row.projectUuid); + return { ...row, project: proj ? { uuid: proj.uuid, name: proj.name } : undefined }; + } + return { ...row }; +} + +function makeModel(name: string) { + const rows = () => db[name]; + return { + findFirst: vi.fn(async ({ where }: any = {}) => { + const r = rows().find((row) => matchWhere(row, where)); + return r ? hydrate(name, r) : null; + }), + findUnique: vi.fn(async ({ where }: any = {}) => { + // composite unique key used by ProjectMember + if (where?.projectUuid_memberType_memberUuid) { + const k = where.projectUuid_memberType_memberUuid; + return rows().find((r) => r.projectUuid === k.projectUuid && r.memberType === k.memberType && r.memberUuid === k.memberUuid) ?? null; + } + // composite unique key used by ProjectGroupMember + if (where?.projectGroupUuid_memberType_memberUuid) { + const k = where.projectGroupUuid_memberType_memberUuid; + return rows().find((r) => r.projectGroupUuid === k.projectGroupUuid && r.memberType === k.memberType && r.memberUuid === k.memberUuid) ?? null; + } + const r = rows().find((row) => matchWhere(row, where)); + return r ? hydrate(name, r) : null; + }), + findMany: vi.fn(async ({ where, take }: any = {}) => { + let out = rows().filter((r) => matchWhere(r, where)); + if (typeof take === "number") out = out.slice(0, take); + return out.map((r) => hydrate(name, r)); + }), + count: vi.fn(async ({ where }: any = {}) => rows().filter((r) => matchWhere(r, where)).length), + groupBy: vi.fn(async ({ by, where }: any = {}) => { + const matched = rows().filter((r) => matchWhere(r, where)); + const key = (by as string[])[0]; + const groups = new Map(); + for (const r of matched) groups.set(r[key], (groups.get(r[key]) ?? 0) + 1); + return [...groups.entries()].map(([k, n]) => ({ [key]: k, _count: { [key]: n, _all: n } })); + }), + create: vi.fn(async ({ data }: any) => { + const row = { + uuid: data.uuid ?? `${name}-${rows().length + 1}-${Math.floor(performance.now() * 1000)}`, + createdAt: new Date(), + updatedAt: new Date(), + ...data, + }; + rows().push(row); + return { ...row }; + }), + update: vi.fn(async ({ where, data }: any) => { + const row = rows().find((r) => matchWhere(r, where)); + if (!row) { + const e: any = new Error("Record to update not found"); + e.code = "P2025"; + throw e; + } + Object.assign(row, data, { updatedAt: new Date() }); + // task.update includes project relation in some callers + return { ...row, project: db.project.find((p) => p.uuid === row.projectUuid) }; + }), + updateMany: vi.fn(async ({ where, data }: any) => { + const matched = rows().filter((r) => matchWhere(r, where)); + for (const row of matched) Object.assign(row, data, { updatedAt: new Date() }); + return { count: matched.length }; + }), + upsert: vi.fn(async ({ where, create }: any) => { + // composite-key upsert used for seeding owner membership rows. Match on the + // SPECIFIC key field present (projectUuid for ProjectMember, projectGroupUuid + // for ProjectGroupMember) — never via undefined===undefined. + const pk = where.projectUuid_memberType_memberUuid; + const gk = where.projectGroupUuid_memberType_memberUuid; + let row; + if (pk) { + row = rows().find((r) => r.projectUuid === pk.projectUuid && r.memberType === pk.memberType && r.memberUuid === pk.memberUuid); + } else if (gk) { + row = rows().find((r) => r.projectGroupUuid === gk.projectGroupUuid && r.memberType === gk.memberType && r.memberUuid === gk.memberUuid); + } else { + row = rows().find((r) => matchWhere(r, where)); + } + if (!row) { + row = { uuid: `${name}-${rows().length + 1}`, createdAt: new Date(), updatedAt: new Date(), ...create }; + rows().push(row); + } + return { ...row }; + }), + }; +} + +const mockPrisma = vi.hoisted(() => ({} as any)); +for (const _ of []) void _; // noop to keep hoist ordering clear + +vi.mock("@/lib/prisma", () => { + // Build the prisma stub lazily so `db` is populated per-test. + const models = ["project", "projectMember", "idea", "proposal", "document", "task", "activity", "notification", "comment", "user", "agent", "projectGroup", "projectGroupMember", "taskDependency", "acceptanceCriterion"]; + const client: any = {}; + for (const m of models) client[m] = makeModel(m); + client.$transaction = async (arg: any) => (typeof arg === "function" ? arg(client) : Promise.all(arg)); + Object.assign(mockPrisma, client); + return { prisma: client }; +}); + +// event bus is fire-and-forget; stub it +vi.mock("@/lib/event-bus", () => ({ eventBus: { emitChange: vi.fn() } })); + +import { + canAccessProject, + getAccessibleProjectUuids, + claimOrCanManageProject, + claimOrCanManageGroup, + canManageProject, + canManageGroup, +} from "@/lib/authz/project-access"; +import * as projectService from "@/services/project.service"; +import * as taskService from "@/services/task.service"; +import * as commentService from "@/services/comment.service"; +import * as activityService from "@/services/activity.service"; +import * as searchService from "@/services/search.service"; +import * as notificationService from "@/services/notification.service"; +import type { AuthContext, SuperAdminAuthContext, AgentAuthContext } from "@/types/auth"; + +const COMPANY = "co-1"; +const P = "proj-private"; +const S = "proj-shared"; + +// actors +const A: AuthContext = { type: "user", companyUuid: COMPANY, actorUuid: "userA" }; // owner of P +const M: AuthContext = { type: "agent", companyUuid: COMPANY, actorUuid: "agentM" }; // member of P +const B: AuthContext = { type: "user", companyUuid: COMPANY, actorUuid: "userB" }; // non-member +const N: AgentAuthContext = { // non-member, project:admin + type: "agent", companyUuid: COMPANY, actorUuid: "agentN", + roles: ["admin_agent"], permissions: ["project:read", "project:write", "project:admin", "task:read", "task:write", "idea:read"], + agentName: "AdminBot", projectUuids: [P], // header claims P — must NOT grant access +}; +const SA: SuperAdminAuthContext = { type: "super_admin", email: "root@chorus.local" }; + +function seed() { + for (const k of Object.keys(db)) db[k].length = 0; + db.user.push({ uuid: "userA", companyUuid: COMPANY, name: "A" }, { uuid: "userB", companyUuid: COMPANY, name: "B" }); + db.agent.push({ uuid: "agentM", companyUuid: COMPANY, name: "M" }, { uuid: "agentN", companyUuid: COMPANY, name: "N" }); + + db.project.push( + { uuid: P, companyUuid: COMPANY, name: "Private", description: "secret", groupUuid: null, visibility: "private", ownerType: "user", ownerUuid: "userA" }, + { uuid: S, companyUuid: COMPANY, name: "Shared", description: "open", groupUuid: null, visibility: "shared", ownerType: "user", ownerUuid: "userA" }, + ); + // P members: owner A + agent M + db.projectMember.push( + { uuid: "pm-a", companyUuid: COMPANY, projectUuid: P, memberType: "user", memberUuid: "userA", role: "member" }, + { uuid: "pm-m", companyUuid: COMPANY, projectUuid: P, memberType: "agent", memberUuid: "agentM", role: "member" }, + ); + + for (const proj of [P, S]) { + const tag = proj === P ? "priv" : "shar"; + db.idea.push({ uuid: `idea-${tag}`, companyUuid: COMPANY, projectUuid: proj, title: `idea ${tag} secretword`, content: null, status: "open", assigneeType: null, assigneeUuid: null, createdByUuid: "userA" }); + db.proposal.push({ uuid: `prop-${tag}`, companyUuid: COMPANY, projectUuid: proj, title: `prop ${tag} secretword`, status: "pending", createdByUuid: "userA" }); + db.document.push({ uuid: `doc-${tag}`, companyUuid: COMPANY, projectUuid: proj, type: "tech_design", title: `doc ${tag} secretword`, content: null, version: 1, proposalUuid: null, createdByUuid: "userA" }); + db.task.push({ uuid: `task-${tag}`, companyUuid: COMPANY, projectUuid: proj, title: `task ${tag} secretword`, description: null, status: "open", priority: "medium", storyPoints: null, acceptanceCriteria: null, assigneeType: null, assigneeUuid: null, assignedAt: null, assignedByUuid: null, proposalUuid: null, createdByUuid: "userA", dependsOn: [], dependedBy: [], acceptanceCriteriaItems: [] }); + db.activity.push({ uuid: `act-${tag}`, companyUuid: COMPANY, projectUuid: proj, targetType: "task", targetUuid: `task-${tag}`, actorType: "user", actorUuid: "userA", action: "created", value: null, sessionUuid: null, sessionName: null }); + db.comment.push({ uuid: `cmt-${tag}`, companyUuid: COMPANY, targetType: "task", targetUuid: `task-${tag}`, content: "hi", authorType: "user", authorUuid: "userA" }); + // a project-scoped notification for user B (recipient) referencing each project + db.notification.push({ uuid: `ntf-${tag}`, companyUuid: COMPANY, recipientType: "user", recipientUuid: "userB", projectUuid: proj, type: "mention", title: "n", body: "", entityType: null, entityUuid: null, actorType: null, actorUuid: null, readAt: null, archivedAt: null }); + } + // a non-project notification for B (projectUuid "") — must always be visible + db.notification.push({ uuid: "ntf-global", companyUuid: COMPANY, recipientType: "user", recipientUuid: "userB", projectUuid: "", type: "system", title: "g", body: "", entityType: null, entityUuid: null, actorType: null, actorUuid: null, readAt: null, archivedAt: null }); + + // Stamp timestamps on every seeded row so service formatters (.toISOString()) + // work against the in-memory store. + const now = new Date("2026-06-11T00:00:00Z"); + for (const k of Object.keys(db)) { + for (const row of db[k]) { + if (row.createdAt === undefined) row.createdAt = now; + if (row.updatedAt === undefined) row.updatedAt = now; + } + } +} + +beforeEach(() => { + vi.clearAllMocks(); + seed(); +}); + +describe("project-visibility cascade — canAccessProject core", () => { + it("private project P: owner A and member M allowed; non-members B and N denied; super_admin allowed", async () => { + expect(await canAccessProject(A, P)).toBe(true); + expect(await canAccessProject(M, P)).toBe(true); + expect(await canAccessProject(B, P)).toBe(false); + expect(await canAccessProject(N, P)).toBe(false); // project:admin does NOT bypass + expect(await canAccessProject(SA, P)).toBe(true); + }); + + it("shared project S: visible to every company actor", async () => { + for (const actor of [A, M, B, N, SA]) { + expect(await canAccessProject(actor, S)).toBe(true); + } + }); + + it("AgentAuthContext.projectUuids[] header does NOT grant a non-member access", async () => { + // N's header lists P, yet access is derived purely from membership. + expect(N.projectUuids).toContain(P); + expect(await canAccessProject(N, P)).toBe(false); + }); +}); + +describe("project-visibility cascade — single-entity reads", () => { + it("getProject(P): owner/member/SA get it, non-members get null", async () => { + expect(await projectService.getProject(COMPANY, P, A)).not.toBeNull(); + expect(await projectService.getProject(COMPANY, P, M)).not.toBeNull(); + expect(await projectService.getProject(COMPANY, P, SA)).not.toBeNull(); + expect(await projectService.getProject(COMPANY, P, B)).toBeNull(); + expect(await projectService.getProject(COMPANY, P, N)).toBeNull(); + }); + + it("getTask on private task: members allowed, non-members get null", async () => { + expect(await taskService.getTask(COMPANY, "task-priv", A)).not.toBeNull(); + expect(await taskService.getTask(COMPANY, "task-priv", M)).not.toBeNull(); + expect(await taskService.getTask(COMPANY, "task-priv", B)).toBeNull(); + expect(await taskService.getTask(COMPANY, "task-priv", N)).toBeNull(); + expect(await taskService.getTask(COMPANY, "task-priv", SA)).not.toBeNull(); + }); + + it("shared task is readable by a non-member of P (regression)", async () => { + expect(await taskService.getTask(COMPANY, "task-shar", B)).not.toBeNull(); + expect(await taskService.getTask(COMPANY, "task-shar", N)).not.toBeNull(); + }); +}); + +describe("project-visibility cascade — list reads", () => { + it("listProjects: non-members never see P; owner/member do; both see S", async () => { + const forB = await projectService.listProjects({ companyUuid: COMPANY, skip: 0, take: 50, auth: B }); + const uuidsB = forB.projects.map((p: any) => p.uuid); + expect(uuidsB).toContain(S); + expect(uuidsB).not.toContain(P); + + const forA = await projectService.listProjects({ companyUuid: COMPANY, skip: 0, take: 50, auth: A }); + expect(forA.projects.map((p: any) => p.uuid).sort()).toEqual([P, S].sort()); + + const forSA = await projectService.listProjects({ companyUuid: COMPANY, skip: 0, take: 50, auth: SA }); + expect(forSA.projects.map((p: any) => p.uuid).sort()).toEqual([P, S].sort()); + }); + + it("listTasks on P: empty for non-members, populated for members", async () => { + const params = (auth: any) => ({ companyUuid: COMPANY, projectUuid: P, skip: 0, take: 50, auth }); + expect((await taskService.listTasks(params(B))).total).toBe(0); + expect((await taskService.listTasks(params(N))).total).toBe(0); + expect((await taskService.listTasks(params(M))).total).toBe(1); + expect((await taskService.listTasks(params(A))).total).toBe(1); + }); + + it("listActivities on P: empty for non-members, populated for members", async () => { + const params = (auth: any) => ({ companyUuid: COMPANY, projectUuid: P, skip: 0, take: 50, auth }); + expect((await activityService.listActivities(params(B))).total).toBe(0); + expect((await activityService.listActivities(params(N))).total).toBe(0); + expect((await activityService.listActivities(params(M))).total).toBe(1); + }); +}); + +describe("project-visibility cascade — search never leaks private entities", () => { + const baseSearch = (auth: any) => ({ query: "secretword", companyUuid: COMPANY, auth }); + + it("non-member global search returns only shared-project entities", async () => { + const res = await searchService.search(baseSearch(B)); + const projectUuidsHit = new Set(res.results.map((r: any) => r.projectUuid).filter(Boolean)); + expect(projectUuidsHit.has(P)).toBe(false); + // shared entities still found + expect(res.results.some((r: any) => r.projectUuid === S)).toBe(true); + }); + + it("project:admin non-member search still cannot see private entities", async () => { + const res = await searchService.search(baseSearch(N)); + expect(res.results.some((r: any) => r.projectUuid === P)).toBe(false); + }); + + it("member search DOES see private entities", async () => { + const res = await searchService.search(baseSearch(M)); + expect(res.results.some((r: any) => r.projectUuid === P)).toBe(true); + }); +}); + +describe("project-visibility cascade — notifications", () => { + it("non-member B does not receive the private-project notification but keeps shared + global ones", async () => { + const res = await notificationService.list({ companyUuid: COMPANY, recipientType: "user", recipientUuid: "userB", auth: B }); + const uuids = res.notifications.map((n: any) => n.uuid); + expect(uuids).not.toContain("ntf-priv"); // private project -> hidden + expect(uuids).toContain("ntf-shar"); // shared project -> visible + expect(uuids).toContain("ntf-global"); // non-project -> always visible + }); + + it("super_admin sees all of B's notifications including the private-project one", async () => { + const res = await notificationService.list({ companyUuid: COMPANY, recipientType: "user", recipientUuid: "userB", auth: SA }); + expect(res.notifications.map((n: any) => n.uuid)).toContain("ntf-priv"); + }); +}); + +describe("project-visibility cascade — writes", () => { + it("claimTask on a private task: non-members rejected, member succeeds", async () => { + await expect( + taskService.claimTask({ taskUuid: "task-priv", companyUuid: COMPANY, assigneeType: "user", assigneeUuid: "userB", assignedByUuid: "userB" }, B), + ).rejects.toThrow(); + await expect( + taskService.claimTask({ taskUuid: "task-priv", companyUuid: COMPANY, assigneeType: "agent", assigneeUuid: "agentN", assignedByUuid: "agentN" }, N), + ).rejects.toThrow(); + // member M succeeds + const claimed = await taskService.claimTask({ taskUuid: "task-priv", companyUuid: COMPANY, assigneeType: "agent", assigneeUuid: "agentM", assignedByUuid: "agentM" }, M); + expect(claimed.status).toBe("assigned"); + }); + + it("createComment on a private task: non-members rejected, member succeeds", async () => { + await expect( + commentService.createComment({ companyUuid: COMPANY, targetType: "task", targetUuid: "task-priv", content: "x", authorType: "user", authorUuid: "userB", auth: B }), + ).rejects.toThrow(); + await expect( + commentService.createComment({ companyUuid: COMPANY, targetType: "task", targetUuid: "task-priv", content: "x", authorType: "agent", authorUuid: "agentN", auth: N }), + ).rejects.toThrow(); + const ok = await commentService.createComment({ companyUuid: COMPANY, targetType: "task", targetUuid: "task-priv", content: "ok", authorType: "agent", authorUuid: "agentM", auth: M }); + expect(ok.uuid).toBeTruthy(); + }); + + it("listComments on a private task: empty for non-members, populated for members", async () => { + expect((await commentService.listComments({ companyUuid: COMPANY, targetType: "task", targetUuid: "task-priv", skip: 0, take: 50, auth: B })).total).toBe(0); + expect((await commentService.listComments({ companyUuid: COMPANY, targetType: "task", targetUuid: "task-priv", skip: 0, take: 50, auth: M })).total).toBeGreaterThan(0); + }); + + it("createComment on a SHARED task: non-member of P is allowed (regression)", async () => { + const ok = await commentService.createComment({ companyUuid: COMPANY, targetType: "task", targetUuid: "task-shar", content: "ok", authorType: "user", authorUuid: "userB", auth: B }); + expect(ok.uuid).toBeTruthy(); + }); +}); + +// =========================================================================== +// Two-level inheritance (ProjectGroup → Project) — dynamic union +// =========================================================================== +// +// Group GRP: PRIVATE, owned by user A, with agent M as a GROUP member (M is NOT +// a direct member of the project below). Contains: +// - GP : a PRIVATE project (owner A, no extra direct members) with one task. +// - GPS: a SHARED project (must stay company-wide regardless of the private group). +// userB is a non-member of everything. +const GRP = "group-private"; +const GP = "proj-in-group"; +const GPS = "shared-in-private-group"; + +function seedGroupFixtures() { + db.projectGroup.push({ + uuid: GRP, companyUuid: COMPANY, name: "Private Group", description: "g", + visibility: "private", ownerType: "user", ownerUuid: "userA", + }); + // Group members: owner A + agent M (M reaches projects ONLY via the group). + db.projectGroupMember.push( + { uuid: "gm-a", companyUuid: COMPANY, projectGroupUuid: GRP, memberType: "user", memberUuid: "userA", role: "member" }, + { uuid: "gm-m", companyUuid: COMPANY, projectGroupUuid: GRP, memberType: "agent", memberUuid: "agentM", role: "member" }, + ); + db.project.push( + { uuid: GP, companyUuid: COMPANY, name: "GroupedPrivate", description: "gp", groupUuid: GRP, visibility: "private", ownerType: "user", ownerUuid: "userA" }, + { uuid: GPS, companyUuid: COMPANY, name: "GroupedShared", description: "gps", groupUuid: GRP, visibility: "shared", ownerType: "user", ownerUuid: "userA" }, + ); + // GP has its own ProjectMember only for the owner (so M's access is purely via the group). + db.projectMember.push({ uuid: "pm-gp-a", companyUuid: COMPANY, projectUuid: GP, memberType: "user", memberUuid: "userA", role: "member" }); + db.task.push({ uuid: "task-gp", companyUuid: COMPANY, projectUuid: GP, title: "grouped task", description: null, status: "open", priority: "medium", storyPoints: null, acceptanceCriteria: null, assigneeType: null, assigneeUuid: null, assignedAt: null, assignedByUuid: null, proposalUuid: null, createdByUuid: "userA", dependsOn: [], dependedBy: [], acceptanceCriteriaItems: [], createdAt: new Date("2026-06-11T00:00:00Z"), updatedAt: new Date("2026-06-11T00:00:00Z") }); + db.comment.push({ uuid: "cmt-gp", companyUuid: COMPANY, targetType: "task", targetUuid: "task-gp", content: "hi", authorType: "user", authorUuid: "userA", createdAt: new Date("2026-06-11T00:00:00Z"), updatedAt: new Date("2026-06-11T00:00:00Z") }); +} + +describe("group inheritance — dynamic union (read + write via group membership)", () => { + beforeEach(() => seedGroupFixtures()); + + it("group member M can READ the group's private project + its task (purely via group membership)", async () => { + expect(await canAccessProject(M, GP)).toBe(true); + expect(await projectService.getProject(COMPANY, GP, M)).not.toBeNull(); + expect(await taskService.getTask(COMPANY, "task-gp", M)).not.toBeNull(); + expect((await taskService.listTasks({ companyUuid: COMPANY, projectUuid: GP, skip: 0, take: 50, auth: M })).total).toBe(1); + }); + + it("group member M can WRITE the group's private project (claim task, comment) via group membership", async () => { + const claimed = await taskService.claimTask({ taskUuid: "task-gp", companyUuid: COMPANY, assigneeType: "agent", assigneeUuid: "agentM", assignedByUuid: "agentM" }, M); + expect(claimed.status).toBe("assigned"); + const c = await commentService.createComment({ companyUuid: COMPANY, targetType: "task", targetUuid: "task-gp", content: "via group", authorType: "agent", authorUuid: "agentM", auth: M }); + expect(c.uuid).toBeTruthy(); + }); + + it("the grouped private project appears in M's accessible-project set", async () => { + const accessible = await getAccessibleProjectUuids(M); + expect(accessible).not.toBe("ALL"); + expect(accessible as string[]).toContain(GP); + }); + + it("a NON-group-member (user B) is denied the group's private project + task", async () => { + expect(await canAccessProject(B, GP)).toBe(false); + expect(await projectService.getProject(COMPANY, GP, B)).toBeNull(); + expect(await taskService.getTask(COMPANY, "task-gp", B)).toBeNull(); + await expect( + taskService.claimTask({ taskUuid: "task-gp", companyUuid: COMPANY, assigneeType: "user", assigneeUuid: "userB", assignedByUuid: "userB" }, B), + ).rejects.toThrow(); + }); + + it("project:admin agent N (non-group-member) is still denied (no bypass)", async () => { + expect(await canAccessProject(N, GP)).toBe(false); + }); + + it("DYNAMIC: removing M from the group revokes access to the grouped project", async () => { + expect(await canAccessProject(M, GP)).toBe(true); + // Remove M's group membership row (dynamic — no snapshot). + const idx = db.projectGroupMember.findIndex((r) => r.projectGroupUuid === GRP && r.memberType === "agent" && r.memberUuid === "agentM"); + db.projectGroupMember.splice(idx, 1); + expect(await canAccessProject(M, GP)).toBe(false); + expect(await taskService.getTask(COMPANY, "task-gp", M)).toBeNull(); + }); + + it("INVARIANT: a SHARED project inside the PRIVATE group is still company-wide", async () => { + // userB is in no group and no project, yet the shared project is visible. + expect(await canAccessProject(B, GPS)).toBe(true); + expect(await projectService.getProject(COMPANY, GPS, B)).not.toBeNull(); + }); + + it("INVARIANT: a non-member does NOT inherit the private grouped project just because they can't see the group", async () => { + // Sanity: B has no accessible projects from this group. + const accessible = await getAccessibleProjectUuids(B); + expect(accessible).not.toBe("ALL"); + expect(accessible as string[]).not.toContain(GP); + // but DOES include the shared one + expect(accessible as string[]).toContain(GPS); + }); +}); + +// =========================================================================== +// Claim-on-manage for legacy NULL-owner entities (the reported UX fix) +// =========================================================================== +// +// Migrated entities have visibility='shared' with ownerType/ownerUuid = NULL. +// The first accessible user/agent to MANAGE one claims ownership (access-gated). +// Actors here: user A (userA) and user B (userB) — both non-owners initially. +const LP = "legacy-project"; // shared, null owner +const LPP = "legacy-private-proj"; // private, null owner (B is NOT a member) +const LG = "legacy-group"; // shared, null owner + +function seedLegacyNullOwner() { + db.project.push( + { uuid: LP, companyUuid: COMPANY, name: "Legacy Shared", description: "", groupUuid: null, visibility: "shared", ownerType: null, ownerUuid: null }, + { uuid: LPP, companyUuid: COMPANY, name: "Legacy Private", description: "", groupUuid: null, visibility: "private", ownerType: null, ownerUuid: null }, + ); + db.projectGroup.push( + { uuid: LG, companyUuid: COMPANY, name: "Legacy Group", description: "", visibility: "shared", ownerType: null, ownerUuid: null }, + ); +} + +describe("claim-on-manage: legacy null-owner entities", () => { + beforeEach(() => seedLegacyNullOwner()); + + it("a shared null-owner PROJECT: first manager (user A) claims it; user B then cannot manage", async () => { + // Before: nobody owns it → canManageProject false for both. + expect(await canManageProject(A, LP)).toBe(false); + // User A manages (claim) → becomes owner. + expect(await claimOrCanManageProject(A, LP)).toBe(true); + const claimed = db.project.find((p) => p.uuid === LP); + expect(claimed.ownerType).toBe("user"); + expect(claimed.ownerUuid).toBe("userA"); + // A is now a member too (seeded). + expect(db.projectMember.some((m) => m.projectUuid === LP && m.memberUuid === "userA")).toBe(true); + // User B can no longer claim/manage (owner now set, B isn't it). + expect(await claimOrCanManageProject(B, LP)).toBe(false); + // ...and the existing owner is never reassigned. + expect(db.project.find((p) => p.uuid === LP).ownerUuid).toBe("userA"); + }); + + it("a PRIVATE null-owner project: a non-member CANNOT claim it (access-gated)", async () => { + // B is not a member of the private legacy project → cannot access → cannot claim. + expect(await claimOrCanManageProject(B, LPP)).toBe(false); + expect(db.project.find((p) => p.uuid === LPP).ownerType).toBeNull(); + }); + + it("super_admin manages a null-owner project WITHOUT claiming (stays owner-less)", async () => { + expect(await claimOrCanManageProject(SA, LP)).toBe(true); + // super_admin path returns before any claim — owner stays null. + expect(db.project.find((p) => p.uuid === LP).ownerType).toBeNull(); + }); + + it("a shared null-owner GROUP: first manager (user A) claims it; user B then cannot manage", async () => { + expect(await canManageGroup(A, LG)).toBe(false); + expect(await claimOrCanManageGroup(A, LG)).toBe(true); + const claimed = db.projectGroup.find((g) => g.uuid === LG); + expect(claimed.ownerType).toBe("user"); + expect(claimed.ownerUuid).toBe("userA"); + expect(db.projectGroupMember.some((m) => m.projectGroupUuid === LG && m.memberUuid === "userA")).toBe(true); + expect(await claimOrCanManageGroup(B, LG)).toBe(false); + }); + + it("an already-owned group is not re-claimed by a different actor", async () => { + // Give LG an owner first (A claims). + await claimOrCanManageGroup(A, LG); + // B attempts → denied, owner unchanged. + expect(await claimOrCanManageGroup(B, LG)).toBe(false); + expect(db.projectGroup.find((g) => g.uuid === LG).ownerUuid).toBe("userA"); + }); +}); diff --git a/src/app/(dashboard)/project-groups/[uuid]/page.tsx b/src/app/(dashboard)/project-groups/[uuid]/page.tsx index 953ee4cc..b65456fc 100644 --- a/src/app/(dashboard)/project-groups/[uuid]/page.tsx +++ b/src/app/(dashboard)/project-groups/[uuid]/page.tsx @@ -24,6 +24,10 @@ interface GroupDashboardData { uuid: string; name: string; description: string | null; + visibility: "shared" | "private"; + ownerType: "user" | "agent" | null; + ownerUuid: string | null; + isOwner: boolean; }; stats: { projectCount: number; @@ -368,6 +372,8 @@ export default function ProjectGroupDashboardPage() { groupName={group.name} groupDescription={group.description} projectCount={stats.projectCount} + visibility={group.visibility} + isOwner={group.isOwner} onUpdated={() => { setShowManage(false); fetchDashboard(); diff --git a/src/app/(dashboard)/projects/[uuid]/activity/page.tsx b/src/app/(dashboard)/projects/[uuid]/activity/page.tsx index 9e5336c4..7a44c525 100644 --- a/src/app/(dashboard)/projects/[uuid]/activity/page.tsx +++ b/src/app/(dashboard)/projects/[uuid]/activity/page.tsx @@ -96,7 +96,7 @@ export default async function ActivityPage({ params }: PageProps) { const t = await getTranslations(); // Validate project exists - const exists = await projectExists(auth.companyUuid, projectUuid); + const exists = await projectExists(auth.companyUuid, projectUuid, auth); if (!exists) { redirect("/projects"); } @@ -105,6 +105,7 @@ export default async function ActivityPage({ params }: PageProps) { const { activities: rawActivities } = await listActivities({ companyUuid: auth.companyUuid, projectUuid, + auth, skip: 0, take: 100, }); diff --git a/src/app/(dashboard)/projects/[uuid]/dashboard/dashboard-content.tsx b/src/app/(dashboard)/projects/[uuid]/dashboard/dashboard-content.tsx index 03fc2a21..447fd3db 100644 --- a/src/app/(dashboard)/projects/[uuid]/dashboard/dashboard-content.tsx +++ b/src/app/(dashboard)/projects/[uuid]/dashboard/dashboard-content.tsx @@ -12,7 +12,7 @@ interface DashboardContentProps { export async function DashboardContent({ projectUuid, initialSelectedIdeaUuid }: DashboardContentProps) { const t = await getTranslations(); - const { project, trackerData, stats, activities, currentUserUuid } = await getDashboardData(projectUuid); + const { project, trackerData, stats, activities, currentUserUuid, isOwner } = await getDashboardData(projectUuid); return (
@@ -22,7 +22,13 @@ export async function DashboardContent({ projectUuid, initialSelectedIdeaUuid }:

{t("ideaTracker.overviewSubtitle")}

- +
diff --git a/src/app/(dashboard)/projects/[uuid]/dashboard/dashboard-data.ts b/src/app/(dashboard)/projects/[uuid]/dashboard/dashboard-data.ts index e07b8858..4918349f 100644 --- a/src/app/(dashboard)/projects/[uuid]/dashboard/dashboard-data.ts +++ b/src/app/(dashboard)/projects/[uuid]/dashboard/dashboard-data.ts @@ -3,6 +3,7 @@ import { getServerAuthContext } from "@/lib/auth-server"; import { getProject, getProjectStats } from "@/services/project.service"; import { getTrackerGroups } from "@/services/idea.service"; import { listActivitiesWithActorNames } from "@/services/activity.service"; +import { canManageOrClaimableProject } from "@/lib/authz/project-access"; export async function getDashboardData(projectUuid: string) { const auth = await getServerAuthContext(); @@ -10,25 +11,36 @@ export async function getDashboardData(projectUuid: string) { redirect("/login"); } - const project = await getProject(auth.companyUuid, projectUuid); + const project = await getProject(auth.companyUuid, projectUuid, auth); if (!project) { redirect("/projects"); } - const trackerData = await getTrackerGroups(auth.companyUuid, projectUuid); - const stats = await getProjectStats(auth.companyUuid, projectUuid); + const trackerData = await getTrackerGroups(auth.companyUuid, projectUuid, auth); + const stats = await getProjectStats(auth.companyUuid, projectUuid, auth); + if (!stats) { + redirect("/projects"); + } const { activities } = await listActivitiesWithActorNames({ companyUuid: auth.companyUuid, projectUuid, skip: 0, take: 5, + auth, }); + // The actor "owns" the project for UI purposes iff they manage it OR could + // claim it (null-owner legacy project they can access). Shows manage controls + // without mutating on read — the real claim happens server-side on a manage + // action. Mirrors getGroupDashboard's isOwner semantics. + const isOwner = await canManageOrClaimableProject(auth, projectUuid); + return { project, trackerData, stats, activities, currentUserUuid: auth.actorUuid, + isOwner, }; } diff --git a/src/app/(dashboard)/projects/[uuid]/dashboard/panels/actions.ts b/src/app/(dashboard)/projects/[uuid]/dashboard/panels/actions.ts index b2437e27..2b04dc57 100644 --- a/src/app/(dashboard)/projects/[uuid]/dashboard/panels/actions.ts +++ b/src/app/(dashboard)/projects/[uuid]/dashboard/panels/actions.ts @@ -22,7 +22,7 @@ export async function getIdeaAction(ideaUuid: string) { return { success: false as const, error: "Unauthorized" }; } - const idea = await getIdeaWithDerivedStatus(auth.companyUuid, ideaUuid); + const idea = await getIdeaWithDerivedStatus(auth.companyUuid, ideaUuid, auth); if (!idea) { return { success: false as const, error: "Not found" }; } @@ -36,7 +36,7 @@ export async function getTaskAction(taskUuid: string) { return { success: false as const, error: "Unauthorized" }; } - const task = await getTask(auth.companyUuid, taskUuid); + const task = await getTask(auth.companyUuid, taskUuid, auth); if (!task) { return { success: false as const, error: "Not found" }; } @@ -57,6 +57,7 @@ export async function moveIdeaAction(ideaUuid: string, targetProjectUuid: string targetProjectUuid, auth.actorUuid, auth.type, + auth, ); // Surface the cascade counts so the dialog can render an accurate // success toast ("moved 2 proposals, 3 tasks, ...") rather than a @@ -87,7 +88,7 @@ export async function moveIdeaPreviewAction(ideaUuid: string, targetProjectUuid: return { success: false as const, error: "Idea is already in the target project" }; } - const result = await moveIdeaPreview(auth.companyUuid, ideaUuid, targetProjectUuid); + const result = await moveIdeaPreview(auth.companyUuid, ideaUuid, targetProjectUuid, auth); return { success: true as const, moved: result.moved }; } catch (e) { logger.error({ err: e }, "Failed to preview idea move"); @@ -128,6 +129,7 @@ export async function getTasksForProposalAction( proposalUuids: [proposalUuid], skip: 0, take: 100, + auth, }); return { success: true as const, data: tasks }; @@ -177,8 +179,8 @@ export async function getProjectsAndGroupsAction() { } const [{ projects }, { groups }] = await Promise.all([ - listProjects({ companyUuid: auth.companyUuid, skip: 0, take: 100 }), - listProjectGroups(auth.companyUuid), + listProjects({ companyUuid: auth.companyUuid, skip: 0, take: 100, auth }), + listProjectGroups(auth.companyUuid, auth), ]); return { success: true as const, data: { projects, groups } }; diff --git a/src/app/(dashboard)/projects/[uuid]/dashboard/project-settings-modal.tsx b/src/app/(dashboard)/projects/[uuid]/dashboard/project-settings-modal.tsx index d11a5e83..fc9bf680 100644 --- a/src/app/(dashboard)/projects/[uuid]/dashboard/project-settings-modal.tsx +++ b/src/app/(dashboard)/projects/[uuid]/dashboard/project-settings-modal.tsx @@ -1,13 +1,16 @@ "use client"; -import { useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { Settings, Loader2 } from "lucide-react"; +import { Settings, Loader2, Lock, Globe, Plus, X, User as UserIcon, Bot } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { ScrollArea } from "@/components/ui/scroll-area"; import { Dialog, DialogContent, @@ -27,18 +30,41 @@ import { AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; +import { isImeComposing } from "@/lib/ime"; import { updateProjectAction, deleteProjectAction } from "../actions"; +type Visibility = "shared" | "private"; + +interface ProjectMember { + uuid: string; + memberType: "user" | "agent"; + memberUuid: string; + name?: string | null; + role: string | null; + createdAt: string; +} + +interface Mentionable { + type: "user" | "agent"; + uuid: string; + name: string; + email?: string | null; +} + interface ProjectSettingsModalProps { projectUuid: string; projectName: string; projectDescription: string | null; + visibility: Visibility; + isOwner: boolean; } export function ProjectSettingsModal({ projectUuid, projectName, projectDescription, + visibility: initialVisibility, + isOwner, }: ProjectSettingsModalProps) { const t = useTranslations(); const router = useRouter(); @@ -48,6 +74,16 @@ export function ProjectSettingsModal({ const [saving, setSaving] = useState(false); const [deleting, setDeleting] = useState(false); + const [visibility, setVisibility] = useState(initialVisibility); + const [visibilitySaving, setVisibilitySaving] = useState(false); + const [visibilityError, setVisibilityError] = useState(null); + + const [members, setMembers] = useState([]); + const [memberError, setMemberError] = useState(null); + const [search, setSearch] = useState(""); + const [results, setResults] = useState([]); + const [adding, setAdding] = useState(false); + const handleSave = async () => { setSaving(true); const result = await updateProjectAction(projectUuid, { @@ -69,6 +105,129 @@ export function ProjectSettingsModal({ } }; + const fetchMembers = useCallback(async () => { + try { + const res = await fetch(`/api/projects/${projectUuid}/members`); + const json = await res.json(); + if (json.success) { + setMembers(json.data || []); + } + } catch { + // silently ignore — surfaced via empty list + } + }, [projectUuid]); + + // Load members when the modal opens and visibility is private. + useEffect(() => { + if (open && visibility === "private" && isOwner) { + fetchMembers(); + } + }, [open, visibility, isOwner, fetchMembers]); + + // Search mentionables (users/agents) as the owner types. + useEffect(() => { + if (!open || visibility !== "private" || !isOwner) return; + const handle = setTimeout(async () => { + try { + const res = await fetch( + `/api/mentionables?q=${encodeURIComponent(search.trim())}&limit=10&forMembers=1`, + ); + const json = await res.json(); + if (json.success) { + setResults(json.data || []); + } + } catch { + setResults([]); + } + }, 200); + return () => clearTimeout(handle); + }, [search, open, visibility, isOwner]); + + const handleVisibilityChange = async (next: Visibility) => { + if (next === visibility) return; + const previous = visibility; + setVisibility(next); + setVisibilitySaving(true); + setVisibilityError(null); + try { + const res = await fetch(`/api/projects/${projectUuid}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ visibility: next }), + }); + const json = await res.json(); + if (!json.success) { + setVisibility(previous); + setVisibilityError(json.error || t("projects.visibilityUpdateFailed")); + } else { + router.refresh(); + } + } catch { + setVisibility(previous); + setVisibilityError(t("projects.visibilityUpdateFailed")); + } finally { + setVisibilitySaving(false); + } + }; + + const addMember = async (memberType: "user" | "agent", memberUuid: string) => { + const value = memberUuid.trim(); + if (!value) return; + setAdding(true); + setMemberError(null); + try { + const res = await fetch(`/api/projects/${projectUuid}/members`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ memberType, memberUuid: value }), + }); + const json = await res.json(); + if (!json.success) { + setMemberError(json.error || t("projects.memberAddFailed")); + } else { + setSearch(""); + setResults([]); + await fetchMembers(); + } + } catch { + setMemberError(t("projects.memberAddFailed")); + } finally { + setAdding(false); + } + }; + + const removeMember = async (member: ProjectMember) => { + setMemberError(null); + try { + const res = await fetch( + `/api/projects/${projectUuid}/members?memberType=${member.memberType}&memberUuid=${encodeURIComponent(member.memberUuid)}`, + { method: "DELETE" }, + ); + const json = await res.json(); + if (!json.success) { + setMemberError(json.error || t("projects.memberRemoveFailed")); + } else { + await fetchMembers(); + } + } catch { + setMemberError(t("projects.memberRemoveFailed")); + } + }; + + const handleSearchKeyDown = (e: React.KeyboardEvent) => { + if (e.key !== "Enter") return; + if (isImeComposing(e)) return; + e.preventDefault(); + const value = search.trim(); + if (!value) return; + // If the input looks like a raw UUID, add it directly as a user member. + if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)) { + addMember("user", value); + } else if (results.length > 0) { + addMember(results[0].type, results[0].uuid); + } + }; + return ( @@ -90,7 +249,7 @@ export function ProjectSettingsModal({ -
+
{/* Basic Information */}

@@ -138,67 +297,231 @@ export function ProjectSettingsModal({ - {/* Danger Zone */} + {/* Visibility */}
-

- {t("projectSettings.dangerZone")} +

+ {t("projects.visibility")}

-
-
-
- - {t("projectSettings.deleteTitle")} + {!isOwner && ( +

+ {t("projects.onlyOwnerCanManage")} +

+ )} + + handleVisibilityChange(value as Visibility)} + disabled={!isOwner || visibilitySaving} + className="gap-3" + > + + + + + {visibilityError && ( +

{visibilityError}

+ )} + + {/* Members manager (private + owner only) */} + {visibility === "private" && isOwner && ( +
+ + +
+ setSearch(e.target.value)} + onKeyDown={handleSearchKeyDown} + placeholder={t("projects.addMemberPlaceholder")} + className="h-10 flex-1 rounded-[10px] border-[#E5E2DC] text-[13px] text-[#2C2C2C] focus-visible:ring-[#C67A52]" + /> +
+ + {/* Search results */} + {search.trim() && results.length > 0 && ( + +
+ {results.map((r) => ( + + ))} +
+
+ )} + + {memberError && ( +

{memberError}

+ )} + + {/* Current members */} + {members.length === 0 ? ( +

+ {t("projects.noMembers")} +

+ ) : ( +
+ {members.map((m) => ( +
- {deleting ? ( - <> - - {t("common.delete")} - - ) : ( - t("common.delete") - )} - - - - + + {m.memberType === "agent" ? ( + + ) : ( + + )} + + {m.name ?? m.memberUuid} + + + {m.memberType === "agent" + ? t("projects.memberAgent") + : t("projects.memberUser")} + + + +
+ ))} +
+ )}
-
+ )}
+ + {isOwner && ( + <> + + + {/* Danger Zone */} +
+

+ {t("projectSettings.dangerZone")} +

+ +
+
+
+ + {t("projectSettings.deleteTitle")} + + + {t("projectSettings.deleteDescription")} + +
+ + + + + + + + {t("projectOverview.deleteProject")} + + + {t("projectOverview.deleteProjectConfirm", { + name: projectName, + })} + + + + + {t("common.cancel")} + + + {deleting ? ( + <> + + {t("common.delete")} + + ) : ( + t("common.delete") + )} + + + + +
+
+
+ + )}

diff --git a/src/app/(dashboard)/projects/[uuid]/documents/[documentUuid]/actions.ts b/src/app/(dashboard)/projects/[uuid]/documents/[documentUuid]/actions.ts index fc971c97..8e94902d 100644 --- a/src/app/(dashboard)/projects/[uuid]/documents/[documentUuid]/actions.ts +++ b/src/app/(dashboard)/projects/[uuid]/documents/[documentUuid]/actions.ts @@ -22,7 +22,7 @@ export async function updateDocumentAction( return { success: false, error: "Document not found" }; } - await updateDocument(documentUuid, { content }); + await updateDocument(documentUuid, { content }, auth); revalidatePath(`/projects/${projectUuid}/documents/${documentUuid}`); revalidatePath(`/projects/${projectUuid}/documents`); diff --git a/src/app/(dashboard)/projects/[uuid]/documents/[documentUuid]/page.tsx b/src/app/(dashboard)/projects/[uuid]/documents/[documentUuid]/page.tsx index 06463e74..36e283ff 100644 --- a/src/app/(dashboard)/projects/[uuid]/documents/[documentUuid]/page.tsx +++ b/src/app/(dashboard)/projects/[uuid]/documents/[documentUuid]/page.tsx @@ -38,13 +38,13 @@ export default async function DocumentDetailPage({ params }: PageProps) { const t = await getTranslations(); // Validate project exists - const exists = await projectExists(auth.companyUuid, projectUuid); + const exists = await projectExists(auth.companyUuid, projectUuid, auth); if (!exists) { redirect("/projects"); } // Get Document details - const document = await getDocument(auth.companyUuid, documentUuid); + const document = await getDocument(auth.companyUuid, documentUuid, auth); if (!document) { return (
diff --git a/src/app/(dashboard)/projects/[uuid]/documents/__tests__/actions.test.ts b/src/app/(dashboard)/projects/[uuid]/documents/__tests__/actions.test.ts index c7fb8f29..a6597f85 100644 --- a/src/app/(dashboard)/projects/[uuid]/documents/__tests__/actions.test.ts +++ b/src/app/(dashboard)/projects/[uuid]/documents/__tests__/actions.test.ts @@ -99,7 +99,7 @@ describe("deleteDocumentAction", () => { expect(result).toEqual({ success: true, projectUuid: PROJECT_UUID }); expect(mockGetDocumentByUuidUnscoped).toHaveBeenCalledWith(DOCUMENT_UUID); - expect(mockDeleteDocument).toHaveBeenCalledWith(DOCUMENT_UUID); + expect(mockDeleteDocument).toHaveBeenCalledWith(DOCUMENT_UUID, expect.anything()); expect(mockRevalidatePath).toHaveBeenCalledWith(DOCUMENTS_PATH); }); diff --git a/src/app/(dashboard)/projects/[uuid]/documents/actions.ts b/src/app/(dashboard)/projects/[uuid]/documents/actions.ts index 5cd8c05f..b232d542 100644 --- a/src/app/(dashboard)/projects/[uuid]/documents/actions.ts +++ b/src/app/(dashboard)/projects/[uuid]/documents/actions.ts @@ -23,7 +23,7 @@ export async function createDocumentAction(input: { } try { - if (!(await projectExists(auth.companyUuid, input.projectUuid))) { + if (!(await projectExists(auth.companyUuid, input.projectUuid, auth))) { return { success: false, error: "Project not found" }; } @@ -34,7 +34,7 @@ export async function createDocumentAction(input: { title: input.title, content: input.content, createdByUuid: auth.actorUuid, - }); + }, auth); await createActivity({ companyUuid: auth.companyUuid, @@ -78,7 +78,7 @@ export async function deleteDocumentAction( return { success: false, error: "forbidden" }; } - await deleteDocument(document.uuid); + await deleteDocument(document.uuid, auth); revalidatePath(`/projects/${document.projectUuid}/documents`); return { success: true, projectUuid: document.projectUuid }; } catch (error) { diff --git a/src/app/(dashboard)/projects/[uuid]/documents/page.tsx b/src/app/(dashboard)/projects/[uuid]/documents/page.tsx index 06a48ef4..d245d385 100644 --- a/src/app/(dashboard)/projects/[uuid]/documents/page.tsx +++ b/src/app/(dashboard)/projects/[uuid]/documents/page.tsx @@ -32,7 +32,7 @@ export default async function DocumentsPage({ params, searchParams }: PageProps) const t = await getTranslations(); // Validate project exists - const exists = await projectExists(auth.companyUuid, projectUuid); + const exists = await projectExists(auth.companyUuid, projectUuid, auth); if (!exists) { redirect("/projects"); } @@ -43,6 +43,7 @@ export default async function DocumentsPage({ params, searchParams }: PageProps) projectUuid, skip: 0, take: 1000, + auth, }); // Calculate count per type diff --git a/src/app/(dashboard)/projects/[uuid]/ideas/[ideaUuid]/actions.ts b/src/app/(dashboard)/projects/[uuid]/ideas/[ideaUuid]/actions.ts index 5bdc9a95..72b8fd04 100644 --- a/src/app/(dashboard)/projects/[uuid]/ideas/[ideaUuid]/actions.ts +++ b/src/app/(dashboard)/projects/[uuid]/ideas/[ideaUuid]/actions.ts @@ -31,7 +31,7 @@ export async function claimIdeaAction(ideaUuid: string) { assigneeType: auth.type, assigneeUuid: auth.actorUuid, assignedByUuid: auth.actorUuid, - }); + }, auth); await createActivity({ companyUuid: auth.companyUuid, @@ -78,7 +78,7 @@ export async function claimIdeaToAgentAction(ideaUuid: string, agentUuid: string assigneeType: "agent", assigneeUuid: agentUuid, assignedByUuid: auth.actorUuid, - }); + }, auth); await createActivity({ companyUuid: auth.companyUuid, @@ -125,7 +125,7 @@ export async function claimIdeaToUserAction(ideaUuid: string, userUuid: string) assigneeType: "user", assigneeUuid: userUuid, assignedByUuid: auth.actorUuid, - }); + }, auth); revalidatePath(`/projects/${idea.projectUuid}/ideas/${ideaUuid}`); revalidatePath(`/projects/${idea.projectUuid}/ideas`); @@ -155,7 +155,7 @@ export async function releaseIdeaAction(ideaUuid: string) { return { success: false, error: "Idea cannot be released from current status" }; } - await releaseIdea(idea.uuid); + await releaseIdea(idea.uuid, auth); revalidatePath(`/projects/${idea.projectUuid}/ideas/${ideaUuid}`); revalidatePath(`/projects/${idea.projectUuid}/ideas`); diff --git a/src/app/(dashboard)/projects/[uuid]/ideas/[ideaUuid]/activity-actions.ts b/src/app/(dashboard)/projects/[uuid]/ideas/[ideaUuid]/activity-actions.ts index edfa00a0..25c33118 100644 --- a/src/app/(dashboard)/projects/[uuid]/ideas/[ideaUuid]/activity-actions.ts +++ b/src/app/(dashboard)/projects/[uuid]/ideas/[ideaUuid]/activity-actions.ts @@ -26,6 +26,7 @@ export async function getIdeaActivitiesAction( targetUuid: ideaUuid, skip: 0, take: 50, + auth, }); } catch (error) { logger.error({ err: error }, "Failed to get idea activities"); diff --git a/src/app/(dashboard)/projects/[uuid]/ideas/actions.ts b/src/app/(dashboard)/projects/[uuid]/ideas/actions.ts index 9b0049a1..cf91ac71 100644 --- a/src/app/(dashboard)/projects/[uuid]/ideas/actions.ts +++ b/src/app/(dashboard)/projects/[uuid]/ideas/actions.ts @@ -35,7 +35,7 @@ export async function createIdeaAction(input: CreateIdeaInput) { content: input.content || null, attachments: input.attachments || null, createdByUuid: auth.actorUuid, - }); + }, auth); revalidatePath(`/projects/${input.projectUuid}/ideas`); return { success: true, ideaUuid: idea.uuid }; @@ -62,7 +62,7 @@ export async function updateIdeaAction(input: UpdateIdeaInput) { const idea = await updateIdea(input.ideaUuid, auth.companyUuid, { title: input.title, content: input.content, - }); + }, auth); revalidatePath(`/projects/${input.projectUuid}/ideas`); return { success: true, idea }; @@ -79,7 +79,7 @@ export async function deleteIdeaAction(ideaUuid: string, projectUuid: string) { } try { - await deleteIdea(ideaUuid); + await deleteIdea(ideaUuid, auth); revalidatePath(`/projects/${projectUuid}/ideas`); return { success: true }; } catch (error) { @@ -104,6 +104,7 @@ export async function fetchIdeasAction(projectUuid: string) { projectUuid, skip: 0, take: 1000, + auth, }); const allIdeaUuids = allIdeas.map((idea) => idea.uuid); diff --git a/src/app/(dashboard)/projects/[uuid]/ideas/ideas-page-content.tsx b/src/app/(dashboard)/projects/[uuid]/ideas/ideas-page-content.tsx index d5a44742..fa83285d 100644 --- a/src/app/(dashboard)/projects/[uuid]/ideas/ideas-page-content.tsx +++ b/src/app/(dashboard)/projects/[uuid]/ideas/ideas-page-content.tsx @@ -49,7 +49,7 @@ export async function IdeasPageContent({ const t = await getTranslations(); // Validate project exists - const exists = await projectExists(auth.companyUuid, projectUuid); + const exists = await projectExists(auth.companyUuid, projectUuid, auth); if (!exists) { redirect("/projects"); } @@ -60,6 +60,7 @@ export async function IdeasPageContent({ projectUuid, skip: 0, take: 1000, + auth, }); // Get Ideas assigned to me (for counting) @@ -71,6 +72,7 @@ export async function IdeasPageContent({ assignedToMe: true, actorUuid: auth.actorUuid, actorType: auth.type, + auth, }); // Calculate count per status diff --git a/src/app/(dashboard)/projects/[uuid]/proposals/[proposalUuid]/actions.ts b/src/app/(dashboard)/projects/[uuid]/proposals/[proposalUuid]/actions.ts index 2868f9ba..98cbd859 100644 --- a/src/app/(dashboard)/projects/[uuid]/proposals/[proposalUuid]/actions.ts +++ b/src/app/(dashboard)/projects/[uuid]/proposals/[proposalUuid]/actions.ts @@ -40,7 +40,7 @@ export async function approveProposalAction(proposalUuid: string, reviewNote?: s return { success: false, error: "Proposal is not pending review" }; } - await approveProposal(proposalUuid, auth.companyUuid, auth.actorUuid, reviewNote || null); + await approveProposal(proposalUuid, auth.companyUuid, auth.actorUuid, reviewNote || null, auth); await createActivity({ companyUuid: auth.companyUuid, @@ -81,7 +81,7 @@ export async function submitProposalAction(proposalUuid: string) { return { success: false, error: "Proposal is not in draft status" }; } - await submitProposal(proposalUuid, auth.companyUuid); + await submitProposal(proposalUuid, auth.companyUuid, auth); revalidatePath(`/projects/${proposal.projectUuid}/proposals/${proposalUuid}`); revalidatePath(`/projects/${proposal.projectUuid}/proposals`); @@ -111,7 +111,7 @@ export async function rejectProposalAction(proposalUuid: string, reviewNote?: st return { success: false, error: "Proposal is not pending review" }; } - await rejectProposal(proposalUuid, auth.actorUuid, reviewNote || ""); + await rejectProposal(proposalUuid, auth.actorUuid, reviewNote || "", auth); await createActivity({ companyUuid: auth.companyUuid, @@ -150,7 +150,7 @@ export async function closeProposalAction(proposalUuid: string, reviewNote: stri return { success: false, error: "Proposal is not pending review" }; } - await closeProposal(proposalUuid, auth.actorUuid, reviewNote); + await closeProposal(proposalUuid, auth.actorUuid, reviewNote, auth); revalidatePath(`/projects/${proposal.projectUuid}/proposals/${proposalUuid}`); revalidatePath(`/projects/${proposal.projectUuid}/proposals`); @@ -178,7 +178,7 @@ export async function revokeProposalAction(proposalUuid: string, reviewNote?: st return { success: false, error: "Proposal is not approved" }; } - const result = await revokeProposal(proposalUuid, auth.companyUuid, auth.actorUuid, reviewNote); + const result = await revokeProposal(proposalUuid, auth.companyUuid, auth.actorUuid, reviewNote, auth); await createActivity({ companyUuid: auth.companyUuid, @@ -217,7 +217,7 @@ export async function deleteProposalAction(proposalUuid: string, projectUuid: st return { success: false, error: "Proposal not found" }; } - await deleteProposal(proposalUuid, auth.companyUuid); + await deleteProposal(proposalUuid, auth.companyUuid, auth); revalidatePath(`/projects/${projectUuid}/proposals`); @@ -272,7 +272,7 @@ export async function addDocumentDraftAction( return { success: false, error: "Proposal not found" }; } - const updated = await addDocumentDraft(proposalUuid, auth.companyUuid, draft); + const updated = await addDocumentDraft(proposalUuid, auth.companyUuid, draft, auth); revalidatePath(`/projects/${proposal.projectUuid}/proposals/${proposalUuid}`); @@ -306,7 +306,7 @@ export async function addTaskDraftAction( return { success: false, error: "Proposal not found" }; } - const updated = await addTaskDraft(proposalUuid, auth.companyUuid, draft); + const updated = await addTaskDraft(proposalUuid, auth.companyUuid, draft, auth); revalidatePath(`/projects/${proposal.projectUuid}/proposals/${proposalUuid}`); @@ -334,7 +334,7 @@ export async function updateDocumentDraftAction( return { success: false, error: "Proposal not found" }; } - const updated = await updateDocumentDraft(proposalUuid, auth.companyUuid, draftUuid, updates); + const updated = await updateDocumentDraft(proposalUuid, auth.companyUuid, draftUuid, updates, auth); revalidatePath(`/projects/${proposal.projectUuid}/proposals/${proposalUuid}`); @@ -369,7 +369,7 @@ export async function updateTaskDraftAction( return { success: false, error: "Proposal not found" }; } - const updated = await updateTaskDraft(proposalUuid, auth.companyUuid, draftUuid, updates); + const updated = await updateTaskDraft(proposalUuid, auth.companyUuid, draftUuid, updates, auth); revalidatePath(`/projects/${proposal.projectUuid}/proposals/${proposalUuid}`); @@ -393,7 +393,7 @@ export async function removeDocumentDraftAction(proposalUuid: string, draftUuid: return { success: false, error: "Proposal not found" }; } - const updated = await removeDocumentDraft(proposalUuid, auth.companyUuid, draftUuid); + const updated = await removeDocumentDraft(proposalUuid, auth.companyUuid, draftUuid, auth); revalidatePath(`/projects/${proposal.projectUuid}/proposals/${proposalUuid}`); @@ -417,7 +417,7 @@ export async function removeTaskDraftAction(proposalUuid: string, draftUuid: str return { success: false, error: "Proposal not found" }; } - const updated = await removeTaskDraft(proposalUuid, auth.companyUuid, draftUuid); + const updated = await removeTaskDraft(proposalUuid, auth.companyUuid, draftUuid, auth); revalidatePath(`/projects/${proposal.projectUuid}/proposals/${proposalUuid}`); diff --git a/src/app/(dashboard)/projects/[uuid]/proposals/[proposalUuid]/page.tsx b/src/app/(dashboard)/projects/[uuid]/proposals/[proposalUuid]/page.tsx index 74282608..87ac3772 100644 --- a/src/app/(dashboard)/projects/[uuid]/proposals/[proposalUuid]/page.tsx +++ b/src/app/(dashboard)/projects/[uuid]/proposals/[proposalUuid]/page.tsx @@ -79,13 +79,13 @@ export default async function ProposalDetailPage({ params }: PageProps) { const t = await getTranslations(); // Validate project exists - const exists = await projectExists(auth.companyUuid, projectUuid); + const exists = await projectExists(auth.companyUuid, projectUuid, auth); if (!exists) { redirect("/projects"); } // Get Proposal details - const proposal = await getProposal(auth.companyUuid, proposalUuid); + const proposal = await getProposal(auth.companyUuid, proposalUuid, auth); if (!proposal) { return (
@@ -103,7 +103,7 @@ export default async function ProposalDetailPage({ params }: PageProps) { // Fetch source ideas (when inputType is "idea" and inputUuids exist) const sourceIdeas = proposal.inputType === "idea" && proposal.inputUuids?.length ? (await Promise.all( - proposal.inputUuids.map((uuid: string) => getIdea(auth.companyUuid, uuid)) + proposal.inputUuids.map((uuid: string) => getIdea(auth.companyUuid, uuid, auth)) )).filter(Boolean) as Awaited>[] : []; diff --git a/src/app/(dashboard)/projects/[uuid]/proposals/actions.ts b/src/app/(dashboard)/projects/[uuid]/proposals/actions.ts index e8fa73b9..b6f0c5ce 100644 --- a/src/app/(dashboard)/projects/[uuid]/proposals/actions.ts +++ b/src/app/(dashboard)/projects/[uuid]/proposals/actions.ts @@ -31,7 +31,7 @@ export async function createProposalAction( try { // Validate project exists - if (!(await projectExists(auth.companyUuid, projectUuid))) { + if (!(await projectExists(auth.companyUuid, projectUuid, auth))) { return { success: false, error: "Project not found" }; } @@ -73,7 +73,7 @@ export async function createProposalAction( taskDrafts: data.taskDrafts, createdByUuid: auth.actorUuid, createdByType: "user", - }); + }, auth); revalidatePath(`/projects/${projectUuid}/proposals`); @@ -99,6 +99,7 @@ export async function fetchProposalsAction(projectUuid: string) { projectUuid, skip: 0, take: 1000, + auth, }); return { success: true as const, data: proposals }; } catch (error) { diff --git a/src/app/(dashboard)/projects/[uuid]/proposals/new/page.tsx b/src/app/(dashboard)/projects/[uuid]/proposals/new/page.tsx index 74d3a1c4..8d5429b7 100644 --- a/src/app/(dashboard)/projects/[uuid]/proposals/new/page.tsx +++ b/src/app/(dashboard)/projects/[uuid]/proposals/new/page.tsx @@ -24,7 +24,7 @@ export default async function NewProposalPage({ params, searchParams }: PageProp const t = await getTranslations(); // Validate project exists - const exists = await projectExists(auth.companyUuid, projectUuid); + const exists = await projectExists(auth.companyUuid, projectUuid, auth); if (!exists) { redirect("/projects"); } @@ -38,6 +38,7 @@ export default async function NewProposalPage({ params, searchParams }: PageProp assignedToMe: true, actorUuid: auth.actorUuid, actorType: auth.type, + auth, }); // All ideas with resolved elaboration are available (ideas can be reused across proposals) diff --git a/src/app/(dashboard)/projects/[uuid]/proposals/page.tsx b/src/app/(dashboard)/projects/[uuid]/proposals/page.tsx index 97c41474..40fba0ca 100644 --- a/src/app/(dashboard)/projects/[uuid]/proposals/page.tsx +++ b/src/app/(dashboard)/projects/[uuid]/proposals/page.tsx @@ -25,7 +25,7 @@ export default async function ProposalsPage({ params }: PageProps) { const { uuid: projectUuid } = await params; const t = await getTranslations(); - const exists = await projectExists(auth.companyUuid, projectUuid); + const exists = await projectExists(auth.companyUuid, projectUuid, auth); if (!exists) { redirect("/projects"); } @@ -35,6 +35,7 @@ export default async function ProposalsPage({ params }: PageProps) { projectUuid, skip: 0, take: 1000, + auth, }); const pendingCount = proposals.filter((p) => p.status === "pending").length; diff --git a/src/app/(dashboard)/projects/[uuid]/tasks/[taskUuid]/actions.ts b/src/app/(dashboard)/projects/[uuid]/tasks/[taskUuid]/actions.ts index 760a2871..817bf266 100644 --- a/src/app/(dashboard)/projects/[uuid]/tasks/[taskUuid]/actions.ts +++ b/src/app/(dashboard)/projects/[uuid]/tasks/[taskUuid]/actions.ts @@ -32,7 +32,7 @@ export async function claimTaskAction(taskUuid: string) { assigneeType: auth.type, assigneeUuid: auth.actorUuid, assignedByUuid: auth.actorUuid, - }); + }, auth); // Record activity await createActivity({ @@ -79,7 +79,7 @@ export async function claimTaskToAgentAction(taskUuid: string, agentUuid: string assigneeType: "agent", assigneeUuid: agentUuid, assignedByUuid: auth.actorUuid, - }); + }, auth); // Record activity await createActivity({ @@ -122,7 +122,7 @@ export async function releaseTaskAction(taskUuid: string) { } // Release task - await releaseTask(taskUuid); + await releaseTask(taskUuid, auth); // Record activity await createActivity({ @@ -158,7 +158,7 @@ export async function updateTaskStatusAction(taskUuid: string, newStatus: string return { success: false, error: "Task not found" }; } - await updateTask(taskUuid, { status: newStatus }); + await updateTask(taskUuid, { status: newStatus }, auth); // Record activity await createActivity({ @@ -205,7 +205,7 @@ export async function verifyTaskAction(taskUuid: string) { return { success: false, error: gate.reason || "Not all required acceptance criteria are passed" }; } - await updateTask(taskUuid, { status: "done" }); + await updateTask(taskUuid, { status: "done" }, auth); revalidatePath(`/projects/${task.projectUuid}/tasks/${taskUuid}`); revalidatePath(`/projects/${task.projectUuid}/tasks`); @@ -240,7 +240,7 @@ export async function claimTaskToUserAction(taskUuid: string, userUuid: string) assigneeType: "user", assigneeUuid: userUuid, assignedByUuid: auth.actorUuid, - }); + }, auth); // Record activity await createActivity({ @@ -290,7 +290,7 @@ export async function createTaskAction(input: CreateTaskInput) { storyPoints: input.storyPoints, acceptanceCriteria: input.acceptanceCriteria, createdByUuid: auth.actorUuid, - }); + }, auth); // Record activity await createActivity({ @@ -341,13 +341,13 @@ export async function updateTaskFieldsAction(input: UpdateTaskFieldsInput) { priority: input.priority, storyPoints: input.storyPoints, acceptanceCriteria: input.acceptanceCriteria, - }); + }, auth); // Only replace structured acceptance criteria when the client explicitly // sends them (i.e. they actually changed). Omitting the field leaves the // existing criteria — and their dev/admin verification marks — untouched. if (input.acceptanceCriteriaItems !== undefined) { - await replaceAcceptanceCriteria(auth.companyUuid, input.taskUuid, input.acceptanceCriteriaItems); + await replaceAcceptanceCriteria(auth.companyUuid, input.taskUuid, input.acceptanceCriteriaItems, auth); } revalidatePath(`/projects/${input.projectUuid}/tasks`); @@ -371,7 +371,7 @@ export async function deleteTaskAction(taskUuid: string, projectUuid: string) { return { success: false, error: "Task not found" }; } - await deleteTask(taskUuid); + await deleteTask(taskUuid, auth); revalidatePath(`/projects/${projectUuid}/tasks`); return { success: true }; } catch (error) { diff --git a/src/app/(dashboard)/projects/[uuid]/tasks/[taskUuid]/activity-actions.ts b/src/app/(dashboard)/projects/[uuid]/tasks/[taskUuid]/activity-actions.ts index cf1d4cac..a8349a4e 100644 --- a/src/app/(dashboard)/projects/[uuid]/tasks/[taskUuid]/activity-actions.ts +++ b/src/app/(dashboard)/projects/[uuid]/tasks/[taskUuid]/activity-actions.ts @@ -27,6 +27,7 @@ export async function getTaskActivitiesAction( targetUuid: taskUuid, skip: 0, take: 50, + auth, }); } catch (error) { logger.error({ err: error }, "Failed to get task activities"); diff --git a/src/app/(dashboard)/projects/[uuid]/tasks/[taskUuid]/criteria-actions.ts b/src/app/(dashboard)/projects/[uuid]/tasks/[taskUuid]/criteria-actions.ts index fe05ed74..df712b25 100644 --- a/src/app/(dashboard)/projects/[uuid]/tasks/[taskUuid]/criteria-actions.ts +++ b/src/app/(dashboard)/projects/[uuid]/tasks/[taskUuid]/criteria-actions.ts @@ -30,6 +30,7 @@ export async function markCriteriaAction( taskUuid, criteria, { type: auth.type, actorUuid: auth.actorUuid }, + auth, ); revalidatePath(`/projects/${task.projectUuid}/tasks/${taskUuid}`); @@ -61,7 +62,7 @@ export async function resetCriterionAction( return { success: false, error: "Task not found" }; } - await resetAcceptanceCriterion(auth.companyUuid, taskUuid, criterionUuid); + await resetAcceptanceCriterion(auth.companyUuid, taskUuid, criterionUuid, auth); revalidatePath(`/projects/${task.projectUuid}/tasks/${taskUuid}`); revalidatePath(`/projects/${task.projectUuid}/tasks`); @@ -93,6 +94,7 @@ export async function selfCheckCriteriaAction( taskUuid, criteria, { type: auth.type, actorUuid: auth.actorUuid }, + auth, ); revalidatePath(`/projects/${task.projectUuid}/tasks/${taskUuid}`); diff --git a/src/app/(dashboard)/projects/[uuid]/tasks/[taskUuid]/dependency-actions.ts b/src/app/(dashboard)/projects/[uuid]/tasks/[taskUuid]/dependency-actions.ts index a3021b18..794b9e57 100644 --- a/src/app/(dashboard)/projects/[uuid]/tasks/[taskUuid]/dependency-actions.ts +++ b/src/app/(dashboard)/projects/[uuid]/tasks/[taskUuid]/dependency-actions.ts @@ -7,7 +7,7 @@ export async function getTaskDependenciesAction(taskUuid: string) { const auth = await getServerAuthContext(); if (!auth) return { dependsOn: [], dependedBy: [] }; try { - return await taskService.getTaskDependencies(auth.companyUuid, taskUuid); + return await taskService.getTaskDependencies(auth.companyUuid, taskUuid, auth); } catch { return { dependsOn: [], dependedBy: [] }; } @@ -17,7 +17,7 @@ export async function addTaskDependencyAction(taskUuid: string, dependsOnUuid: s const auth = await getServerAuthContext(); if (!auth) return { success: false, error: "Unauthorized" }; try { - await taskService.addTaskDependency(auth.companyUuid, taskUuid, dependsOnUuid); + await taskService.addTaskDependency(auth.companyUuid, taskUuid, dependsOnUuid, auth); return { success: true }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : "Unknown error" }; @@ -28,7 +28,7 @@ export async function removeTaskDependencyAction(taskUuid: string, dependsOnUuid const auth = await getServerAuthContext(); if (!auth) return { success: false, error: "Unauthorized" }; try { - await taskService.removeTaskDependency(auth.companyUuid, taskUuid, dependsOnUuid); + await taskService.removeTaskDependency(auth.companyUuid, taskUuid, dependsOnUuid, auth); return { success: true }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : "Unknown error" }; @@ -44,6 +44,7 @@ export async function getProjectTasksForDependencyAction(projectUuid: string) { projectUuid, skip: 0, take: 1000, + auth, }); return { tasks: result.tasks.map(t => ({ uuid: t.uuid, title: t.title, status: t.status })) }; } catch { diff --git a/src/app/(dashboard)/projects/[uuid]/tasks/actions.ts b/src/app/(dashboard)/projects/[uuid]/tasks/actions.ts index 3e88bf18..59b26f68 100644 --- a/src/app/(dashboard)/projects/[uuid]/tasks/actions.ts +++ b/src/app/(dashboard)/projects/[uuid]/tasks/actions.ts @@ -49,16 +49,16 @@ export async function moveTaskToColumnAction( // Done column should only be reached through verify action if (newStatus === "done" && task.status !== "to_verify") { // When dragging to done column, set to_verify instead - await updateTask(taskUuid, { status: "to_verify" }); + await updateTask(taskUuid, { status: "to_verify" }, auth); } else if (newStatus === "done" && task.status === "to_verify") { // If task is in to_verify and dragged to done, verify it const gate = await checkAcceptanceCriteriaGate(taskUuid); if (!gate.allowed) { return { success: false, error: gate.reason || "Not all required acceptance criteria are passed", gateBlocked: true, unresolvedCriteria: gate.unresolvedCriteria || [] }; } - await updateTask(taskUuid, { status: "done" }); + await updateTask(taskUuid, { status: "done" }, auth); } else { - await updateTask(taskUuid, { status: newStatus }); + await updateTask(taskUuid, { status: newStatus }, auth); } revalidatePath(`/projects/${projectUuid}/tasks`); @@ -84,7 +84,7 @@ export async function forceMoveTaskToColumnAction( return { success: false, error: "Task not found" }; } - await updateTask(taskUuid, { status }); + await updateTask(taskUuid, { status }, auth); await createActivity({ companyUuid: auth.companyUuid, @@ -119,6 +119,7 @@ export async function fetchTasksAction(projectUuid: string) { projectUuid, skip: 0, take: 1000, + auth, }); return { success: true as const, data: tasks }; } catch (error) { @@ -134,7 +135,7 @@ export async function getProjectDependenciesAction(projectUuid: string) { } try { - return await getProjectTaskDependencies(auth.companyUuid, projectUuid); + return await getProjectTaskDependencies(auth.companyUuid, projectUuid, auth); } catch (error) { logger.error({ err: error }, "Failed to get project dependencies"); return { nodes: [], edges: [] }; diff --git a/src/app/(dashboard)/projects/[uuid]/tasks/tasks-page-content.tsx b/src/app/(dashboard)/projects/[uuid]/tasks/tasks-page-content.tsx index 848e62ec..b5d5e2f4 100644 --- a/src/app/(dashboard)/projects/[uuid]/tasks/tasks-page-content.tsx +++ b/src/app/(dashboard)/projects/[uuid]/tasks/tasks-page-content.tsx @@ -26,7 +26,7 @@ export async function TasksPageContent({ const t = await getTranslations(); // Validate project exists - const exists = await projectExists(auth.companyUuid, projectUuid); + const exists = await projectExists(auth.companyUuid, projectUuid, auth); if (!exists) { redirect("/projects"); } @@ -37,6 +37,7 @@ export async function TasksPageContent({ projectUuid, skip: 0, take: 1000, + auth, }); const totalHours = tasks.reduce((sum, task) => sum + (task.storyPoints || 0), 0); diff --git a/src/app/(dashboard)/projects/comment-actions.ts b/src/app/(dashboard)/projects/comment-actions.ts index 06756ccf..fa272fa7 100644 --- a/src/app/(dashboard)/projects/comment-actions.ts +++ b/src/app/(dashboard)/projects/comment-actions.ts @@ -40,6 +40,7 @@ export async function getCommentsAction( targetUuid, skip: 0, take: 100, + auth, }); const commentsWithOwner = await resolveAgentOwners(result.comments); @@ -83,6 +84,7 @@ export async function createCommentAction( content: content.trim(), authorType: auth.type, authorUuid: auth.actorUuid, + auth, }); // Record activity for notification pipeline diff --git a/src/app/(dashboard)/projects/page.tsx b/src/app/(dashboard)/projects/page.tsx index ffbfd2e9..0c899302 100644 --- a/src/app/(dashboard)/projects/page.tsx +++ b/src/app/(dashboard)/projects/page.tsx @@ -35,6 +35,7 @@ import { Bot, Layers, Sparkles, + Lock, } from "lucide-react"; import { Progress } from "@/components/ui/progress"; import { MoveProjectConfirmDialog } from "@/components/move-project-confirm-dialog"; @@ -48,6 +49,7 @@ interface ProjectData { name: string; description: string | null; groupUuid: string | null; + visibility?: "shared" | "private"; createdAt: string; updatedAt: string; counts: { @@ -64,6 +66,9 @@ interface ProjectGroupData { name: string; description: string | null; projectCount: number; + visibility?: "shared" | "private"; + ownerType?: "user" | "agent" | null; + ownerUuid?: string | null; createdAt: string; updatedAt: string; } @@ -141,6 +146,12 @@ function ProjectGridCard({ project }: { project: ProjectData }) { {project.name} + {project.visibility === "private" && ( + + + {t("projects.visibilityPrivate")} + + )} {isEmpty && ( {t("projects.empty")} @@ -206,6 +217,12 @@ function ProjectListRow({ project, showDivider = true }: { project: ProjectData; {project.name} + {project.visibility === "private" && ( + + + {t("projects.visibilityPrivate")} + + )} {isEmpty && ( {t("projects.empty")} @@ -318,6 +335,15 @@ function GroupSection({ > {projects.length} + {group.visibility === "private" && ( + + + {t("projects.visibilityPrivate")} + + )}
diff --git a/src/app/api/__tests__/proposals-summary-route.test.ts b/src/app/api/__tests__/proposals-summary-route.test.ts index 0a6cbf3e..15e7477b 100644 --- a/src/app/api/__tests__/proposals-summary-route.test.ts +++ b/src/app/api/__tests__/proposals-summary-route.test.ts @@ -66,7 +66,7 @@ describe("GET /api/projects/[uuid]/proposals/summary", () => { const req = makeRequest(`/api/projects/${projectUuid}/proposals/summary`); await GET(req, makeContext(projectUuid)); - expect(mockGetProjectProposals).toHaveBeenCalledWith(companyUuid, projectUuid); + expect(mockGetProjectProposals).toHaveBeenCalledWith(companyUuid, projectUuid, expect.anything()); }); it("returns empty array when no approved proposals exist", async () => { diff --git a/src/app/api/comments/route.ts b/src/app/api/comments/route.ts index 21eb34aa..13907a6b 100644 --- a/src/app/api/comments/route.ts +++ b/src/app/api/comments/route.ts @@ -41,6 +41,7 @@ export const GET = withErrorHandler(async (request: NextRequest) => { targetUuid: query.targetUuid, skip, take, + auth, }); return paginated(comments, page, pageSize, total); @@ -84,6 +85,7 @@ export const POST = withErrorHandler(async (request: NextRequest) => { content: body.content.trim(), authorType: isUser(auth) ? "user" : "agent", authorUuid: auth.actorUuid, + auth, }); return success(comment); diff --git a/src/app/api/documents/[uuid]/route.ts b/src/app/api/documents/[uuid]/route.ts index c6cfbef9..0a738f6e 100644 --- a/src/app/api/documents/[uuid]/route.ts +++ b/src/app/api/documents/[uuid]/route.ts @@ -26,7 +26,7 @@ export const GET = withErrorHandler<{ uuid: string }>( if (denied) return denied; const { uuid } = await context.params; - const document = await getDocument(auth.companyUuid, uuid); + const document = await getDocument(auth.companyUuid, uuid, auth); if (!document) { return errors.notFound("Document"); @@ -76,7 +76,7 @@ export const PATCH = withErrorHandler<{ uuid: string }>( title: body.title?.trim(), content: body.content !== undefined ? (body.content.trim() || null) : undefined, incrementVersion: body.incrementVersion, - }); + }, auth); return success(updated); } @@ -106,7 +106,7 @@ export const DELETE = withErrorHandler<{ uuid: string }>( return errors.notFound("Document"); } - await deleteDocument(document.uuid); + await deleteDocument(document.uuid, auth); return success({ deleted: true }); } ); diff --git a/src/app/api/ideas/[uuid]/claim/__tests__/route.test.ts b/src/app/api/ideas/[uuid]/claim/__tests__/route.test.ts index f16eeeff..ee9666ce 100644 --- a/src/app/api/ideas/[uuid]/claim/__tests__/route.test.ts +++ b/src/app/api/ideas/[uuid]/claim/__tests__/route.test.ts @@ -73,6 +73,7 @@ describe("POST /api/ideas/[uuid]/claim — agent selection gating", () => { assigneeUuid: agentUuid, assignedByUuid: userUuid, }), + expect.anything(), ); }); @@ -151,6 +152,7 @@ describe("POST /api/ideas/[uuid]/claim — agent self-claim", () => { assigneeType: "agent", assigneeUuid: agentUuid, }), + expect.anything(), ); }); diff --git a/src/app/api/ideas/[uuid]/claim/route.ts b/src/app/api/ideas/[uuid]/claim/route.ts index ec31bb17..0c85abd4 100644 --- a/src/app/api/ideas/[uuid]/claim/route.ts +++ b/src/app/api/ideas/[uuid]/claim/route.ts @@ -94,7 +94,7 @@ export const POST = withErrorHandler<{ uuid: string }>( assigneeType, assigneeUuid, assignedByUuid, - }); + }, auth); return success(updated); } catch (e) { diff --git a/src/app/api/ideas/[uuid]/move/__tests__/integration.test.ts b/src/app/api/ideas/[uuid]/move/__tests__/integration.test.ts index e13ab94d..cfc5775e 100644 --- a/src/app/api/ideas/[uuid]/move/__tests__/integration.test.ts +++ b/src/app/api/ideas/[uuid]/move/__tests__/integration.test.ts @@ -37,6 +37,16 @@ const { hoistedPrisma, hoistedActivity } = vi.hoisted(() => ({ })); const mockPrisma = buildMockPrisma(); +// canAccessProject (added by the project-visibility feature) consults +// prisma.projectMember to authorize the synthetic human actor against the +// source + target projects. The shared fixture's mock prisma predates that +// table, so stub a membership-returning model here: the actor is treated as +// a member of every project, which is all this cascade-move test needs. +(mockPrisma as unknown as Record).projectMember = { + findUnique: vi.fn().mockResolvedValue({ id: 1 }), + findFirst: vi.fn().mockResolvedValue({ id: 1 }), + findMany: vi.fn().mockResolvedValue([]), +}; const mockActivityService = buildActivityServiceMock(COMPANY_UUID); hoistedPrisma.current = mockPrisma; hoistedActivity.current = mockActivityService; diff --git a/src/app/api/ideas/[uuid]/move/__tests__/route.test.ts b/src/app/api/ideas/[uuid]/move/__tests__/route.test.ts index 0972a237..b8fbd0ab 100644 --- a/src/app/api/ideas/[uuid]/move/__tests__/route.test.ts +++ b/src/app/api/ideas/[uuid]/move/__tests__/route.test.ts @@ -80,6 +80,7 @@ describe("PATCH /api/ideas/[uuid]/move — moved cascade counts", () => { TARGET_PROJECT_UUID, USER_UUID, "user", + expect.anything(), ); }); diff --git a/src/app/api/ideas/[uuid]/move/preview/__tests__/route.test.ts b/src/app/api/ideas/[uuid]/move/preview/__tests__/route.test.ts index 72754451..a02863c7 100644 --- a/src/app/api/ideas/[uuid]/move/preview/__tests__/route.test.ts +++ b/src/app/api/ideas/[uuid]/move/preview/__tests__/route.test.ts @@ -78,6 +78,7 @@ describe("GET /api/ideas/[uuid]/move/preview", () => { COMPANY_UUID, IDEA_UUID, TARGET_PROJECT_UUID, + expect.anything(), ); }); @@ -143,6 +144,7 @@ describe("GET /api/ideas/[uuid]/move/preview", () => { COMPANY_UUID, IDEA_UUID, TARGET_PROJECT_UUID, + expect.anything(), ); }); diff --git a/src/app/api/ideas/[uuid]/move/preview/route.ts b/src/app/api/ideas/[uuid]/move/preview/route.ts index 1ab6772d..85a6039d 100644 --- a/src/app/api/ideas/[uuid]/move/preview/route.ts +++ b/src/app/api/ideas/[uuid]/move/preview/route.ts @@ -52,7 +52,7 @@ export const GET = withErrorHandler<{ uuid: string }>( return errors.badRequest("Idea is already in the target project"); } - const result = await moveIdeaPreview(auth.companyUuid, ideaUuid, targetProjectUuid); + const result = await moveIdeaPreview(auth.companyUuid, ideaUuid, targetProjectUuid, auth); return success({ moved: result.moved }); } ); diff --git a/src/app/api/ideas/[uuid]/move/route.ts b/src/app/api/ideas/[uuid]/move/route.ts index 901dc83b..77f36aa6 100644 --- a/src/app/api/ideas/[uuid]/move/route.ts +++ b/src/app/api/ideas/[uuid]/move/route.ts @@ -34,7 +34,8 @@ export const PATCH = withErrorHandler<{ uuid: string }>( uuid, body.targetProjectUuid, auth.actorUuid, - auth.type + auth.type, + auth ); return success(updated); diff --git a/src/app/api/ideas/[uuid]/release/route.ts b/src/app/api/ideas/[uuid]/release/route.ts index 79426c88..5d0520dc 100644 --- a/src/app/api/ideas/[uuid]/release/route.ts +++ b/src/app/api/ideas/[uuid]/release/route.ts @@ -36,7 +36,7 @@ export const POST = withErrorHandler<{ uuid: string }>( } try { - const updated = await releaseIdea(idea.uuid); + const updated = await releaseIdea(idea.uuid, auth); return success(updated); } catch (e) { if (e instanceof NotClaimedError) { diff --git a/src/app/api/ideas/[uuid]/route.ts b/src/app/api/ideas/[uuid]/route.ts index 5349b00f..cd3c829f 100644 --- a/src/app/api/ideas/[uuid]/route.ts +++ b/src/app/api/ideas/[uuid]/route.ts @@ -27,7 +27,7 @@ export const GET = withErrorHandler<{ uuid: string }>( if (denied) return denied; const { uuid } = await context.params; - const idea = await getIdea(auth.companyUuid, uuid); + const idea = await getIdea(auth.companyUuid, uuid, auth); if (!idea) { return errors.notFound("Idea"); @@ -98,7 +98,7 @@ export const PATCH = withErrorHandler<{ uuid: string }>( updateData.status = body.status; } - const updated = await updateIdea(idea.uuid, auth.companyUuid, updateData); + const updated = await updateIdea(idea.uuid, auth.companyUuid, updateData, auth); return success(updated); } ); @@ -123,7 +123,7 @@ export const DELETE = withErrorHandler<{ uuid: string }>( return errors.notFound("Idea"); } - await deleteIdea(idea.uuid); + await deleteIdea(idea.uuid, auth); return success({ deleted: true }); } ); diff --git a/src/app/api/mentionables/route.ts b/src/app/api/mentionables/route.ts index f3c5b5ec..75dd9fe5 100644 --- a/src/app/api/mentionables/route.ts +++ b/src/app/api/mentionables/route.ts @@ -7,7 +7,7 @@ import { success, errors } from "@/lib/api-response"; import { getAuthContext, isAgent } from "@/lib/auth"; import * as mentionService from "@/services/mention.service"; -// GET /api/mentionables?q=keyword&limit=10 +// GET /api/mentionables?q=keyword&limit=10&forMembers=1 export const GET = withErrorHandler(async (request: NextRequest) => { const auth = await getAuthContext(request); if (!auth) { @@ -17,6 +17,10 @@ export const GET = withErrorHandler(async (request: NextRequest) => { const query = parseQuery(request); const q = query.q || ""; const limit = Math.min(50, Math.max(1, parseInt(query.limit || "10", 10))); + // forMembers=1 powers the member-add picker, which also lists recent company + // users on an empty query. Absent/unset preserves the default @mention + // behavior (agents only on empty query). + const forMembers = query.forMembers === "1" || query.forMembers === "true"; const results = await mentionService.searchMentionables({ companyUuid: auth.companyUuid, @@ -25,6 +29,7 @@ export const GET = withErrorHandler(async (request: NextRequest) => { actorUuid: auth.actorUuid, ownerUuid: isAgent(auth) ? auth.ownerUuid : auth.actorUuid, limit, + includeUsersOnEmpty: forMembers, }); return success(results); diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts index 5dd7647e..8b114a18 100644 --- a/src/app/api/notifications/route.ts +++ b/src/app/api/notifications/route.ts @@ -36,6 +36,7 @@ export const GET = withErrorHandler(async (request: NextRequest) => { archived: false, skip: offset, take: limit, + auth, }); return success({ diff --git a/src/app/api/project-groups/[uuid]/__tests__/route.test.ts b/src/app/api/project-groups/[uuid]/__tests__/route.test.ts new file mode 100644 index 00000000..2dccfd34 --- /dev/null +++ b/src/app/api/project-groups/[uuid]/__tests__/route.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +// Mock dependencies +const mockGetProjectGroup = vi.fn(); +const mockUpdateProjectGroup = vi.fn(); +const mockDeleteProjectGroup = vi.fn(); +const mockSetGroupVisibility = vi.fn(); +const mockGetAuthContext = vi.fn(); +const mockCanAccessGroup = vi.fn(); +const mockClaimOrCanManageGroup = vi.fn(); + +vi.mock("@/services/project-group.service", () => ({ + getProjectGroup: (...args: unknown[]) => mockGetProjectGroup(...args), + updateProjectGroup: (...args: unknown[]) => mockUpdateProjectGroup(...args), + deleteProjectGroup: (...args: unknown[]) => mockDeleteProjectGroup(...args), + setGroupVisibility: (...args: unknown[]) => mockSetGroupVisibility(...args), +})); + +vi.mock("@/lib/authz/project-access", () => ({ + canAccessGroup: (...args: unknown[]) => mockCanAccessGroup(...args), + claimOrCanManageGroup: (...args: unknown[]) => mockClaimOrCanManageGroup(...args), +})); + +vi.mock("@/lib/auth", () => ({ + getAuthContext: (...args: unknown[]) => mockGetAuthContext(...args), + isUser: (auth: { type: string }) => auth.type === "user", + isAgent: (auth: { type: string }) => auth.type === "agent", + hasPermission: (auth: { permissions?: string[] }, perm: string) => + auth.permissions?.includes(perm) ?? false, + checkAgentPermission: (auth: { type: string; permissions?: string[] }, perm: string) => { + if (auth.type === "agent" && !(auth.permissions?.includes(perm) ?? false)) { + return new Response( + JSON.stringify({ success: false, error: { message: `Missing permission: ${perm}` } }), + { status: 403 } + ); + } + return null; + }, +})); + +import { GET, PATCH, DELETE } from "@/app/api/project-groups/[uuid]/route"; + +const companyUuid = "company-0000-0000-0000-000000000001"; +const groupUuid = "group-0000-0000-0000-000000000001"; +const ownerAuth = { type: "user", companyUuid, actorUuid: "owner-uuid-1" }; +const memberAuth = { type: "user", companyUuid, actorUuid: "member-uuid-2" }; + +const groupRecord = { + uuid: groupUuid, + name: "Group", + description: null, + visibility: "private", + ownerType: "user", + ownerUuid: "owner-uuid-1", +}; + +function makeRequest( + url: string, + init?: ConstructorParameters[1] +): NextRequest { + return new NextRequest(new URL(url, "http://localhost:3000"), init); +} + +function makeContext(uuid: string) { + return { params: Promise.resolve({ uuid }) }; +} + +describe("GET /api/project-groups/[uuid] — visibility leak rule", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetAuthContext.mockResolvedValue(memberAuth); + }); + + it("returns 404 (not 403) when group is inaccessible — no existence leak", async () => { + mockGetProjectGroup.mockResolvedValue(null); + const res = await GET(makeRequest(`/api/project-groups/${groupUuid}`), makeContext(groupUuid)); + expect(res.status).toBe(404); + }); + + it("returns group when accessible", async () => { + mockGetProjectGroup.mockResolvedValue(groupRecord); + const res = await GET(makeRequest(`/api/project-groups/${groupUuid}`), makeContext(groupUuid)); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body.data.uuid).toBe(groupUuid); + }); +}); + +describe("PATCH /api/project-groups/[uuid] — manage gating", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetAuthContext.mockResolvedValue(ownerAuth); + mockCanAccessGroup.mockResolvedValue(true); + mockClaimOrCanManageGroup.mockResolvedValue(true); + mockSetGroupVisibility.mockResolvedValue({ uuid: groupUuid, visibility: "shared" }); + mockUpdateProjectGroup.mockResolvedValue(groupRecord); + }); + + it("owner can change visibility", async () => { + const res = await PATCH( + makeRequest(`/api/project-groups/${groupUuid}`, { + method: "PATCH", + body: JSON.stringify({ visibility: "shared" }), + }), + makeContext(groupUuid) + ); + expect(res.status).toBe(200); + expect(mockSetGroupVisibility).toHaveBeenCalledWith(companyUuid, groupUuid, "shared"); + }); + + it("non-owner member gets 403 when updating", async () => { + mockGetAuthContext.mockResolvedValue(memberAuth); + mockClaimOrCanManageGroup.mockResolvedValue(false); + + const res = await PATCH( + makeRequest(`/api/project-groups/${groupUuid}`, { + method: "PATCH", + body: JSON.stringify({ name: "Renamed" }), + }), + makeContext(groupUuid) + ); + expect(res.status).toBe(403); + expect(mockUpdateProjectGroup).not.toHaveBeenCalled(); + }); + + it("returns 404 when group is inaccessible (no leak) before manage check", async () => { + mockCanAccessGroup.mockResolvedValue(false); + const res = await PATCH( + makeRequest(`/api/project-groups/${groupUuid}`, { + method: "PATCH", + body: JSON.stringify({ name: "Renamed" }), + }), + makeContext(groupUuid) + ); + expect(res.status).toBe(404); + expect(mockClaimOrCanManageGroup).not.toHaveBeenCalled(); + }); +}); + +describe("DELETE /api/project-groups/[uuid] — manage gating (newly gated)", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetAuthContext.mockResolvedValue(ownerAuth); + mockCanAccessGroup.mockResolvedValue(true); + mockClaimOrCanManageGroup.mockResolvedValue(true); + mockDeleteProjectGroup.mockResolvedValue(true); + }); + + it("owner (or claimer) can delete the group", async () => { + const res = await DELETE( + makeRequest(`/api/project-groups/${groupUuid}`, { method: "DELETE" }), + makeContext(groupUuid) + ); + expect(res.status).toBe(200); + expect(mockDeleteProjectGroup).toHaveBeenCalledWith(companyUuid, groupUuid, false); + }); + + it("non-member of a private group gets 404 (no existence leak) before manage check", async () => { + mockGetAuthContext.mockResolvedValue(memberAuth); + mockCanAccessGroup.mockResolvedValue(false); + + const res = await DELETE( + makeRequest(`/api/project-groups/${groupUuid}`, { method: "DELETE" }), + makeContext(groupUuid) + ); + expect(res.status).toBe(404); + expect(mockClaimOrCanManageGroup).not.toHaveBeenCalled(); + expect(mockDeleteProjectGroup).not.toHaveBeenCalled(); + }); + + it("accessible non-owner gets 403", async () => { + mockGetAuthContext.mockResolvedValue(memberAuth); + mockClaimOrCanManageGroup.mockResolvedValue(false); + + const res = await DELETE( + makeRequest(`/api/project-groups/${groupUuid}`, { method: "DELETE" }), + makeContext(groupUuid) + ); + expect(res.status).toBe(403); + expect(mockDeleteProjectGroup).not.toHaveBeenCalled(); + }); + + it("returns 404 when the group does not exist (service returns false)", async () => { + mockDeleteProjectGroup.mockResolvedValue(false); + const res = await DELETE( + makeRequest(`/api/project-groups/${groupUuid}`, { method: "DELETE" }), + makeContext(groupUuid) + ); + expect(res.status).toBe(404); + }); +}); diff --git a/src/app/api/project-groups/[uuid]/dashboard/route.ts b/src/app/api/project-groups/[uuid]/dashboard/route.ts index 6d8850bf..4cf8562f 100644 --- a/src/app/api/project-groups/[uuid]/dashboard/route.ts +++ b/src/app/api/project-groups/[uuid]/dashboard/route.ts @@ -16,7 +16,7 @@ export const GET = withErrorHandler( if (denied) return denied; const { uuid } = await context.params; - const dashboard = await getGroupDashboard(auth.companyUuid, uuid); + const dashboard = await getGroupDashboard(auth.companyUuid, uuid, auth); if (!dashboard) return errors.notFound("Project group"); return success(dashboard); diff --git a/src/app/api/project-groups/[uuid]/members/__tests__/route.test.ts b/src/app/api/project-groups/[uuid]/members/__tests__/route.test.ts new file mode 100644 index 00000000..a0ba03c2 --- /dev/null +++ b/src/app/api/project-groups/[uuid]/members/__tests__/route.test.ts @@ -0,0 +1,248 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +// Mock dependencies +const mockListGroupMembers = vi.fn(); +const mockAddGroupMember = vi.fn(); +const mockRemoveGroupMember = vi.fn(); +const mockGetAuthContext = vi.fn(); +const mockCanAccessGroup = vi.fn(); +const mockClaimOrCanManageGroup = vi.fn(); + +vi.mock("@/services/project-group.service", () => ({ + listGroupMembers: (...args: unknown[]) => mockListGroupMembers(...args), + addGroupMember: (...args: unknown[]) => mockAddGroupMember(...args), + removeGroupMember: (...args: unknown[]) => mockRemoveGroupMember(...args), +})); + +vi.mock("@/lib/authz/project-access", () => ({ + canAccessGroup: (...args: unknown[]) => mockCanAccessGroup(...args), + claimOrCanManageGroup: (...args: unknown[]) => mockClaimOrCanManageGroup(...args), +})); + +vi.mock("@/lib/auth", () => ({ + getAuthContext: (...args: unknown[]) => mockGetAuthContext(...args), + isUser: (auth: { type: string }) => auth.type === "user", + isAgent: (auth: { type: string }) => auth.type === "agent", + hasPermission: (auth: { permissions?: string[] }, perm: string) => + auth.permissions?.includes(perm) ?? false, + checkAgentPermission: (auth: { type: string; permissions?: string[] }, perm: string) => { + if (auth.type === "agent" && !(auth.permissions?.includes(perm) ?? false)) { + return new Response( + JSON.stringify({ success: false, error: { message: `Missing permission: ${perm}` } }), + { status: 403 } + ); + } + return null; + }, +})); + +import { GET, POST, DELETE } from "@/app/api/project-groups/[uuid]/members/route"; + +const companyUuid = "company-0000-0000-0000-000000000001"; +const groupUuid = "group-0000-0000-0000-000000000001"; +const ownerAuth = { type: "user", companyUuid, actorUuid: "owner-uuid-1" }; +const memberAuth = { type: "user", companyUuid, actorUuid: "member-uuid-2" }; + +function makeRequest( + url: string, + init?: ConstructorParameters[1] +): NextRequest { + return new NextRequest(new URL(url, "http://localhost:3000"), init); +} + +function makeContext(uuid: string) { + return { params: Promise.resolve({ uuid }) }; +} + +describe("GET /api/project-groups/[uuid]/members", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetAuthContext.mockResolvedValue(ownerAuth); + mockCanAccessGroup.mockResolvedValue(true); + mockListGroupMembers.mockResolvedValue([]); + }); + + it("lists members for a member who can access the group", async () => { + const members = [ + { uuid: "m1", memberType: "user", memberUuid: "owner-uuid-1", role: "owner", createdAt: "x" }, + ]; + mockGetAuthContext.mockResolvedValue(memberAuth); + mockListGroupMembers.mockResolvedValue(members); + + const res = await GET(makeRequest(`/api/project-groups/${groupUuid}/members`), makeContext(groupUuid)); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.success).toBe(true); + expect(body.data.members).toEqual(members); + }); + + it("returns 401 when not authenticated", async () => { + mockGetAuthContext.mockResolvedValue(null); + const res = await GET(makeRequest(`/api/project-groups/${groupUuid}/members`), makeContext(groupUuid)); + expect(res.status).toBe(401); + }); + + it("returns 404 (not 403) for an inaccessible group — no existence leak", async () => { + mockCanAccessGroup.mockResolvedValue(false); + const res = await GET(makeRequest(`/api/project-groups/${groupUuid}/members`), makeContext(groupUuid)); + expect(res.status).toBe(404); + }); +}); + +describe("POST /api/project-groups/[uuid]/members", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetAuthContext.mockResolvedValue(ownerAuth); + mockCanAccessGroup.mockResolvedValue(true); + mockClaimOrCanManageGroup.mockResolvedValue(true); + mockAddGroupMember.mockResolvedValue({ + uuid: "m2", + memberType: "user", + memberUuid: "new-user", + role: "member", + createdAt: "x", + }); + }); + + it("owner can add a member", async () => { + const res = await POST( + makeRequest(`/api/project-groups/${groupUuid}/members`, { + method: "POST", + body: JSON.stringify({ memberType: "user", memberUuid: "new-user" }), + }), + makeContext(groupUuid) + ); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.success).toBe(true); + expect(mockAddGroupMember).toHaveBeenCalledWith(companyUuid, groupUuid, "user", "new-user"); + }); + + it("non-owner member gets 403", async () => { + mockGetAuthContext.mockResolvedValue(memberAuth); + mockClaimOrCanManageGroup.mockResolvedValue(false); + + const res = await POST( + makeRequest(`/api/project-groups/${groupUuid}/members`, { + method: "POST", + body: JSON.stringify({ memberType: "user", memberUuid: "new-user" }), + }), + makeContext(groupUuid) + ); + + expect(res.status).toBe(403); + expect(mockAddGroupMember).not.toHaveBeenCalled(); + }); + + it("returns 404 for an inaccessible group before checking management", async () => { + mockCanAccessGroup.mockResolvedValue(false); + + const res = await POST( + makeRequest(`/api/project-groups/${groupUuid}/members`, { + method: "POST", + body: JSON.stringify({ memberType: "user", memberUuid: "new-user" }), + }), + makeContext(groupUuid) + ); + + expect(res.status).toBe(404); + expect(mockClaimOrCanManageGroup).not.toHaveBeenCalled(); + }); + + it("returns 422 for invalid memberType", async () => { + const res = await POST( + makeRequest(`/api/project-groups/${groupUuid}/members`, { + method: "POST", + body: JSON.stringify({ memberType: "bogus", memberUuid: "new-user" }), + }), + makeContext(groupUuid) + ); + + expect(res.status).toBe(422); + }); + + it("returns 422 for missing memberUuid", async () => { + const res = await POST( + makeRequest(`/api/project-groups/${groupUuid}/members`, { + method: "POST", + body: JSON.stringify({ memberType: "agent" }), + }), + makeContext(groupUuid) + ); + + expect(res.status).toBe(422); + }); +}); + +describe("DELETE /api/project-groups/[uuid]/members", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetAuthContext.mockResolvedValue(ownerAuth); + mockCanAccessGroup.mockResolvedValue(true); + mockClaimOrCanManageGroup.mockResolvedValue(true); + mockRemoveGroupMember.mockResolvedValue(true); + }); + + it("owner can remove a member via query params", async () => { + const res = await DELETE( + makeRequest( + `/api/project-groups/${groupUuid}/members?memberType=user&memberUuid=victim`, + { method: "DELETE" } + ), + makeContext(groupUuid) + ); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.success).toBe(true); + expect(mockRemoveGroupMember).toHaveBeenCalledWith(companyUuid, groupUuid, "user", "victim"); + }); + + it("non-owner member gets 403", async () => { + mockGetAuthContext.mockResolvedValue(memberAuth); + mockClaimOrCanManageGroup.mockResolvedValue(false); + + const res = await DELETE( + makeRequest( + `/api/project-groups/${groupUuid}/members?memberType=user&memberUuid=victim`, + { method: "DELETE" } + ), + makeContext(groupUuid) + ); + + expect(res.status).toBe(403); + expect(mockRemoveGroupMember).not.toHaveBeenCalled(); + }); + + it("returns 404 for an inaccessible group before checking management", async () => { + mockCanAccessGroup.mockResolvedValue(false); + + const res = await DELETE( + makeRequest( + `/api/project-groups/${groupUuid}/members?memberType=user&memberUuid=victim`, + { method: "DELETE" } + ), + makeContext(groupUuid) + ); + + expect(res.status).toBe(404); + expect(mockClaimOrCanManageGroup).not.toHaveBeenCalled(); + }); + + it("returns 404 when the member does not exist", async () => { + mockRemoveGroupMember.mockResolvedValue(false); + + const res = await DELETE( + makeRequest( + `/api/project-groups/${groupUuid}/members?memberType=user&memberUuid=ghost`, + { method: "DELETE" } + ), + makeContext(groupUuid) + ); + + expect(res.status).toBe(404); + }); +}); diff --git a/src/app/api/project-groups/[uuid]/members/route.ts b/src/app/api/project-groups/[uuid]/members/route.ts new file mode 100644 index 00000000..1e4dff1e --- /dev/null +++ b/src/app/api/project-groups/[uuid]/members/route.ts @@ -0,0 +1,161 @@ +// src/app/api/project-groups/[uuid]/members/route.ts +// Project Group Members API - List, Add, Remove (Project Visibility — Tech Design §5) +// UUID-Based Architecture: All operations use UUIDs +// +// Leak rule: an inaccessible group must look like it does not exist (404). +// An accessible group the actor cannot MANAGE (i.e. is not the owner) yields +// 403 on mutations. Listing members only requires access (read). + +import { NextRequest } from "next/server"; +import { withErrorHandler, parseBody } from "@/lib/api-handler"; +import { success, errors } from "@/lib/api-response"; +import { getAuthContext, isUser, isAgent, hasPermission, checkAgentPermission } from "@/lib/auth"; +import { + listGroupMembers, + addGroupMember, + removeGroupMember, +} from "@/services/project-group.service"; +import { canAccessGroup, claimOrCanManageGroup } from "@/lib/authz/project-access"; + +type RouteContext = { params: Promise<{ uuid: string }> }; + +type MemberType = "user" | "agent"; + +function isMemberType(value: unknown): value is MemberType { + return value === "user" || value === "agent"; +} + +// GET /api/project-groups/[uuid]/members - List group members +export const GET = withErrorHandler<{ uuid: string }>( + async (request: NextRequest, context: RouteContext) => { + const auth = await getAuthContext(request); + if (!auth) { + return errors.unauthorized(); + } + const denied = checkAgentPermission(auth, "project:read"); + if (denied) return denied; + + const { uuid: groupUuid } = await context.params; + + // Must be able to access the group; otherwise hide its existence. + if (!(await canAccessGroup(auth, groupUuid))) { + return errors.notFound("Project group"); + } + + const members = await listGroupMembers(auth.companyUuid, groupUuid); + return success({ members }); + } +); + +// POST /api/project-groups/[uuid]/members - Add a member (owner-only) +export const POST = withErrorHandler<{ uuid: string }>( + async (request: NextRequest, context: RouteContext) => { + const auth = await getAuthContext(request); + if (!auth) { + return errors.unauthorized(); + } + + // Managing members requires project:write for agents, or a human user. + if (isAgent(auth)) { + if (!hasPermission(auth, "project:write")) { + return errors.forbidden("Missing permission: project:write"); + } + } else if (!isUser(auth)) { + return errors.forbidden("Only users or permitted agents can manage project group members"); + } + + const { uuid: groupUuid } = await context.params; + + // Leak rule: inaccessible -> 404; accessible but not owner -> 403. + if (!(await canAccessGroup(auth, groupUuid))) { + return errors.notFound("Project group"); + } + if (!(await claimOrCanManageGroup(auth, groupUuid))) { + return errors.forbidden("Only the project group owner can manage members"); + } + + const body = await parseBody<{ + memberType?: string; + memberUuid?: string; + }>(request); + + if (!isMemberType(body.memberType)) { + return errors.validationError({ memberType: "memberType must be 'user' or 'agent'" }); + } + if (!body.memberUuid || body.memberUuid.trim() === "") { + return errors.validationError({ memberUuid: "memberUuid is required" }); + } + + const member = await addGroupMember( + auth.companyUuid, + groupUuid, + body.memberType, + body.memberUuid.trim() + ); + if (!member) { + return errors.notFound("Project group"); + } + + return success(member); + } +); + +// DELETE /api/project-groups/[uuid]/members?memberType=...&memberUuid=... - Remove a member (owner-only) +export const DELETE = withErrorHandler<{ uuid: string }>( + async (request: NextRequest, context: RouteContext) => { + const auth = await getAuthContext(request); + if (!auth) { + return errors.unauthorized(); + } + + if (isAgent(auth)) { + if (!hasPermission(auth, "project:write")) { + return errors.forbidden("Missing permission: project:write"); + } + } else if (!isUser(auth)) { + return errors.forbidden("Only users or permitted agents can manage project group members"); + } + + const { uuid: groupUuid } = await context.params; + + // Leak rule: inaccessible -> 404; accessible but not owner -> 403. + if (!(await canAccessGroup(auth, groupUuid))) { + return errors.notFound("Project group"); + } + if (!(await claimOrCanManageGroup(auth, groupUuid))) { + return errors.forbidden("Only the project group owner can manage members"); + } + + // Accept memberType + memberUuid from query params, falling back to the body. + const url = new URL(request.url); + let memberType: string | null = url.searchParams.get("memberType"); + let memberUuid: string | null = url.searchParams.get("memberUuid"); + + if (!memberType || !memberUuid) { + const body = await parseBody<{ memberType?: string; memberUuid?: string }>(request).catch( + () => ({} as { memberType?: string; memberUuid?: string }) + ); + memberType = memberType ?? body.memberType ?? null; + memberUuid = memberUuid ?? body.memberUuid ?? null; + } + + if (!isMemberType(memberType)) { + return errors.validationError({ memberType: "memberType must be 'user' or 'agent'" }); + } + if (!memberUuid || memberUuid.trim() === "") { + return errors.validationError({ memberUuid: "memberUuid is required" }); + } + + const removed = await removeGroupMember( + auth.companyUuid, + groupUuid, + memberType, + memberUuid.trim() + ); + if (!removed) { + return errors.notFound("Member"); + } + + return success({ removed: true }); + } +); diff --git a/src/app/api/project-groups/[uuid]/route.ts b/src/app/api/project-groups/[uuid]/route.ts index a906900a..9d54bcfa 100644 --- a/src/app/api/project-groups/[uuid]/route.ts +++ b/src/app/api/project-groups/[uuid]/route.ts @@ -9,7 +9,9 @@ import { getProjectGroup, updateProjectGroup, deleteProjectGroup, + setGroupVisibility, } from "@/services/project-group.service"; +import { canAccessGroup, claimOrCanManageGroup } from "@/lib/authz/project-access"; // GET /api/project-groups/[uuid] export const GET = withErrorHandler( @@ -20,7 +22,7 @@ export const GET = withErrorHandler( if (denied) return denied; const { uuid } = await context.params; - const group = await getProjectGroup(auth.companyUuid, uuid); + const group = await getProjectGroup(auth.companyUuid, uuid, auth); if (!group) return errors.notFound("Project group"); return success(group); @@ -41,7 +43,29 @@ export const PATCH = withErrorHandler( } const { uuid } = await context.params; - const body = await parseBody<{ name?: string; description?: string }>(request); + const body = await parseBody<{ + name?: string; + description?: string; + visibility?: string; + }>(request); + + // Leak rule: inaccessible -> 404; accessible but not owner -> 403. The + // accessibility check runs BEFORE the manage check (no existence leak). + if (!(await canAccessGroup(auth, uuid))) { + return errors.notFound("Project group"); + } + if (!(await claimOrCanManageGroup(auth, uuid))) { + return errors.forbidden("Only the project group owner can update the group"); + } + + // Visibility change (if requested) goes through setGroupVisibility. + if (body.visibility !== undefined) { + if (body.visibility !== "shared" && body.visibility !== "private") { + return errors.validationError({ visibility: "visibility must be 'shared' or 'private'" }); + } + const updated = await setGroupVisibility(auth.companyUuid, uuid, body.visibility); + if (!updated) return errors.notFound("Project group"); + } const group = await updateProjectGroup({ companyUuid: auth.companyUuid, @@ -69,6 +93,16 @@ export const DELETE = withErrorHandler( } const { uuid } = await context.params; + + // Leak rule: inaccessible -> 404; accessible but not owner -> 403. The + // accessibility check runs BEFORE the manage check (no existence leak). + if (!(await canAccessGroup(auth, uuid))) { + return errors.notFound("Project group"); + } + if (!(await claimOrCanManageGroup(auth, uuid))) { + return errors.forbidden("Only the project group owner can delete the group"); + } + const shouldDeleteProjects = request.nextUrl.searchParams.get("deleteProjects") === "true"; const deleted = await deleteProjectGroup(auth.companyUuid, uuid, shouldDeleteProjects); diff --git a/src/app/api/project-groups/route.ts b/src/app/api/project-groups/route.ts index e52cae7e..e3a4c077 100644 --- a/src/app/api/project-groups/route.ts +++ b/src/app/api/project-groups/route.ts @@ -17,7 +17,7 @@ export const GET = withErrorHandler(async (request: NextRequest) => { const denied = checkAgentPermission(auth, "project:read"); if (denied) return denied; - const result = await listProjectGroups(auth.companyUuid); + const result = await listProjectGroups(auth.companyUuid, auth); return success(result); }); @@ -33,15 +33,42 @@ export const POST = withErrorHandler(async (request: NextRequest) => { return errors.forbidden("Only users or permitted agents can create project groups"); } - const body = await parseBody<{ name: string; description?: string }>(request); + const body = await parseBody<{ + name: string; + description?: string; + visibility?: string; + memberUuids?: { memberType?: string; memberUuid?: string }[]; + }>(request); if (!body.name || body.name.trim() === "") { return errors.validationError({ name: "Name is required" }); } + if (body.visibility !== undefined && body.visibility !== "shared" && body.visibility !== "private") { + return errors.validationError({ visibility: "visibility must be 'shared' or 'private'" }); + } + + // Owner = the acting human or agent. Super admin creates an owner-less group. + const ownerType: "user" | "agent" | null = + isUser(auth) || isAgent(auth) ? auth.type : null; + const ownerUuid: string | null = + isUser(auth) || isAgent(auth) ? auth.actorUuid : null; + + const memberUuids = (body.memberUuids ?? []) + .filter( + (m): m is { memberType: "user" | "agent"; memberUuid: string } => + (m.memberType === "user" || m.memberType === "agent") && + typeof m.memberUuid === "string" && + m.memberUuid.trim() !== "" + ) + .map((m) => ({ memberType: m.memberType, memberUuid: m.memberUuid.trim() })); const group = await createProjectGroup({ companyUuid: auth.companyUuid, name: body.name.trim(), description: body.description?.trim() || null, + visibility: body.visibility as "shared" | "private" | undefined, + ownerType, + ownerUuid, + memberUuids, }); return success(group); diff --git a/src/app/api/projects/[uuid]/__tests__/route.test.ts b/src/app/api/projects/[uuid]/__tests__/route.test.ts new file mode 100644 index 00000000..97d3087b --- /dev/null +++ b/src/app/api/projects/[uuid]/__tests__/route.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +// Mock dependencies +const mockGetProject = vi.fn(); +const mockUpdateProject = vi.fn(); +const mockDeleteProject = vi.fn(); +const mockSetProjectVisibility = vi.fn(); +const mockGetAuthContext = vi.fn(); +const mockClaimOrCanManageProject = vi.fn(); + +vi.mock("@/services/project.service", () => ({ + getProject: (...args: unknown[]) => mockGetProject(...args), + updateProject: (...args: unknown[]) => mockUpdateProject(...args), + deleteProject: (...args: unknown[]) => mockDeleteProject(...args), + setProjectVisibility: (...args: unknown[]) => mockSetProjectVisibility(...args), +})); + +vi.mock("@/lib/authz/project-access", () => ({ + claimOrCanManageProject: (...args: unknown[]) => mockClaimOrCanManageProject(...args), +})); + +vi.mock("@/lib/auth", () => ({ + getAuthContext: (...args: unknown[]) => mockGetAuthContext(...args), + isUser: (auth: { type: string }) => auth.type === "user", + isAgent: (auth: { type: string }) => auth.type === "agent", + hasPermission: (auth: { permissions?: string[] }, perm: string) => + auth.permissions?.includes(perm) ?? false, + checkAgentPermission: (auth: { type: string; permissions?: string[] }, perm: string) => { + if (auth.type === "agent" && !(auth.permissions?.includes(perm) ?? false)) { + return new Response( + JSON.stringify({ success: false, error: { message: `Missing permission: ${perm}` } }), + { status: 403 } + ); + } + return null; + }, +})); + +import { GET, PATCH, DELETE } from "@/app/api/projects/[uuid]/route"; + +const companyUuid = "company-0000-0000-0000-000000000001"; +const projectUuid = "project-0000-0000-0000-000000000001"; +const ownerAuth = { type: "user", companyUuid, actorUuid: "owner-uuid-1" }; +const memberAuth = { type: "user", companyUuid, actorUuid: "member-uuid-2" }; + +const projectRecord = { + uuid: projectUuid, + name: "Proj", + description: null, + groupUuid: null, + visibility: "private", + ownerType: "user", + ownerUuid: "owner-uuid-1", + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-02T00:00:00Z"), + _count: { ideas: 0, documents: 0, tasks: 0, proposals: 0, activities: 0 }, +}; + +function makeRequest( + url: string, + init?: ConstructorParameters[1] +): NextRequest { + return new NextRequest(new URL(url, "http://localhost:3000"), init); +} + +function makeContext(uuid: string) { + return { params: Promise.resolve({ uuid }) }; +} + +describe("GET /api/projects/[uuid] — visibility leak rule", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetAuthContext.mockResolvedValue(memberAuth); + }); + + it("returns 404 (not 403) when project is inaccessible — no existence leak", async () => { + mockGetProject.mockResolvedValue(null); + const res = await GET(makeRequest(`/api/projects/${projectUuid}`), makeContext(projectUuid)); + expect(res.status).toBe(404); + }); + + it("returns project with visibility when accessible", async () => { + mockGetProject.mockResolvedValue(projectRecord); + const res = await GET(makeRequest(`/api/projects/${projectUuid}`), makeContext(projectUuid)); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body.data.visibility).toBe("private"); + }); +}); + +describe("PATCH /api/projects/[uuid] — manage gating", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetAuthContext.mockResolvedValue(ownerAuth); + mockGetProject.mockResolvedValue(projectRecord); + mockClaimOrCanManageProject.mockResolvedValue(true); + mockSetProjectVisibility.mockResolvedValue({ uuid: projectUuid, visibility: "shared" }); + mockUpdateProject.mockResolvedValue({ + uuid: projectUuid, + name: "Renamed", + description: null, + createdAt: projectRecord.createdAt, + updatedAt: projectRecord.updatedAt, + }); + }); + + it("owner can change visibility", async () => { + const res = await PATCH( + makeRequest(`/api/projects/${projectUuid}`, { + method: "PATCH", + body: JSON.stringify({ visibility: "shared" }), + }), + makeContext(projectUuid) + ); + const body = await res.json(); + expect(res.status).toBe(200); + expect(mockSetProjectVisibility).toHaveBeenCalledWith(companyUuid, projectUuid, "shared"); + expect(body.data.visibility).toBe("shared"); + }); + + it("non-owner member gets 403 when changing visibility", async () => { + mockGetAuthContext.mockResolvedValue(memberAuth); + mockClaimOrCanManageProject.mockResolvedValue(false); + + const res = await PATCH( + makeRequest(`/api/projects/${projectUuid}`, { + method: "PATCH", + body: JSON.stringify({ visibility: "shared" }), + }), + makeContext(projectUuid) + ); + expect(res.status).toBe(403); + expect(mockSetProjectVisibility).not.toHaveBeenCalled(); + }); + + it("returns 404 when project is inaccessible (no leak) before manage check", async () => { + mockGetProject.mockResolvedValue(null); + const res = await PATCH( + makeRequest(`/api/projects/${projectUuid}`, { + method: "PATCH", + body: JSON.stringify({ visibility: "shared" }), + }), + makeContext(projectUuid) + ); + expect(res.status).toBe(404); + expect(mockClaimOrCanManageProject).not.toHaveBeenCalled(); + }); + + it("rejects invalid visibility value with 422", async () => { + const res = await PATCH( + makeRequest(`/api/projects/${projectUuid}`, { + method: "PATCH", + body: JSON.stringify({ visibility: "bogus" }), + }), + makeContext(projectUuid) + ); + expect(res.status).toBe(422); + }); +}); + +describe("DELETE /api/projects/[uuid] — manage gating", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetAuthContext.mockResolvedValue(ownerAuth); + mockGetProject.mockResolvedValue(projectRecord); + mockClaimOrCanManageProject.mockResolvedValue(true); + mockDeleteProject.mockResolvedValue(true); + }); + + it("owner can delete the project", async () => { + const res = await DELETE( + makeRequest(`/api/projects/${projectUuid}`, { method: "DELETE" }), + makeContext(projectUuid) + ); + expect(res.status).toBe(200); + expect(mockDeleteProject).toHaveBeenCalledWith(companyUuid, projectUuid); + }); + + it("non-owner member gets 403", async () => { + mockGetAuthContext.mockResolvedValue(memberAuth); + mockClaimOrCanManageProject.mockResolvedValue(false); + + const res = await DELETE( + makeRequest(`/api/projects/${projectUuid}`, { method: "DELETE" }), + makeContext(projectUuid) + ); + expect(res.status).toBe(403); + expect(mockDeleteProject).not.toHaveBeenCalled(); + }); + + it("returns 404 when project is inaccessible (no leak)", async () => { + mockGetProject.mockResolvedValue(null); + const res = await DELETE( + makeRequest(`/api/projects/${projectUuid}`, { method: "DELETE" }), + makeContext(projectUuid) + ); + expect(res.status).toBe(404); + expect(mockClaimOrCanManageProject).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/projects/[uuid]/activity/route.ts b/src/app/api/projects/[uuid]/activity/route.ts index 39226c50..7828716c 100644 --- a/src/app/api/projects/[uuid]/activity/route.ts +++ b/src/app/api/projects/[uuid]/activity/route.ts @@ -7,6 +7,7 @@ import { prisma } from "@/lib/prisma"; import { withErrorHandler, parsePagination } from "@/lib/api-handler"; import { paginated, errors } from "@/lib/api-response"; import { getAuthContext, checkAgentPermission } from "@/lib/auth"; +import { canAccessProject } from "@/lib/authz/project-access"; type RouteContext = { params: Promise<{ uuid: string }> }; @@ -23,18 +24,13 @@ export const GET = withErrorHandler<{ uuid: string }>( const { uuid: projectUuid } = await context.params; const { page, pageSize, skip, take } = parsePagination(request); - // Find project (query by UUID) - const project = await prisma.project.findFirst({ - where: { uuid: projectUuid, companyUuid: auth.companyUuid }, - select: { uuid: true }, - }); - - if (!project) { + // Must be able to access the project; otherwise hide its existence (404). + if (!(await canAccessProject(auth, projectUuid))) { return errors.notFound("Project"); } const where = { - projectUuid: project.uuid, + projectUuid, companyUuid: auth.companyUuid, }; diff --git a/src/app/api/projects/[uuid]/available/route.ts b/src/app/api/projects/[uuid]/available/route.ts index 6871c240..2486de0a 100644 --- a/src/app/api/projects/[uuid]/available/route.ts +++ b/src/app/api/projects/[uuid]/available/route.ts @@ -24,7 +24,7 @@ export const GET = withErrorHandler<{ uuid: string }>( const { uuid: projectUuid } = await context.params; // Find project - const project = await getProjectByUuid(auth.companyUuid, projectUuid); + const project = await getProjectByUuid(auth.companyUuid, projectUuid, auth); if (!project) { return errors.notFound("Project"); } diff --git a/src/app/api/projects/[uuid]/documents/route.ts b/src/app/api/projects/[uuid]/documents/route.ts index ab1dea22..b2f168f2 100644 --- a/src/app/api/projects/[uuid]/documents/route.ts +++ b/src/app/api/projects/[uuid]/documents/route.ts @@ -29,7 +29,7 @@ export const GET = withErrorHandler<{ uuid: string }>( const typeFilter = url.searchParams.get("type") || undefined; // Validate project exists - if (!(await projectExists(auth.companyUuid, projectUuid))) { + if (!(await projectExists(auth.companyUuid, projectUuid, auth))) { return errors.notFound("Project"); } @@ -39,6 +39,7 @@ export const GET = withErrorHandler<{ uuid: string }>( skip, take, type: typeFilter, + auth, }); return paginated(documents, page, pageSize, total); @@ -65,7 +66,7 @@ export const POST = withErrorHandler<{ uuid: string }>( const { uuid: projectUuid } = await context.params; // Validate project exists - if (!(await projectExists(auth.companyUuid, projectUuid))) { + if (!(await projectExists(auth.companyUuid, projectUuid, auth))) { return errors.notFound("Project"); } @@ -90,7 +91,7 @@ export const POST = withErrorHandler<{ uuid: string }>( title: body.title.trim(), content: body.content?.trim() || null, createdByUuid: auth.actorUuid, - }); + }, auth); return success(document); } diff --git a/src/app/api/projects/[uuid]/group/route.ts b/src/app/api/projects/[uuid]/group/route.ts index 5ae025d1..bb53e440 100644 --- a/src/app/api/projects/[uuid]/group/route.ts +++ b/src/app/api/projects/[uuid]/group/route.ts @@ -6,6 +6,7 @@ import { withErrorHandler, parseBody } from "@/lib/api-handler"; import { success, errors } from "@/lib/api-response"; import { getAuthContext, isUser, isAgent, hasPermission } from "@/lib/auth"; import { moveProjectToGroup } from "@/services/project-group.service"; +import { canAccessProject } from "@/lib/authz/project-access"; // PATCH /api/projects/[uuid]/group export const PATCH = withErrorHandler( @@ -21,6 +22,12 @@ export const PATCH = withErrorHandler( } const { uuid } = await context.params; + + // Must be able to access the project; otherwise hide its existence (404). + if (!(await canAccessProject(auth, uuid))) { + return errors.notFound("Project"); + } + const body = await parseBody<{ groupUuid: string | null }>(request); const result = await moveProjectToGroup( diff --git a/src/app/api/projects/[uuid]/ideas/route.ts b/src/app/api/projects/[uuid]/ideas/route.ts index 104c5efb..f9db368f 100644 --- a/src/app/api/projects/[uuid]/ideas/route.ts +++ b/src/app/api/projects/[uuid]/ideas/route.ts @@ -29,7 +29,7 @@ export const GET = withErrorHandler<{ uuid: string }>( const statusFilter = url.searchParams.get("status") || undefined; // Validate project exists - if (!(await projectExists(auth.companyUuid, projectUuid))) { + if (!(await projectExists(auth.companyUuid, projectUuid, auth))) { return errors.notFound("Project"); } @@ -39,6 +39,7 @@ export const GET = withErrorHandler<{ uuid: string }>( skip, take, status: statusFilter, + auth, }); return paginated(ideas, page, pageSize, total); @@ -65,7 +66,7 @@ export const POST = withErrorHandler<{ uuid: string }>( const { uuid: projectUuid } = await context.params; // Validate project exists - if (!(await projectExists(auth.companyUuid, projectUuid))) { + if (!(await projectExists(auth.companyUuid, projectUuid, auth))) { return errors.notFound("Project"); } @@ -87,7 +88,7 @@ export const POST = withErrorHandler<{ uuid: string }>( content: body.content?.trim() || null, attachments: body.attachments, createdByUuid: auth.actorUuid, - }); + }, auth); return success(idea); } diff --git a/src/app/api/projects/[uuid]/ideas/tracker/route.ts b/src/app/api/projects/[uuid]/ideas/tracker/route.ts index 46002439..b370fee0 100644 --- a/src/app/api/projects/[uuid]/ideas/tracker/route.ts +++ b/src/app/api/projects/[uuid]/ideas/tracker/route.ts @@ -22,11 +22,11 @@ export const GET = withErrorHandler<{ uuid: string }>( const { uuid: projectUuid } = await context.params; - if (!(await projectExists(auth.companyUuid, projectUuid))) { + if (!(await projectExists(auth.companyUuid, projectUuid, auth))) { return errors.notFound("Project"); } - const result = await getTrackerGroups(auth.companyUuid, projectUuid); + const result = await getTrackerGroups(auth.companyUuid, projectUuid, auth); return success(result); } ); diff --git a/src/app/api/projects/[uuid]/members/__tests__/route.test.ts b/src/app/api/projects/[uuid]/members/__tests__/route.test.ts new file mode 100644 index 00000000..89f671bb --- /dev/null +++ b/src/app/api/projects/[uuid]/members/__tests__/route.test.ts @@ -0,0 +1,247 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +// Mock dependencies +const mockListProjectMembers = vi.fn(); +const mockAddProjectMember = vi.fn(); +const mockRemoveProjectMember = vi.fn(); +const mockGetAuthContext = vi.fn(); +const mockCanAccessProject = vi.fn(); +const mockClaimOrCanManageProject = vi.fn(); + +vi.mock("@/services/project.service", () => ({ + listProjectMembers: (...args: unknown[]) => mockListProjectMembers(...args), + addProjectMember: (...args: unknown[]) => mockAddProjectMember(...args), + removeProjectMember: (...args: unknown[]) => mockRemoveProjectMember(...args), +})); + +vi.mock("@/lib/authz/project-access", () => ({ + canAccessProject: (...args: unknown[]) => mockCanAccessProject(...args), + claimOrCanManageProject: (...args: unknown[]) => mockClaimOrCanManageProject(...args), +})); + +vi.mock("@/lib/auth", () => ({ + getAuthContext: (...args: unknown[]) => mockGetAuthContext(...args), + isUser: (auth: { type: string }) => auth.type === "user", + isAgent: (auth: { type: string }) => auth.type === "agent", + hasPermission: (auth: { permissions?: string[] }, perm: string) => + auth.permissions?.includes(perm) ?? false, + checkAgentPermission: (auth: { type: string; permissions?: string[] }, perm: string) => { + if (auth.type === "agent" && !(auth.permissions?.includes(perm) ?? false)) { + return new Response( + JSON.stringify({ success: false, error: { message: `Missing permission: ${perm}` } }), + { status: 403 } + ); + } + return null; + }, +})); + +import { GET, POST, DELETE } from "@/app/api/projects/[uuid]/members/route"; + +const companyUuid = "company-0000-0000-0000-000000000001"; +const projectUuid = "project-0000-0000-0000-000000000001"; +const ownerAuth = { type: "user", companyUuid, actorUuid: "owner-uuid-1" }; +const memberAuth = { type: "user", companyUuid, actorUuid: "member-uuid-2" }; + +function makeRequest( + url: string, + init?: ConstructorParameters[1] +): NextRequest { + return new NextRequest(new URL(url, "http://localhost:3000"), init); +} + +function makeContext(uuid: string) { + return { params: Promise.resolve({ uuid }) }; +} + +describe("GET /api/projects/[uuid]/members", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetAuthContext.mockResolvedValue(ownerAuth); + mockCanAccessProject.mockResolvedValue(true); + mockListProjectMembers.mockResolvedValue([]); + }); + + it("lists members for a member who can access the project", async () => { + const members = [ + { uuid: "m1", memberType: "user", memberUuid: "owner-uuid-1", role: "owner", createdAt: "x" }, + ]; + mockGetAuthContext.mockResolvedValue(memberAuth); + mockListProjectMembers.mockResolvedValue(members); + + const res = await GET(makeRequest(`/api/projects/${projectUuid}/members`), makeContext(projectUuid)); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.success).toBe(true); + expect(body.data.members).toEqual(members); + }); + + it("returns 401 when not authenticated", async () => { + mockGetAuthContext.mockResolvedValue(null); + const res = await GET(makeRequest(`/api/projects/${projectUuid}/members`), makeContext(projectUuid)); + expect(res.status).toBe(401); + }); + + it("returns 404 (not 403) for an inaccessible project — no existence leak", async () => { + mockCanAccessProject.mockResolvedValue(false); + const res = await GET(makeRequest(`/api/projects/${projectUuid}/members`), makeContext(projectUuid)); + expect(res.status).toBe(404); + }); +}); + +describe("POST /api/projects/[uuid]/members", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetAuthContext.mockResolvedValue(ownerAuth); + mockCanAccessProject.mockResolvedValue(true); + mockClaimOrCanManageProject.mockResolvedValue(true); + mockAddProjectMember.mockResolvedValue({ + uuid: "m2", + memberType: "user", + memberUuid: "new-user", + role: "member", + createdAt: "x", + }); + }); + + it("owner can add a member", async () => { + const res = await POST( + makeRequest(`/api/projects/${projectUuid}/members`, { + method: "POST", + body: JSON.stringify({ memberType: "user", memberUuid: "new-user" }), + }), + makeContext(projectUuid) + ); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.success).toBe(true); + expect(mockAddProjectMember).toHaveBeenCalledWith(companyUuid, projectUuid, "user", "new-user"); + }); + + it("non-owner member gets 403", async () => { + mockGetAuthContext.mockResolvedValue(memberAuth); + mockClaimOrCanManageProject.mockResolvedValue(false); + + const res = await POST( + makeRequest(`/api/projects/${projectUuid}/members`, { + method: "POST", + body: JSON.stringify({ memberType: "user", memberUuid: "new-user" }), + }), + makeContext(projectUuid) + ); + + expect(res.status).toBe(403); + expect(mockAddProjectMember).not.toHaveBeenCalled(); + }); + + it("returns 404 for an inaccessible project before checking management", async () => { + mockCanAccessProject.mockResolvedValue(false); + + const res = await POST( + makeRequest(`/api/projects/${projectUuid}/members`, { + method: "POST", + body: JSON.stringify({ memberType: "user", memberUuid: "new-user" }), + }), + makeContext(projectUuid) + ); + + expect(res.status).toBe(404); + expect(mockClaimOrCanManageProject).not.toHaveBeenCalled(); + }); + + it("returns 422 for invalid memberType", async () => { + const res = await POST( + makeRequest(`/api/projects/${projectUuid}/members`, { + method: "POST", + body: JSON.stringify({ memberType: "bogus", memberUuid: "new-user" }), + }), + makeContext(projectUuid) + ); + + expect(res.status).toBe(422); + }); + + it("returns 422 for missing memberUuid", async () => { + const res = await POST( + makeRequest(`/api/projects/${projectUuid}/members`, { + method: "POST", + body: JSON.stringify({ memberType: "agent" }), + }), + makeContext(projectUuid) + ); + + expect(res.status).toBe(422); + }); +}); + +describe("DELETE /api/projects/[uuid]/members", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetAuthContext.mockResolvedValue(ownerAuth); + mockCanAccessProject.mockResolvedValue(true); + mockClaimOrCanManageProject.mockResolvedValue(true); + mockRemoveProjectMember.mockResolvedValue(true); + }); + + it("owner can remove a member via query params", async () => { + const res = await DELETE( + makeRequest( + `/api/projects/${projectUuid}/members?memberType=user&memberUuid=victim`, + { method: "DELETE" } + ), + makeContext(projectUuid) + ); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.success).toBe(true); + expect(mockRemoveProjectMember).toHaveBeenCalledWith(companyUuid, projectUuid, "user", "victim"); + }); + + it("non-owner member gets 403", async () => { + mockGetAuthContext.mockResolvedValue(memberAuth); + mockClaimOrCanManageProject.mockResolvedValue(false); + + const res = await DELETE( + makeRequest( + `/api/projects/${projectUuid}/members?memberType=user&memberUuid=victim`, + { method: "DELETE" } + ), + makeContext(projectUuid) + ); + + expect(res.status).toBe(403); + expect(mockRemoveProjectMember).not.toHaveBeenCalled(); + }); + + it("returns 404 for an inaccessible project", async () => { + mockCanAccessProject.mockResolvedValue(false); + + const res = await DELETE( + makeRequest( + `/api/projects/${projectUuid}/members?memberType=user&memberUuid=victim`, + { method: "DELETE" } + ), + makeContext(projectUuid) + ); + + expect(res.status).toBe(404); + }); + + it("returns 404 when the member does not exist", async () => { + mockRemoveProjectMember.mockResolvedValue(false); + + const res = await DELETE( + makeRequest( + `/api/projects/${projectUuid}/members?memberType=user&memberUuid=ghost`, + { method: "DELETE" } + ), + makeContext(projectUuid) + ); + + expect(res.status).toBe(404); + }); +}); diff --git a/src/app/api/projects/[uuid]/members/route.ts b/src/app/api/projects/[uuid]/members/route.ts new file mode 100644 index 00000000..4558ea35 --- /dev/null +++ b/src/app/api/projects/[uuid]/members/route.ts @@ -0,0 +1,161 @@ +// src/app/api/projects/[uuid]/members/route.ts +// Project Members API - List, Add, Remove (Project Visibility — Tech Design §5) +// UUID-Based Architecture: All operations use UUIDs +// +// Leak rule: an inaccessible project must look like it does not exist (404). +// An accessible project the actor cannot MANAGE (i.e. is not the owner) yields +// 403 on mutations. Listing members only requires access (read). + +import { NextRequest } from "next/server"; +import { withErrorHandler, parseBody } from "@/lib/api-handler"; +import { success, errors } from "@/lib/api-response"; +import { getAuthContext, isUser, isAgent, hasPermission, checkAgentPermission } from "@/lib/auth"; +import { + listProjectMembers, + addProjectMember, + removeProjectMember, +} from "@/services/project.service"; +import { canAccessProject, claimOrCanManageProject } from "@/lib/authz/project-access"; + +type RouteContext = { params: Promise<{ uuid: string }> }; + +type MemberType = "user" | "agent"; + +function isMemberType(value: unknown): value is MemberType { + return value === "user" || value === "agent"; +} + +// GET /api/projects/[uuid]/members - List project members +export const GET = withErrorHandler<{ uuid: string }>( + async (request: NextRequest, context: RouteContext) => { + const auth = await getAuthContext(request); + if (!auth) { + return errors.unauthorized(); + } + const denied = checkAgentPermission(auth, "project:read"); + if (denied) return denied; + + const { uuid: projectUuid } = await context.params; + + // Must be able to access the project; otherwise hide its existence. + if (!(await canAccessProject(auth, projectUuid))) { + return errors.notFound("Project"); + } + + const members = await listProjectMembers(auth.companyUuid, projectUuid); + return success({ members }); + } +); + +// POST /api/projects/[uuid]/members - Add a member (owner-only) +export const POST = withErrorHandler<{ uuid: string }>( + async (request: NextRequest, context: RouteContext) => { + const auth = await getAuthContext(request); + if (!auth) { + return errors.unauthorized(); + } + + // Managing members requires project:write for agents, or a human user. + if (isAgent(auth)) { + if (!hasPermission(auth, "project:write")) { + return errors.forbidden("Missing permission: project:write"); + } + } else if (!isUser(auth)) { + return errors.forbidden("Only users or permitted agents can manage project members"); + } + + const { uuid: projectUuid } = await context.params; + + // Leak rule: inaccessible -> 404; accessible but not owner -> 403. + if (!(await canAccessProject(auth, projectUuid))) { + return errors.notFound("Project"); + } + if (!(await claimOrCanManageProject(auth, projectUuid))) { + return errors.forbidden("Only the project owner can manage members"); + } + + const body = await parseBody<{ + memberType?: string; + memberUuid?: string; + }>(request); + + if (!isMemberType(body.memberType)) { + return errors.validationError({ memberType: "memberType must be 'user' or 'agent'" }); + } + if (!body.memberUuid || body.memberUuid.trim() === "") { + return errors.validationError({ memberUuid: "memberUuid is required" }); + } + + const member = await addProjectMember( + auth.companyUuid, + projectUuid, + body.memberType, + body.memberUuid.trim() + ); + if (!member) { + return errors.notFound("Project"); + } + + return success(member); + } +); + +// DELETE /api/projects/[uuid]/members?memberType=...&memberUuid=... - Remove a member (owner-only) +export const DELETE = withErrorHandler<{ uuid: string }>( + async (request: NextRequest, context: RouteContext) => { + const auth = await getAuthContext(request); + if (!auth) { + return errors.unauthorized(); + } + + if (isAgent(auth)) { + if (!hasPermission(auth, "project:write")) { + return errors.forbidden("Missing permission: project:write"); + } + } else if (!isUser(auth)) { + return errors.forbidden("Only users or permitted agents can manage project members"); + } + + const { uuid: projectUuid } = await context.params; + + // Leak rule: inaccessible -> 404; accessible but not owner -> 403. + if (!(await canAccessProject(auth, projectUuid))) { + return errors.notFound("Project"); + } + if (!(await claimOrCanManageProject(auth, projectUuid))) { + return errors.forbidden("Only the project owner can manage members"); + } + + // Accept memberType + memberUuid from query params, falling back to the body. + const url = new URL(request.url); + let memberType: string | null = url.searchParams.get("memberType"); + let memberUuid: string | null = url.searchParams.get("memberUuid"); + + if (!memberType || !memberUuid) { + const body = await parseBody<{ memberType?: string; memberUuid?: string }>(request).catch( + () => ({} as { memberType?: string; memberUuid?: string }) + ); + memberType = memberType ?? body.memberType ?? null; + memberUuid = memberUuid ?? body.memberUuid ?? null; + } + + if (!isMemberType(memberType)) { + return errors.validationError({ memberType: "memberType must be 'user' or 'agent'" }); + } + if (!memberUuid || memberUuid.trim() === "") { + return errors.validationError({ memberUuid: "memberUuid is required" }); + } + + const removed = await removeProjectMember( + auth.companyUuid, + projectUuid, + memberType, + memberUuid.trim() + ); + if (!removed) { + return errors.notFound("Member"); + } + + return success({ removed: true }); + } +); diff --git a/src/app/api/projects/[uuid]/proposals/[proposalUuid]/validate/route.ts b/src/app/api/projects/[uuid]/proposals/[proposalUuid]/validate/route.ts index 6f640024..b2ae7f06 100644 --- a/src/app/api/projects/[uuid]/proposals/[proposalUuid]/validate/route.ts +++ b/src/app/api/projects/[uuid]/proposals/[proposalUuid]/validate/route.ts @@ -20,7 +20,7 @@ export const GET = withErrorHandler<{ uuid: string; proposalUuid: string }>( if (denied) return denied; const { proposalUuid } = await context.params; - const result = await validateProposal(auth.companyUuid, proposalUuid); + const result = await validateProposal(auth.companyUuid, proposalUuid, auth); return success(result); } ); diff --git a/src/app/api/projects/[uuid]/proposals/route.ts b/src/app/api/projects/[uuid]/proposals/route.ts index 39cf1bfd..16010c3e 100644 --- a/src/app/api/projects/[uuid]/proposals/route.ts +++ b/src/app/api/projects/[uuid]/proposals/route.ts @@ -30,7 +30,7 @@ export const GET = withErrorHandler<{ uuid: string }>( const statusFilter = url.searchParams.get("status") || undefined; // Validate project exists - if (!(await projectExists(auth.companyUuid, projectUuid))) { + if (!(await projectExists(auth.companyUuid, projectUuid, auth))) { return errors.notFound("Project"); } @@ -40,6 +40,7 @@ export const GET = withErrorHandler<{ uuid: string }>( skip, take, status: statusFilter, + auth, }); return paginated(proposals, page, pageSize, total); @@ -66,7 +67,7 @@ export const POST = withErrorHandler<{ uuid: string }>( const { uuid: projectUuid } = await context.params; // Validate project exists - if (!(await projectExists(auth.companyUuid, projectUuid))) { + if (!(await projectExists(auth.companyUuid, projectUuid, auth))) { return errors.notFound("Project"); } @@ -104,7 +105,7 @@ export const POST = withErrorHandler<{ uuid: string }>( taskDrafts: body.taskDrafts, createdByUuid: auth.actorUuid, createdByType, - }); + }, auth); return success(proposal); } diff --git a/src/app/api/projects/[uuid]/proposals/summary/route.ts b/src/app/api/projects/[uuid]/proposals/summary/route.ts index a704f30c..de9f2837 100644 --- a/src/app/api/projects/[uuid]/proposals/summary/route.ts +++ b/src/app/api/projects/[uuid]/proposals/summary/route.ts @@ -23,11 +23,11 @@ export const GET = withErrorHandler<{ uuid: string }>( const { uuid: projectUuid } = await context.params; // Validate project exists and belongs to company - if (!(await projectExists(auth.companyUuid, projectUuid))) { + if (!(await projectExists(auth.companyUuid, projectUuid, auth))) { return errors.notFound("Project"); } - const data = await getProjectProposals(auth.companyUuid, projectUuid); + const data = await getProjectProposals(auth.companyUuid, projectUuid, auth); return success(data); } diff --git a/src/app/api/projects/[uuid]/route.ts b/src/app/api/projects/[uuid]/route.ts index 1b858d89..8b2b6c81 100644 --- a/src/app/api/projects/[uuid]/route.ts +++ b/src/app/api/projects/[uuid]/route.ts @@ -10,7 +10,9 @@ import { getProject, updateProject, deleteProject, + setProjectVisibility, } from "@/services/project.service"; +import { claimOrCanManageProject } from "@/lib/authz/project-access"; type RouteContext = { params: Promise<{ uuid: string }> }; @@ -24,7 +26,7 @@ export const GET = withErrorHandler(async (request: NextRequest, context: RouteC if (denied) return denied; const { uuid } = await context.params; - const project = await getProject(auth.companyUuid, uuid); + const project = await getProject(auth.companyUuid, uuid, auth); if (!project) { return errors.notFound("Project"); @@ -35,6 +37,9 @@ export const GET = withErrorHandler(async (request: NextRequest, context: RouteC name: project.name, description: project.description, groupUuid: project.groupUuid, + visibility: project.visibility, + ownerType: project.ownerType, + ownerUuid: project.ownerUuid, createdAt: project.createdAt.toISOString(), updatedAt: project.updatedAt.toISOString(), counts: { @@ -65,9 +70,21 @@ export const PATCH = withErrorHandler(async (request: NextRequest, context: Rout const { uuid } = await context.params; + // Leak rule: an inaccessible project must look like it does not exist (404), + // whereas an accessible project the actor cannot manage yields 403. We probe + // accessibility via the gated getProject first, then require management rights. + const existing = await getProject(auth.companyUuid, uuid, auth); + if (!existing) { + return errors.notFound("Project"); + } + if (!(await claimOrCanManageProject(auth, uuid))) { + return errors.forbidden("Only the project owner can manage this project"); + } + const body = await parseBody<{ name?: string; description?: string; + visibility?: "shared" | "private"; }>(request); const updateData: { name?: string; description?: string | null } = {}; @@ -83,15 +100,32 @@ export const PATCH = withErrorHandler(async (request: NextRequest, context: Rout updateData.description = body.description?.trim() || null; } - const project = await updateProject(auth.companyUuid, uuid, updateData); - if (!project) { - return errors.notFound("Project"); + // Apply visibility change via the dedicated service (owner-only, already gated above). + if (body.visibility !== undefined) { + if (!["shared", "private"].includes(body.visibility)) { + return errors.validationError({ visibility: "Visibility must be 'shared' or 'private'" }); + } + const updated = await setProjectVisibility(auth.companyUuid, uuid, body.visibility); + if (!updated) { + return errors.notFound("Project"); + } + } + + // Apply name/description updates if any were provided. + let project = existing; + if (Object.keys(updateData).length > 0) { + const result = await updateProject(auth.companyUuid, uuid, updateData); + if (!result) { + return errors.notFound("Project"); + } + project = { ...existing, ...result }; } return success({ uuid: project.uuid, name: project.name, description: project.description, + visibility: body.visibility ?? existing.visibility, createdAt: project.createdAt.toISOString(), updatedAt: project.updatedAt.toISOString(), }); @@ -115,6 +149,15 @@ export const DELETE = withErrorHandler(async (request: NextRequest, context: Rou const { uuid } = await context.params; + // Leak rule: inaccessible project -> 404; accessible but not the owner -> 403. + const existing = await getProject(auth.companyUuid, uuid, auth); + if (!existing) { + return errors.notFound("Project"); + } + if (!(await claimOrCanManageProject(auth, uuid))) { + return errors.forbidden("Only the project owner can delete this project"); + } + const deleted = await deleteProject(auth.companyUuid, uuid); if (!deleted) { return errors.notFound("Project"); diff --git a/src/app/api/projects/[uuid]/stats/route.ts b/src/app/api/projects/[uuid]/stats/route.ts index d8f5523b..39d754d8 100644 --- a/src/app/api/projects/[uuid]/stats/route.ts +++ b/src/app/api/projects/[uuid]/stats/route.ts @@ -22,21 +22,26 @@ export const GET = withErrorHandler<{ uuid: string }>( const { uuid: projectUuid } = await context.params; - const project = await getProject(auth.companyUuid, projectUuid); + const project = await getProject(auth.companyUuid, projectUuid, auth); if (!project) { return errors.notFound("Project"); } const [stats, { activities }] = await Promise.all([ - getProjectStats(auth.companyUuid, projectUuid), + getProjectStats(auth.companyUuid, projectUuid, auth), listActivitiesWithActorNames({ companyUuid: auth.companyUuid, projectUuid, skip: 0, take: 5, + auth, }), ]); + if (!stats) { + return errors.notFound("Project"); + } + return success({ stats, recentActivities: activities }); } ); diff --git a/src/app/api/projects/[uuid]/tasks/dependencies/route.ts b/src/app/api/projects/[uuid]/tasks/dependencies/route.ts index 5892f36d..08f25b02 100644 --- a/src/app/api/projects/[uuid]/tasks/dependencies/route.ts +++ b/src/app/api/projects/[uuid]/tasks/dependencies/route.ts @@ -23,11 +23,11 @@ export const GET = withErrorHandler<{ uuid: string }>( const { uuid: projectUuid } = await context.params; // Validate project exists - if (!(await projectExists(auth.companyUuid, projectUuid))) { + if (!(await projectExists(auth.companyUuid, projectUuid, auth))) { return errors.notFound("Project"); } - const dag = await getProjectTaskDependencies(auth.companyUuid, projectUuid); + const dag = await getProjectTaskDependencies(auth.companyUuid, projectUuid, auth); return success(dag); } ); diff --git a/src/app/api/projects/[uuid]/tasks/route.ts b/src/app/api/projects/[uuid]/tasks/route.ts index 3f02fc10..aa44b8cf 100644 --- a/src/app/api/projects/[uuid]/tasks/route.ts +++ b/src/app/api/projects/[uuid]/tasks/route.ts @@ -31,7 +31,7 @@ export const GET = withErrorHandler<{ uuid: string }>( const proposalUuids = url.searchParams.get("proposalUuids")?.split(",").filter(Boolean); // Validate project exists - if (!(await projectExists(auth.companyUuid, projectUuid))) { + if (!(await projectExists(auth.companyUuid, projectUuid, auth))) { return errors.notFound("Project"); } @@ -43,6 +43,7 @@ export const GET = withErrorHandler<{ uuid: string }>( status: statusFilter, priority: priorityFilter, proposalUuids, + auth, }); return paginated(tasks, page, pageSize, total); @@ -69,7 +70,7 @@ export const POST = withErrorHandler<{ uuid: string }>( const { uuid: projectUuid } = await context.params; // Validate project exists - if (!(await projectExists(auth.companyUuid, projectUuid))) { + if (!(await projectExists(auth.companyUuid, projectUuid, auth))) { return errors.notFound("Project"); } @@ -110,7 +111,7 @@ export const POST = withErrorHandler<{ uuid: string }>( priority, storyPoints: storyPoints || null, createdByUuid: auth.actorUuid, - }); + }, auth); return success(task); } diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts index 3ab7ca14..66d5afb4 100644 --- a/src/app/api/projects/route.ts +++ b/src/app/api/projects/route.ts @@ -7,6 +7,7 @@ import { prisma } from "@/lib/prisma"; import { withErrorHandler, parseBody, parsePagination } from "@/lib/api-handler"; import { success, paginated, errors } from "@/lib/api-response"; import { getAuthContext, isUser, isAgent, hasPermission, checkAgentPermission } from "@/lib/auth"; +import { listProjectsWithStats, createProject } from "@/services/project.service"; // GET /api/projects - List Projects export const GET = withErrorHandler(async (request: NextRequest) => { @@ -19,37 +20,14 @@ export const GET = withErrorHandler(async (request: NextRequest) => { const { page, pageSize, skip, take } = parsePagination(request); - const [projects, total] = await Promise.all([ - prisma.project.findMany({ - where: { companyUuid: auth.companyUuid }, - skip, - take, - orderBy: { updatedAt: "desc" }, - select: { - uuid: true, - name: true, - description: true, - groupUuid: true, - createdAt: true, - updatedAt: true, - _count: { - select: { - ideas: true, - documents: true, - tasks: true, - proposals: true, - }, - }, - tasks: { - where: { status: { in: ["done", "closed"] } }, - select: { uuid: true }, - }, - }, - }), - prisma.project.count({ - where: { companyUuid: auth.companyUuid }, - }), - ]); + // Restrict results to the projects this actor can access (service injects the + // accessible-projects filter via `auth`). + const { projects, total } = await listProjectsWithStats({ + companyUuid: auth.companyUuid, + skip, + take, + auth, + }); // Transform to API response format const data = projects.map((p) => ({ @@ -57,13 +35,16 @@ export const GET = withErrorHandler(async (request: NextRequest) => { name: p.name, description: p.description, groupUuid: p.groupUuid, + visibility: p.visibility, + ownerType: p.ownerType, + ownerUuid: p.ownerUuid, createdAt: p.createdAt.toISOString(), updatedAt: p.updatedAt.toISOString(), counts: { ideas: p._count.ideas, documents: p._count.documents, tasks: p._count.tasks, - doneTasks: p.tasks.length, + doneTasks: p.tasksDone, proposals: p._count.proposals, }, })); @@ -91,6 +72,8 @@ export const POST = withErrorHandler(async (request: NextRequest) => { name: string; description?: string; groupUuid?: string; + visibility?: "shared" | "private"; + memberUuids?: { memberType: "user" | "agent"; memberUuid: string }[]; }>(request); // Validate required fields @@ -98,6 +81,11 @@ export const POST = withErrorHandler(async (request: NextRequest) => { return errors.validationError({ name: "Name is required" }); } + // Validate visibility if provided + if (body.visibility !== undefined && !["shared", "private"].includes(body.visibility)) { + return errors.validationError({ visibility: "Visibility must be 'shared' or 'private'" }); + } + // Validate groupUuid belongs to the same company if provided if (body.groupUuid) { const group = await prisma.projectGroup.findFirst({ @@ -108,26 +96,31 @@ export const POST = withErrorHandler(async (request: NextRequest) => { } } - const project = await prisma.project.create({ - data: { - companyUuid: auth.companyUuid, - name: body.name.trim(), - description: body.description?.trim() || null, - groupUuid: body.groupUuid || null, - }, - select: { - uuid: true, - name: true, - description: true, - createdAt: true, - updatedAt: true, - }, + // The owner is the acting actor. super_admin has no actorUuid, so the project + // is created ownerless (only super_admin / shared visibility grants access). + const isOwnerActor = auth.type === "user" || auth.type === "agent"; + const ownerType = isOwnerActor ? auth.type : null; + const ownerUuid = isOwnerActor ? auth.actorUuid : null; + + const project = await createProject({ + companyUuid: auth.companyUuid, + name: body.name.trim(), + description: body.description?.trim() || null, + groupUuid: body.groupUuid || null, + visibility: body.visibility, // service defaults to "private" when undefined + ownerType, + ownerUuid, + memberUuids: body.memberUuids, }); return success({ uuid: project.uuid, name: project.name, description: project.description, + groupUuid: project.groupUuid, + visibility: project.visibility, + ownerType: project.ownerType, + ownerUuid: project.ownerUuid, createdAt: project.createdAt.toISOString(), updatedAt: project.updatedAt.toISOString(), }); diff --git a/src/app/api/proposals/[uuid]/approve/route.ts b/src/app/api/proposals/[uuid]/approve/route.ts index 22a1bbf1..1bb1a3c1 100644 --- a/src/app/api/proposals/[uuid]/approve/route.ts +++ b/src/app/api/proposals/[uuid]/approve/route.ts @@ -48,7 +48,8 @@ export const POST = withErrorHandler<{ uuid: string }>( proposal.uuid, auth.companyUuid, auth.actorUuid, - body.reviewNote + body.reviewNote, + auth ); await createActivity({ diff --git a/src/app/api/proposals/[uuid]/close/route.ts b/src/app/api/proposals/[uuid]/close/route.ts index 5b7e0815..7f99a07b 100644 --- a/src/app/api/proposals/[uuid]/close/route.ts +++ b/src/app/api/proposals/[uuid]/close/route.ts @@ -53,7 +53,8 @@ export const POST = withErrorHandler<{ uuid: string }>( const updated = await closeProposal( proposal.uuid, auth.actorUuid, - body.reviewNote.trim() + body.reviewNote.trim(), + auth ); return success(updated); diff --git a/src/app/api/proposals/[uuid]/reject/route.ts b/src/app/api/proposals/[uuid]/reject/route.ts index 2ede06e8..a4daac1e 100644 --- a/src/app/api/proposals/[uuid]/reject/route.ts +++ b/src/app/api/proposals/[uuid]/reject/route.ts @@ -54,7 +54,8 @@ export const POST = withErrorHandler<{ uuid: string }>( const updated = await rejectProposal( proposal.uuid, auth.actorUuid, - body.reviewNote.trim() + body.reviewNote.trim(), + auth ); await createActivity({ diff --git a/src/app/api/proposals/[uuid]/revoke/route.ts b/src/app/api/proposals/[uuid]/revoke/route.ts index aea80478..148330e1 100644 --- a/src/app/api/proposals/[uuid]/revoke/route.ts +++ b/src/app/api/proposals/[uuid]/revoke/route.ts @@ -48,7 +48,8 @@ export const POST = withErrorHandler<{ uuid: string }>( proposal.uuid, auth.companyUuid, auth.actorUuid, - body.reviewNote + body.reviewNote, + auth ); await createActivity({ diff --git a/src/app/api/proposals/[uuid]/route.ts b/src/app/api/proposals/[uuid]/route.ts index 412c1fbe..8d198fc1 100644 --- a/src/app/api/proposals/[uuid]/route.ts +++ b/src/app/api/proposals/[uuid]/route.ts @@ -21,7 +21,7 @@ export const GET = withErrorHandler<{ uuid: string }>( if (denied) return denied; const { uuid } = await context.params; - const proposal = await getProposal(auth.companyUuid, uuid); + const proposal = await getProposal(auth.companyUuid, uuid, auth); if (!proposal) { return errors.notFound("Proposal"); diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 41994b01..4c493e1d 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -76,6 +76,7 @@ export const GET = withErrorHandler( scopeUuid, entityTypes, limit, + auth, }); return success(result); diff --git a/src/app/api/tasks/[uuid]/claim/__tests__/route.test.ts b/src/app/api/tasks/[uuid]/claim/__tests__/route.test.ts index d7ddc3b0..df9e027c 100644 --- a/src/app/api/tasks/[uuid]/claim/__tests__/route.test.ts +++ b/src/app/api/tasks/[uuid]/claim/__tests__/route.test.ts @@ -73,6 +73,7 @@ describe("POST /api/tasks/[uuid]/claim — agent selection gating", () => { assigneeUuid: agentUuid, assignedByUuid: userUuid, }), + expect.anything(), ); }); @@ -151,6 +152,7 @@ describe("POST /api/tasks/[uuid]/claim — agent self-claim", () => { assigneeType: "agent", assigneeUuid: agentUuid, }), + expect.anything(), ); }); diff --git a/src/app/api/tasks/[uuid]/claim/route.ts b/src/app/api/tasks/[uuid]/claim/route.ts index 77174619..520e781f 100644 --- a/src/app/api/tasks/[uuid]/claim/route.ts +++ b/src/app/api/tasks/[uuid]/claim/route.ts @@ -92,7 +92,7 @@ export const POST = withErrorHandler<{ uuid: string }>( assigneeType, assigneeUuid, assignedByUuid, - }); + }, auth); return success(updated); } catch (e) { diff --git a/src/app/api/tasks/[uuid]/dependencies/[dependsOnUuid]/route.ts b/src/app/api/tasks/[uuid]/dependencies/[dependsOnUuid]/route.ts index 7ebdab82..5d197293 100644 --- a/src/app/api/tasks/[uuid]/dependencies/[dependsOnUuid]/route.ts +++ b/src/app/api/tasks/[uuid]/dependencies/[dependsOnUuid]/route.ts @@ -27,7 +27,7 @@ export const DELETE = withErrorHandler<{ uuid: string; dependsOnUuid: string }>( return errors.notFound("Task"); } - await removeTaskDependency(auth.companyUuid, uuid, dependsOnUuid); + await removeTaskDependency(auth.companyUuid, uuid, dependsOnUuid, auth); return success({ deleted: true }); } ); diff --git a/src/app/api/tasks/[uuid]/dependencies/route.ts b/src/app/api/tasks/[uuid]/dependencies/route.ts index 7422701b..07d692eb 100644 --- a/src/app/api/tasks/[uuid]/dependencies/route.ts +++ b/src/app/api/tasks/[uuid]/dependencies/route.ts @@ -37,7 +37,7 @@ export const POST = withErrorHandler<{ uuid: string }>( } try { - const dep = await addTaskDependency(auth.companyUuid, uuid, body.dependsOnUuid); + const dep = await addTaskDependency(auth.companyUuid, uuid, body.dependsOnUuid, auth); return success(dep); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; @@ -69,7 +69,7 @@ export const GET = withErrorHandler<{ uuid: string }>( return errors.notFound("Task"); } - const deps = await getTaskDependencies(auth.companyUuid, uuid); + const deps = await getTaskDependencies(auth.companyUuid, uuid, auth); return success(deps); } ); diff --git a/src/app/api/tasks/[uuid]/release/route.ts b/src/app/api/tasks/[uuid]/release/route.ts index 71b23c58..b7e983a0 100644 --- a/src/app/api/tasks/[uuid]/release/route.ts +++ b/src/app/api/tasks/[uuid]/release/route.ts @@ -36,7 +36,7 @@ export const POST = withErrorHandler<{ uuid: string }>( } try { - const updated = await releaseTask(task.uuid); + const updated = await releaseTask(task.uuid, auth); return success(updated); } catch (e) { if (e instanceof NotClaimedError) { diff --git a/src/app/api/tasks/[uuid]/route.ts b/src/app/api/tasks/[uuid]/route.ts index 00c66350..6716191c 100644 --- a/src/app/api/tasks/[uuid]/route.ts +++ b/src/app/api/tasks/[uuid]/route.ts @@ -29,7 +29,7 @@ export const GET = withErrorHandler<{ uuid: string }>( if (denied) return denied; const { uuid } = await context.params; - const task = await getTask(auth.companyUuid, uuid); + const task = await getTask(auth.companyUuid, uuid, auth); if (!task) { return errors.notFound("Task"); @@ -161,7 +161,7 @@ export const PATCH = withErrorHandler<{ uuid: string }>( updateData.status = body.status; } - const updated = await updateTask(task.uuid, updateData); + const updated = await updateTask(task.uuid, updateData, auth); return success(updated); } ); @@ -186,7 +186,7 @@ export const DELETE = withErrorHandler<{ uuid: string }>( return errors.notFound("Task"); } - await deleteTask(task.uuid); + await deleteTask(task.uuid, auth); return success({ deleted: true }); } ); diff --git a/src/components/manage-project-group-dialog.tsx b/src/components/manage-project-group-dialog.tsx index 8fa272ff..fe649d06 100644 --- a/src/components/manage-project-group-dialog.tsx +++ b/src/components/manage-project-group-dialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { @@ -12,8 +12,40 @@ import { import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Settings, Trash2, AlertTriangle } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Settings, + Trash2, + AlertTriangle, + Lock, + Globe, + Plus, + X, + User as UserIcon, + Bot, +} from "lucide-react"; import { authFetch } from "@/lib/auth-client"; +import { isImeComposing } from "@/lib/ime"; + +type Visibility = "shared" | "private"; + +interface GroupMember { + uuid: string; + memberType: "user" | "agent"; + memberUuid: string; + name?: string | null; + role: string | null; + createdAt: string; +} + +interface Mentionable { + type: "user" | "agent"; + uuid: string; + name: string; + email?: string | null; +} interface ManageProjectGroupDialogProps { open: boolean; @@ -22,6 +54,8 @@ interface ManageProjectGroupDialogProps { groupName: string; groupDescription: string | null; projectCount: number; + visibility: Visibility; + isOwner: boolean; onUpdated: () => void; } @@ -32,6 +66,8 @@ export function ManageProjectGroupDialog({ groupName, groupDescription, projectCount, + visibility: initialVisibility, + isOwner, onUpdated, }: ManageProjectGroupDialogProps) { const t = useTranslations("projectGroups"); @@ -43,15 +79,30 @@ export function ManageProjectGroupDialog({ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [deleteProjects, setDeleteProjects] = useState(false); + const [visibility, setVisibility] = useState(initialVisibility); + const [visibilitySaving, setVisibilitySaving] = useState(false); + const [visibilityError, setVisibilityError] = useState(null); + + const [members, setMembers] = useState([]); + const [memberError, setMemberError] = useState(null); + const [search, setSearch] = useState(""); + const [results, setResults] = useState([]); + const [adding, setAdding] = useState(false); + // Reset state when dialog opens - const handleOpenChange = (open: boolean) => { - if (open) { + const handleOpenChange = (next: boolean) => { + if (next) { setName(groupName); setDescription(groupDescription ?? ""); setShowDeleteConfirm(false); setDeleteProjects(false); + setVisibility(initialVisibility); + setVisibilityError(null); + setMemberError(null); + setSearch(""); + setResults([]); } - onOpenChange(open); + onOpenChange(next); }; const handleSave = async () => { @@ -91,6 +142,129 @@ export function ManageProjectGroupDialog({ } }; + const fetchMembers = useCallback(async () => { + try { + const res = await authFetch(`/api/project-groups/${groupUuid}/members`); + const json = await res.json(); + if (json.success) { + setMembers(json.data?.members ?? []); + } + } catch { + // silently ignore — surfaced via empty list + } + }, [groupUuid]); + + // Load members when the dialog opens and the actor manages a private group. + useEffect(() => { + if (open && visibility === "private" && isOwner) { + fetchMembers(); + } + }, [open, visibility, isOwner, fetchMembers]); + + // Search mentionables (users/agents) as the owner types. + useEffect(() => { + if (!open || visibility !== "private" || !isOwner) return; + const handle = setTimeout(async () => { + try { + const res = await authFetch( + `/api/mentionables?q=${encodeURIComponent(search.trim())}&limit=10&forMembers=1`, + ); + const json = await res.json(); + if (json.success) { + setResults(json.data ?? []); + } + } catch { + setResults([]); + } + }, 200); + return () => clearTimeout(handle); + }, [search, open, visibility, isOwner]); + + const handleVisibilityChange = async (next: Visibility) => { + if (next === visibility) return; + const previous = visibility; + setVisibility(next); + setVisibilitySaving(true); + setVisibilityError(null); + try { + const res = await authFetch(`/api/project-groups/${groupUuid}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ visibility: next }), + }); + const json = await res.json(); + if (!json.success) { + setVisibility(previous); + setVisibilityError(json.error || t("visibilityUpdateFailed")); + } else { + router.refresh(); + } + } catch { + setVisibility(previous); + setVisibilityError(t("visibilityUpdateFailed")); + } finally { + setVisibilitySaving(false); + } + }; + + const addMember = async (memberType: "user" | "agent", memberUuid: string) => { + const value = memberUuid.trim(); + if (!value) return; + setAdding(true); + setMemberError(null); + try { + const res = await authFetch(`/api/project-groups/${groupUuid}/members`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ memberType, memberUuid: value }), + }); + const json = await res.json(); + if (!json.success) { + setMemberError(json.error || t("memberAddFailed")); + } else { + setSearch(""); + setResults([]); + await fetchMembers(); + } + } catch { + setMemberError(t("memberAddFailed")); + } finally { + setAdding(false); + } + }; + + const removeMember = async (member: GroupMember) => { + setMemberError(null); + try { + const res = await authFetch( + `/api/project-groups/${groupUuid}/members?memberType=${member.memberType}&memberUuid=${encodeURIComponent(member.memberUuid)}`, + { method: "DELETE" }, + ); + const json = await res.json(); + if (!json.success) { + setMemberError(json.error || t("memberRemoveFailed")); + } else { + await fetchMembers(); + } + } catch { + setMemberError(t("memberRemoveFailed")); + } + }; + + const handleSearchKeyDown = (e: React.KeyboardEvent) => { + if (e.key !== "Enter") return; + if (isImeComposing(e)) return; + e.preventDefault(); + const value = search.trim(); + if (!value) return; + // If the input looks like a raw UUID, add it directly as a user member. + if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)) { + addMember("user", value); + } else if (results.length > 0) { + addMember(results[0].type, results[0].uuid); + } + }; + return ( @@ -103,7 +277,7 @@ export function ManageProjectGroupDialog({
-
+
{/* Edit Name */}
-
- {/* Danger Zone */} -
- {!showDeleteConfirm ? ( - - ) : ( -
-
- - {t("deleteConfirmTitle")} -
-

- {t("deleteConfirmDesc")} + {/* Visibility */} +

+ + + {!isOwner && ( +

+ {t("onlyOwnerCanManage")}

+ )} - {projectCount > 0 && ( -
- - + handleVisibilityChange(value as Visibility)} + disabled={!isOwner || visibilitySaving} + className="gap-2.5" + > + + +
+ + {/* Danger Zone */} + {isOwner && ( +
+ {!showDeleteConfirm ? ( + + ) : ( +
+
+ + {t("deleteConfirmTitle")} +
+

+ {t("deleteConfirmDesc")} +

+ + {projectCount > 0 && ( +
+ + +
+ )} + +
+ + +
+
+ )} +
+ )} ); diff --git a/src/lib/authz/__tests__/project-access.test.ts b/src/lib/authz/__tests__/project-access.test.ts new file mode 100644 index 00000000..20c7e471 --- /dev/null +++ b/src/lib/authz/__tests__/project-access.test.ts @@ -0,0 +1,644 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// ===== Prisma mock ===== +const mockPrisma = vi.hoisted(() => ({ + project: { + findMany: vi.fn(), + findFirst: vi.fn(), + updateMany: vi.fn(), + }, + projectMember: { + findMany: vi.fn(), + findUnique: vi.fn(), + upsert: vi.fn(), + }, + projectGroup: { + findMany: vi.fn(), + findFirst: vi.fn(), + updateMany: vi.fn(), + }, + projectGroupMember: { + findMany: vi.fn(), + findUnique: vi.fn(), + upsert: vi.fn(), + }, +})); +vi.mock("@/lib/prisma", () => ({ prisma: mockPrisma })); + +import { + getAccessibleProjectUuids, + canAccessProject, + canManageProject, + getAccessibleGroupUuids, + canAccessGroup, + canManageGroup, + claimOrCanManageProject, + claimOrCanManageGroup, + canManageOrClaimableProject, + canManageOrClaimableGroup, + applyProjectFilter, + ALL_PROJECTS, +} from "../project-access"; +import type { + AuthContext, + SuperAdminAuthContext, + AgentAuthContext, +} from "@/types/auth"; + +const COMPANY = "company-1"; + +const superAdmin: SuperAdminAuthContext = { + type: "super_admin", + email: "root@chorus.local", +}; +const ownerUser: AuthContext = { + type: "user", + companyUuid: COMPANY, + actorUuid: "user-owner", +}; +const memberUser: AuthContext = { + type: "user", + companyUuid: COMPANY, + actorUuid: "user-member", +}; +const nonMemberUser: AuthContext = { + type: "user", + companyUuid: COMPANY, + actorUuid: "user-stranger", +}; +const memberAgent: AuthContext = { + type: "agent", + companyUuid: COMPANY, + actorUuid: "agent-member", +}; +// Agent that carries project:admin but is NOT a member — must still be denied. +const adminAgentNonMember: AgentAuthContext = { + type: "agent", + companyUuid: COMPANY, + actorUuid: "agent-admin", + roles: ["admin_agent"], + permissions: ["project:read", "project:write", "project:admin"], + agentName: "AdminBot", + // A private project's UUID injected via default headers must NOT grant access. + projectUuids: ["private-proj"], +}; + +beforeEach(() => { + vi.clearAllMocks(); + // Safe defaults: no group ownership/membership unless a test sets otherwise, + // so the project-union branch is a no-op for the existing project-level tests. + mockPrisma.projectGroup.findMany.mockResolvedValue([]); + mockPrisma.projectGroupMember.findMany.mockResolvedValue([]); + mockPrisma.projectGroup.findFirst.mockResolvedValue(null); + mockPrisma.projectGroupMember.findUnique.mockResolvedValue(null); +}); + +describe("getAccessibleProjectUuids", () => { + it("returns ALL sentinel for super admin without querying", async () => { + const result = await getAccessibleProjectUuids(superAdmin); + expect(result).toBe(ALL_PROJECTS); + expect(mockPrisma.project.findMany).not.toHaveBeenCalled(); + }); + + it("unions shared/owned projects with memberships for a user", async () => { + mockPrisma.project.findMany.mockResolvedValue([ + { uuid: "shared-1" }, + { uuid: "owned-1" }, + ]); + mockPrisma.projectMember.findMany.mockResolvedValue([ + { projectUuid: "private-member" }, + { projectUuid: "shared-1" }, // duplicate is de-duped + ]); + + const result = await getAccessibleProjectUuids(memberUser); + expect(result).not.toBe(ALL_PROJECTS); + expect(new Set(result as string[])).toEqual( + new Set(["shared-1", "owned-1", "private-member"]), + ); + // Scoped by company + actor + type. + expect(mockPrisma.project.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ companyUuid: COMPANY }), + }), + ); + expect(mockPrisma.projectMember.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + companyUuid: COMPANY, + memberType: "user", + memberUuid: "user-member", + }, + select: { projectUuid: true }, + }), + ); + }); +}); + +describe("canAccessProject", () => { + it("super admin always true, no query", async () => { + expect(await canAccessProject(superAdmin, "any")).toBe(true); + expect(mockPrisma.project.findFirst).not.toHaveBeenCalled(); + }); + + it("false for empty projectUuid", async () => { + expect(await canAccessProject(ownerUser, "")).toBe(false); + }); + + it("false when project not found (or cross-company)", async () => { + mockPrisma.project.findFirst.mockResolvedValue(null); + expect(await canAccessProject(nonMemberUser, "missing")).toBe(false); + }); + + it("shared project: accessible to any company actor", async () => { + mockPrisma.project.findFirst.mockResolvedValue({ + visibility: "shared", + ownerType: null, + ownerUuid: null, + }); + expect(await canAccessProject(nonMemberUser, "shared-1")).toBe(true); + expect(mockPrisma.projectMember.findUnique).not.toHaveBeenCalled(); + }); + + it("private project: owner accesses without a membership row", async () => { + mockPrisma.project.findFirst.mockResolvedValue({ + visibility: "private", + ownerType: "user", + ownerUuid: "user-owner", + }); + expect(await canAccessProject(ownerUser, "private-proj")).toBe(true); + }); + + it("private project: member (user) accesses via membership row", async () => { + mockPrisma.project.findFirst.mockResolvedValue({ + visibility: "private", + ownerType: "user", + ownerUuid: "user-owner", + }); + mockPrisma.projectMember.findUnique.mockResolvedValue({ id: 1 }); + expect(await canAccessProject(memberUser, "private-proj")).toBe(true); + }); + + it("private project: member (agent) accesses via membership row", async () => { + mockPrisma.project.findFirst.mockResolvedValue({ + visibility: "private", + ownerType: "user", + ownerUuid: "user-owner", + }); + mockPrisma.projectMember.findUnique.mockResolvedValue({ id: 2 }); + expect(await canAccessProject(memberAgent, "private-proj")).toBe(true); + }); + + it("private project: non-member user denied", async () => { + mockPrisma.project.findFirst.mockResolvedValue({ + visibility: "private", + ownerType: "user", + ownerUuid: "user-owner", + }); + mockPrisma.projectMember.findUnique.mockResolvedValue(null); + expect(await canAccessProject(nonMemberUser, "private-proj")).toBe(false); + }); + + it("private project: project:admin agent that is NOT a member is still denied (no permission bypass)", async () => { + mockPrisma.project.findFirst.mockResolvedValue({ + visibility: "private", + ownerType: "user", + ownerUuid: "user-owner", + }); + mockPrisma.projectMember.findUnique.mockResolvedValue(null); + expect(await canAccessProject(adminAgentNonMember, "private-proj")).toBe( + false, + ); + // The projectUuids[] default header is ignored — access derives only from + // visibility/ownership/membership. + expect(mockPrisma.projectMember.findUnique).toHaveBeenCalled(); + }); +}); + +describe("canManageProject", () => { + it("super admin always true", async () => { + expect(await canManageProject(superAdmin, "any")).toBe(true); + }); + + it("owner can manage", async () => { + mockPrisma.project.findFirst.mockResolvedValue({ + ownerType: "user", + ownerUuid: "user-owner", + }); + expect(await canManageProject(ownerUser, "private-proj")).toBe(true); + }); + + it("plain member cannot manage", async () => { + mockPrisma.project.findFirst.mockResolvedValue({ + ownerType: "user", + ownerUuid: "user-owner", + }); + expect(await canManageProject(memberUser, "private-proj")).toBe(false); + }); + + it("non-existent project cannot be managed", async () => { + mockPrisma.project.findFirst.mockResolvedValue(null); + expect(await canManageProject(ownerUser, "missing")).toBe(false); + }); +}); + +describe("applyProjectFilter", () => { + it("returns where unchanged for ALL sentinel", () => { + const where = { companyUuid: COMPANY }; + expect(applyProjectFilter(where, ALL_PROJECTS)).toBe(where); + }); + + it("adds projectUuid:{in:set} for a concrete set", () => { + const where = { companyUuid: COMPANY }; + expect(applyProjectFilter(where, ["a", "b"])).toEqual({ + companyUuid: COMPANY, + projectUuid: { in: ["a", "b"] }, + }); + }); + + it("supports a custom project field (e.g. uuid on Project table)", () => { + const where = { companyUuid: COMPANY }; + expect(applyProjectFilter(where, ["a"], "uuid")).toEqual({ + companyUuid: COMPANY, + uuid: { in: ["a"] }, + }); + }); + + it("empty accessible set yields an impossible-to-match in:[] (no leakage)", () => { + expect(applyProjectFilter({ companyUuid: COMPANY }, [])).toEqual({ + companyUuid: COMPANY, + projectUuid: { in: [] }, + }); + }); +}); + +// =========================================================================== +// Two-level inheritance (ProjectGroup → Project, dynamic union) +// =========================================================================== + +const groupMemberUser: AuthContext = { + type: "user", + companyUuid: COMPANY, + actorUuid: "user-groupmember", +}; + +describe("canAccessProject — group inheritance", () => { + it("a GROUP member accesses a PRIVATE project in that group (union)", async () => { + // project: private, owned by someone else, no direct membership, belongs to group-1 + mockPrisma.project.findFirst.mockResolvedValue({ + visibility: "private", + ownerType: "user", + ownerUuid: "user-owner", + groupUuid: "group-1", + }); + mockPrisma.projectMember.findUnique.mockResolvedValue(null); + // isGroupOwnerOrMember: not owner, but a member of group-1 + mockPrisma.projectGroup.findFirst.mockResolvedValue({ ownerType: "user", ownerUuid: "user-owner" }); + mockPrisma.projectGroupMember.findUnique.mockResolvedValue({ id: 1 }); + + expect(await canAccessProject(groupMemberUser, "private-in-group")).toBe(true); + }); + + it("a GROUP owner accesses a private project in that group", async () => { + mockPrisma.project.findFirst.mockResolvedValue({ + visibility: "private", + ownerType: "user", + ownerUuid: "someone-else", + groupUuid: "group-1", + }); + mockPrisma.projectMember.findUnique.mockResolvedValue(null); + mockPrisma.projectGroup.findFirst.mockResolvedValue({ ownerType: "user", ownerUuid: "user-groupmember" }); + + expect(await canAccessProject(groupMemberUser, "private-in-group")).toBe(true); + }); + + it("a NON-group-member is denied a private project in the group", async () => { + mockPrisma.project.findFirst.mockResolvedValue({ + visibility: "private", + ownerType: "user", + ownerUuid: "user-owner", + groupUuid: "group-1", + }); + mockPrisma.projectMember.findUnique.mockResolvedValue(null); + mockPrisma.projectGroup.findFirst.mockResolvedValue({ ownerType: "user", ownerUuid: "user-owner" }); + mockPrisma.projectGroupMember.findUnique.mockResolvedValue(null); + + expect(await canAccessProject(nonMemberUser, "private-in-group")).toBe(false); + }); + + it("INVARIANT: a SHARED project in a PRIVATE group is still company-wide (shared short-circuits)", async () => { + mockPrisma.project.findFirst.mockResolvedValue({ + visibility: "shared", + ownerType: "user", + ownerUuid: "user-owner", + groupUuid: "private-group", + }); + // even a total stranger gets it; group membership never consulted + expect(await canAccessProject(nonMemberUser, "shared-in-private-group")).toBe(true); + expect(mockPrisma.projectGroupMember.findUnique).not.toHaveBeenCalled(); + }); + + it("INVARIANT: a PRIVATE project in a SHARED group stays restricted (shared groups NOT in project-union)", async () => { + // The project's group is shared, but isGroupOwnerOrMember is shared-agnostic: + // a non-member/owner of the (shared) group must NOT inherit the private project. + mockPrisma.project.findFirst.mockResolvedValue({ + visibility: "private", + ownerType: "user", + ownerUuid: "user-owner", + groupUuid: "shared-group", + }); + mockPrisma.projectMember.findUnique.mockResolvedValue(null); + // The group is explicitly SHARED and owned by someone else; the stranger is + // not its owner/member. Including visibility:"shared" here hardens the test: + // it would FAIL if a regression made isGroupOwnerOrMember grant access just + // because the group is shared. + mockPrisma.projectGroup.findFirst.mockResolvedValue({ visibility: "shared", ownerType: "user", ownerUuid: "user-owner" }); + mockPrisma.projectGroupMember.findUnique.mockResolvedValue(null); + + expect(await canAccessProject(nonMemberUser, "private-in-shared-group")).toBe(false); + }); + + it("project:admin agent that is NOT a group member is still denied (no bypass)", async () => { + mockPrisma.project.findFirst.mockResolvedValue({ + visibility: "private", + ownerType: "user", + ownerUuid: "user-owner", + groupUuid: "group-1", + }); + mockPrisma.projectMember.findUnique.mockResolvedValue(null); + mockPrisma.projectGroup.findFirst.mockResolvedValue({ ownerType: "user", ownerUuid: "user-owner" }); + mockPrisma.projectGroupMember.findUnique.mockResolvedValue(null); + + expect(await canAccessProject(adminAgentNonMember, "private-in-group")).toBe(false); + }); +}); + +describe("getAccessibleProjectUuids — group union", () => { + it("unions in projects of groups the actor owns/belongs to", async () => { + // shared/owned direct projects + mockPrisma.project.findMany + .mockResolvedValueOnce([{ uuid: "shared-1" }]) // visible projects + .mockResolvedValueOnce([{ uuid: "groupproj-1" }, { uuid: "groupproj-2" }]); // group projects + mockPrisma.projectMember.findMany.mockResolvedValue([]); + // owns group-1 (union group-set) + mockPrisma.projectGroup.findMany.mockResolvedValue([{ uuid: "group-1" }]); + mockPrisma.projectGroupMember.findMany.mockResolvedValue([]); + + const result = await getAccessibleProjectUuids(memberUser); + expect(new Set(result as string[])).toEqual( + new Set(["shared-1", "groupproj-1", "groupproj-2"]), + ); + }); + + it("does NOT query group projects when the actor owns/belongs to no groups", async () => { + mockPrisma.project.findMany.mockResolvedValueOnce([{ uuid: "shared-1" }]); + mockPrisma.projectMember.findMany.mockResolvedValue([]); + mockPrisma.projectGroup.findMany.mockResolvedValue([]); + mockPrisma.projectGroupMember.findMany.mockResolvedValue([]); + + const result = await getAccessibleProjectUuids(memberUser); + expect(result).toEqual(["shared-1"]); + // only the first project.findMany (visible projects); no second group-projects query + expect(mockPrisma.project.findMany).toHaveBeenCalledTimes(1); + }); +}); + +describe("getAccessibleGroupUuids / canAccessGroup / canManageGroup", () => { + it("super admin => ALL, no query", async () => { + expect(await getAccessibleGroupUuids(superAdmin)).toBe(ALL_PROJECTS); + expect(mockPrisma.projectGroup.findMany).not.toHaveBeenCalled(); + }); + + it("getAccessibleGroupUuids includes shared ∪ owned ∪ member groups", async () => { + mockPrisma.projectGroup.findMany.mockResolvedValue([{ uuid: "shared-g" }, { uuid: "owned-g" }]); + mockPrisma.projectGroupMember.findMany.mockResolvedValue([{ projectGroupUuid: "member-g" }]); + + const result = await getAccessibleGroupUuids(memberUser); + expect(new Set(result as string[])).toEqual(new Set(["shared-g", "owned-g", "member-g"])); + }); + + it("canAccessGroup: shared group accessible to anyone, no membership query", async () => { + mockPrisma.projectGroup.findFirst.mockResolvedValue({ visibility: "shared", ownerType: null, ownerUuid: null }); + expect(await canAccessGroup(nonMemberUser, "shared-g")).toBe(true); + }); + + it("canAccessGroup: private group denied to non-member", async () => { + mockPrisma.projectGroup.findFirst + .mockResolvedValueOnce({ visibility: "private", ownerType: "user", ownerUuid: "user-owner" }) // canAccessGroup lookup + .mockResolvedValueOnce({ ownerType: "user", ownerUuid: "user-owner" }); // isGroupOwnerOrMember lookup + mockPrisma.projectGroupMember.findUnique.mockResolvedValue(null); + expect(await canAccessGroup(nonMemberUser, "private-g")).toBe(false); + }); + + it("canManageGroup: owner yes, member no, super_admin yes", async () => { + mockPrisma.projectGroup.findFirst.mockResolvedValue({ ownerType: "user", ownerUuid: "user-owner" }); + expect(await canManageGroup(ownerUser, "g")).toBe(true); + expect(await canManageGroup(memberUser, "g")).toBe(false); + expect(await canManageGroup(superAdmin, "g")).toBe(true); + }); +}); + +describe("group helpers — guard branches", () => { + it("canAccessGroup: empty groupUuid => false", async () => { + expect(await canAccessGroup(memberUser, "")).toBe(false); + }); + it("canAccessGroup: group not found => false", async () => { + mockPrisma.projectGroup.findFirst.mockResolvedValue(null); + expect(await canAccessGroup(memberUser, "missing")).toBe(false); + }); + it("canAccessGroup: private group, owner allowed", async () => { + mockPrisma.projectGroup.findFirst.mockResolvedValue({ visibility: "private", ownerType: "user", ownerUuid: "user-owner" }); + expect(await canAccessGroup(ownerUser, "g")).toBe(true); + }); + it("canAccessGroup: private group, member allowed via membership row", async () => { + // first findFirst (canAccessGroup) + second (isGroupOwnerOrMember) both private/other-owner + mockPrisma.projectGroup.findFirst.mockResolvedValue({ visibility: "private", ownerType: "user", ownerUuid: "user-owner" }); + mockPrisma.projectGroupMember.findUnique.mockResolvedValue({ id: 7 }); + expect(await canAccessGroup(memberUser, "g")).toBe(true); + }); + it("canManageGroup: empty groupUuid => false", async () => { + expect(await canManageGroup(memberUser, "")).toBe(false); + }); + it("canManageGroup: group not found => false", async () => { + mockPrisma.projectGroup.findFirst.mockResolvedValue(null); + expect(await canManageGroup(ownerUser, "missing")).toBe(false); + }); + it("getAccessibleGroupUuids: member-only group (not owned/shared) is included", async () => { + mockPrisma.projectGroup.findMany.mockResolvedValue([]); // no shared/owned + mockPrisma.projectGroupMember.findMany.mockResolvedValue([{ projectGroupUuid: "g-mem" }]); + const result = await getAccessibleGroupUuids(memberUser); + expect(result as string[]).toEqual(["g-mem"]); + }); +}); + +describe("canAccessProject — group fallthrough when group missing/unowned", () => { + it("project in a group the actor neither owns nor belongs to => denied (isGroupOwnerOrMember group lookup null)", async () => { + mockPrisma.project.findFirst.mockResolvedValue({ + visibility: "private", ownerType: "user", ownerUuid: "user-owner", groupUuid: "ghost-group", + }); + mockPrisma.projectMember.findUnique.mockResolvedValue(null); + mockPrisma.projectGroup.findFirst.mockResolvedValue(null); // group not found => isGroupOwnerOrMember false (line 84) + expect(await canAccessProject(nonMemberUser, "p-ghost")).toBe(false); + }); +}); + +// =========================================================================== +// Claim-on-manage (access-gated) +// =========================================================================== +describe("claimOrCanManageProject", () => { + it("super_admin => true, no claim", async () => { + expect(await claimOrCanManageProject(superAdmin, "p")).toBe(true); + expect(mockPrisma.project.updateMany).not.toHaveBeenCalled(); + }); + + it("BLOCKER guard: non-member of a PRIVATE null-owner project => false, NO claim", async () => { + // canAccessProject: private, owner null, no membership, no group → false + mockPrisma.project.findFirst.mockResolvedValue({ + visibility: "private", ownerType: null, ownerUuid: null, groupUuid: null, + }); + mockPrisma.projectMember.findUnique.mockResolvedValue(null); + expect(await claimOrCanManageProject(nonMemberUser, "p-priv")).toBe(false); + expect(mockPrisma.project.updateMany).not.toHaveBeenCalled(); + }); + + it("accessible (shared) null-owner project => claims + seeds owner member + true", async () => { + // 1st findFirst: canAccessProject (shared → accessible). 2nd: owner lookup (null). + mockPrisma.project.findFirst + .mockResolvedValueOnce({ visibility: "shared", ownerType: null, ownerUuid: null, groupUuid: null }) + .mockResolvedValueOnce({ ownerType: null, ownerUuid: null }); + mockPrisma.project.updateMany.mockResolvedValue({ count: 1 }); + mockPrisma.projectMember.upsert.mockResolvedValue({}); + + expect(await claimOrCanManageProject(nonMemberUser, "p-shared")).toBe(true); + expect(mockPrisma.project.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ ownerType: null, ownerUuid: null }), + data: { ownerType: "user", ownerUuid: "user-stranger" }, + }), + ); + expect(mockPrisma.projectMember.upsert).toHaveBeenCalled(); + }); + + it("already-owned by someone else => false, never reassigns", async () => { + mockPrisma.project.findFirst + .mockResolvedValueOnce({ visibility: "shared", ownerType: null, ownerUuid: null, groupUuid: null }) // canAccess (shared) + .mockResolvedValueOnce({ ownerType: "user", ownerUuid: "other" }); // owner lookup + expect(await claimOrCanManageProject(nonMemberUser, "p-owned")).toBe(false); + expect(mockPrisma.project.updateMany).not.toHaveBeenCalled(); + }); + + it("already-owned by the actor => true, no claim", async () => { + mockPrisma.project.findFirst + .mockResolvedValueOnce({ visibility: "private", ownerType: "user", ownerUuid: "user-owner", groupUuid: null }) // canAccess (owner) + .mockResolvedValueOnce({ ownerType: "user", ownerUuid: "user-owner" }); + expect(await claimOrCanManageProject(ownerUser, "p")).toBe(true); + expect(mockPrisma.project.updateMany).not.toHaveBeenCalled(); + }); + + it("lost race: updateMany 0 rows + re-read shows different owner => false", async () => { + mockPrisma.project.findFirst + .mockResolvedValueOnce({ visibility: "shared", ownerType: null, ownerUuid: null, groupUuid: null }) // canAccess + .mockResolvedValueOnce({ ownerType: null, ownerUuid: null }) // owner lookup + .mockResolvedValueOnce({ ownerType: "user", ownerUuid: "winner" }); // re-read after lost race + mockPrisma.project.updateMany.mockResolvedValue({ count: 0 }); + expect(await claimOrCanManageProject(nonMemberUser, "p")).toBe(false); + }); +}); + +describe("claimOrCanManageGroup", () => { + it("BLOCKER guard: non-member of a PRIVATE null-owner group => false, NO claim", async () => { + // canAccessGroup: private, not owner, not member → false + mockPrisma.projectGroup.findFirst.mockResolvedValue({ visibility: "private", ownerType: null, ownerUuid: null }); + mockPrisma.projectGroupMember.findUnique.mockResolvedValue(null); + expect(await claimOrCanManageGroup(nonMemberUser, "g-priv")).toBe(false); + expect(mockPrisma.projectGroup.updateMany).not.toHaveBeenCalled(); + }); + + it("accessible (shared) null-owner group => claims + true", async () => { + mockPrisma.projectGroup.findFirst + .mockResolvedValueOnce({ visibility: "shared", ownerType: null, ownerUuid: null }) // canAccessGroup (shared) + .mockResolvedValueOnce({ ownerType: null, ownerUuid: null }); // owner lookup + mockPrisma.projectGroup.updateMany.mockResolvedValue({ count: 1 }); + mockPrisma.projectGroupMember.upsert.mockResolvedValue({}); + expect(await claimOrCanManageGroup(nonMemberUser, "g-shared")).toBe(true); + expect(mockPrisma.projectGroup.updateMany).toHaveBeenCalled(); + expect(mockPrisma.projectGroupMember.upsert).toHaveBeenCalled(); + }); +}); + +describe("canManageOrClaimable* (pure — no writes)", () => { + it("project: claimable null-owner accessible => true, performs NO update", async () => { + mockPrisma.project.findFirst + .mockResolvedValueOnce({ ownerType: null, ownerUuid: null }) // predicate owner lookup + .mockResolvedValueOnce({ visibility: "shared", ownerType: null, ownerUuid: null, groupUuid: null }); // canAccess + expect(await canManageOrClaimableProject(nonMemberUser, "p")).toBe(true); + expect(mockPrisma.project.updateMany).not.toHaveBeenCalled(); + }); + + it("project: private null-owner inaccessible => false", async () => { + mockPrisma.project.findFirst + .mockResolvedValueOnce({ ownerType: null, ownerUuid: null }) // predicate + .mockResolvedValueOnce({ visibility: "private", ownerType: null, ownerUuid: null, groupUuid: null }); // canAccess + mockPrisma.projectMember.findUnique.mockResolvedValue(null); + expect(await canManageOrClaimableProject(nonMemberUser, "p")).toBe(false); + expect(mockPrisma.project.updateMany).not.toHaveBeenCalled(); + }); + + it("group: owner => true", async () => { + mockPrisma.projectGroup.findFirst.mockResolvedValue({ ownerType: "user", ownerUuid: "user-owner" }); + expect(await canManageOrClaimableGroup(ownerUser, "g")).toBe(true); + }); +}); + +describe("claim/predicate guard branches (coverage)", () => { + it("claimOrCanManageProject: empty uuid => false; not found => false", async () => { + expect(await claimOrCanManageProject(ownerUser, "")).toBe(false); + // canAccess passes (shared) but project lookup returns null on 2nd call + mockPrisma.project.findFirst + .mockResolvedValueOnce({ visibility: "shared", ownerType: null, ownerUuid: null, groupUuid: null }) + .mockResolvedValueOnce(null); + expect(await claimOrCanManageProject(ownerUser, "gone")).toBe(false); + }); + + it("claimOrCanManageGroup: super_admin => true; empty uuid => false; not found => false", async () => { + expect(await claimOrCanManageGroup(superAdmin, "g")).toBe(true); + expect(await claimOrCanManageGroup(ownerUser, "")).toBe(false); + mockPrisma.projectGroup.findFirst + .mockResolvedValueOnce({ visibility: "shared", ownerType: null, ownerUuid: null }) // canAccessGroup + .mockResolvedValueOnce(null); // owner lookup + expect(await claimOrCanManageGroup(ownerUser, "gone")).toBe(false); + }); + + it("claimOrCanManageGroup: lost race 0 rows + re-read shows different owner => false", async () => { + mockPrisma.projectGroup.findFirst + .mockResolvedValueOnce({ visibility: "shared", ownerType: null, ownerUuid: null }) // canAccessGroup + .mockResolvedValueOnce({ ownerType: null, ownerUuid: null }) // owner lookup + .mockResolvedValueOnce({ ownerType: "user", ownerUuid: "winner" }); // re-read + mockPrisma.projectGroup.updateMany.mockResolvedValue({ count: 0 }); + expect(await claimOrCanManageGroup(nonMemberUser, "g")).toBe(false); + }); + + it("canManageOrClaimableProject: empty uuid => false; not found => false; owner-match => true; non-claimable owned-by-other => false", async () => { + expect(await canManageOrClaimableProject(ownerUser, "")).toBe(false); + mockPrisma.project.findFirst.mockResolvedValueOnce(null); + expect(await canManageOrClaimableProject(ownerUser, "gone")).toBe(false); + mockPrisma.project.findFirst.mockResolvedValueOnce({ ownerType: "user", ownerUuid: "user-owner" }); + expect(await canManageOrClaimableProject(ownerUser, "p")).toBe(true); + mockPrisma.project.findFirst.mockResolvedValueOnce({ ownerType: "user", ownerUuid: "other" }); + expect(await canManageOrClaimableProject(nonMemberUser, "p")).toBe(false); + }); + + it("canManageOrClaimableGroup: super_admin true; empty false; not-found false; claimable accessible => true; owned-other => false", async () => { + expect(await canManageOrClaimableGroup(superAdmin, "g")).toBe(true); + expect(await canManageOrClaimableGroup(ownerUser, "")).toBe(false); + mockPrisma.projectGroup.findFirst.mockResolvedValueOnce(null); + expect(await canManageOrClaimableGroup(ownerUser, "gone")).toBe(false); + // claimable: null owner + accessible (shared) + mockPrisma.projectGroup.findFirst + .mockResolvedValueOnce({ ownerType: null, ownerUuid: null }) // predicate owner lookup + .mockResolvedValueOnce({ visibility: "shared", ownerType: null, ownerUuid: null }); // canAccessGroup + expect(await canManageOrClaimableGroup(nonMemberUser, "g")).toBe(true); + // owned by other => false + mockPrisma.projectGroup.findFirst.mockResolvedValueOnce({ ownerType: "user", ownerUuid: "other" }); + expect(await canManageOrClaimableGroup(nonMemberUser, "g")).toBe(false); + }); +}); diff --git a/src/lib/authz/project-access.ts b/src/lib/authz/project-access.ts new file mode 100644 index 00000000..75e79671 --- /dev/null +++ b/src/lib/authz/project-access.ts @@ -0,0 +1,459 @@ +// src/lib/authz/project-access.ts +// Project visibility access control — the SINGLE source of truth for whether an +// actor may read or write a given project (and, by cascade, its ideas, proposals, +// documents, tasks, activity, comments, notifications and search hits). +// +// Semantics: +// - super_admin => sees/manages everything (ALL sentinel; canAccess/canManage true) +// - user | agent => may access a project when it is SHARED, or they OWN it, or +// they are a ProjectMember of it, OR they own / are a member of +// the project's GROUP (dynamic two-level union — see below). +// Membership is the ONLY way into a private project — the +// permission bitset (incl. project:admin) does NOT widen access. +// +// Two-level inheritance (dynamic union): +// A project's effective accessors = (project owner + ProjectMembers) +// ∪ (its group's owner + ProjectGroupMembers). +// ⚠️ TWO DISTINCT group-sets — do NOT conflate: +// • PROJECT-UNION set (getAccessibleProjectUuids / canAccessProject group +// fallthrough): groups the actor OWNS or is a MEMBER of — NOT shared groups. +// A shared group must not turn its PRIVATE projects company-wide; that would +// break "项目级 > 项目组" (the project's own visibility flag is authoritative). +// • GROUP-VISIBILITY set (getAccessibleGroupUuids / canAccessGroup): shared ∪ +// owned ∪ member groups — used ONLY to decide which GROUPS an actor may +// see/open, never for project access. +// The union only ADDS accessors; it never removes or downgrades a project. +// +// The optional AgentAuthContext.projectUuids[] (default-header convenience) is +// intentionally ignored here — it is not an access grant. + +import { prisma } from "@/lib/prisma"; +import type { AuthContext, SuperAdminAuthContext } from "@/types/auth"; + +/** Sentinel returned by getAccessibleProjectUuids for actors who see all projects. */ +export const ALL_PROJECTS = "ALL" as const; +export type AccessibleProjects = string[] | typeof ALL_PROJECTS; + +export type AnyAuth = AuthContext | SuperAdminAuthContext; + +function isSuperAdmin(auth: AnyAuth): auth is SuperAdminAuthContext { + return auth.type === "super_admin"; +} + +/** + * UUIDs of groups the actor OWNS or is a ProjectGroupMember of. This is the + * "project-union" group-set — it deliberately EXCLUDES shared groups, because a + * shared group must not pull its private projects into company-wide view. + * Used by getAccessibleProjectUuids + canAccessProject for project inheritance. + */ +async function getOwnedOrMemberGroupUuids( + companyUuid: string, + type: string, + actorUuid: string, +): Promise { + const [ownedGroups, groupMemberships] = await Promise.all([ + prisma.projectGroup.findMany({ + where: { companyUuid, ownerType: type, ownerUuid: actorUuid }, + select: { uuid: true }, + }), + prisma.projectGroupMember.findMany({ + where: { companyUuid, memberType: type, memberUuid: actorUuid }, + select: { projectGroupUuid: true }, + }), + ]); + const set = new Set(); + for (const g of ownedGroups) set.add(g.uuid); + for (const m of groupMemberships) set.add(m.projectGroupUuid); + return [...set]; +} + +/** + * Whether the actor owns or is a member of the given group (shared-agnostic — + * does NOT return true merely because the group is shared). This is the gate + * used for PROJECT inheritance; for group visibility use canAccessGroup. + */ +async function isGroupOwnerOrMember( + auth: AuthContext, + groupUuid: string, +): Promise { + const { companyUuid, actorUuid, type } = auth; + const group = await prisma.projectGroup.findFirst({ + where: { uuid: groupUuid, companyUuid }, + select: { ownerType: true, ownerUuid: true }, + }); + if (!group) return false; + if (group.ownerType === type && group.ownerUuid === actorUuid) return true; + const membership = await prisma.projectGroupMember.findUnique({ + where: { + projectGroupUuid_memberType_memberUuid: { + projectGroupUuid: groupUuid, + memberType: type, + memberUuid: actorUuid, + }, + }, + select: { id: true }, + }); + return membership !== null; +} + +/** + * Returns the set of project UUIDs the actor may access within their company, + * or the ALL_PROJECTS sentinel for super admins (meaning "do not filter"). + * + * For users/agents the set is: all SHARED projects of the company, plus any + * PRIVATE project they own, plus any project they are a member of. + */ +export async function getAccessibleProjectUuids( + auth: AnyAuth, +): Promise { + if (isSuperAdmin(auth)) return ALL_PROJECTS; + + const { companyUuid, actorUuid, type } = auth; + + // Shared projects + projects owned by this actor, in one query. + const visibleProjects = await prisma.project.findMany({ + where: { + companyUuid, + OR: [{ visibility: "shared" }, { ownerType: type, ownerUuid: actorUuid }], + }, + select: { uuid: true }, + }); + + // Private projects this actor is an explicit member of. + const memberships = await prisma.projectMember.findMany({ + where: { companyUuid, memberType: type, memberUuid: actorUuid }, + select: { projectUuid: true }, + }); + + const uuids = new Set(); + for (const p of visibleProjects) uuids.add(p.uuid); + for (const m of memberships) uuids.add(m.projectUuid); + + // Dynamic two-level union: add every project belonging to a group the actor + // OWNS or is a MEMBER of (shared groups excluded — see module header). + const unionGroupUuids = await getOwnedOrMemberGroupUuids(companyUuid, type, actorUuid); + if (unionGroupUuids.length > 0) { + const groupProjects = await prisma.project.findMany({ + where: { companyUuid, groupUuid: { in: unionGroupUuids } }, + select: { uuid: true }, + }); + for (const p of groupProjects) uuids.add(p.uuid); + } + + return [...uuids]; +} + +/** + * Whether the actor may access (read OR write) the given project. + * Used by every single-entity read and every mutation that targets a project + * or a project-scoped entity. Returns false for an unknown/cross-company project. + */ +export async function canAccessProject( + auth: AnyAuth, + projectUuid: string, +): Promise { + if (isSuperAdmin(auth)) return true; + if (!projectUuid) return false; + + const { companyUuid, actorUuid, type } = auth; + + const project = await prisma.project.findFirst({ + where: { uuid: projectUuid, companyUuid }, + select: { visibility: true, ownerType: true, ownerUuid: true, groupUuid: true }, + }); + if (!project) return false; + + if (project.visibility === "shared") return true; + if (project.ownerType === type && project.ownerUuid === actorUuid) return true; + + const membership = await prisma.projectMember.findUnique({ + where: { + projectUuid_memberType_memberUuid: { + projectUuid, + memberType: type, + memberUuid: actorUuid, + }, + }, + select: { id: true }, + }); + if (membership !== null) return true; + + // Two-level inheritance: a member/owner of the project's GROUP inherits access + // (shared groups don't count — isGroupOwnerOrMember is shared-agnostic). + if (project.groupUuid && (await isGroupOwnerOrMember(auth, project.groupUuid))) { + return true; + } + + return false; +} + +/** + * Whether the actor may MANAGE the project — change visibility, manage members, + * or delete it. Restricted to the owner (or super admin). Plain members cannot + * manage. + */ +export async function canManageProject( + auth: AnyAuth, + projectUuid: string, +): Promise { + if (isSuperAdmin(auth)) return true; + if (!projectUuid) return false; + + const { companyUuid, actorUuid, type } = auth; + + const project = await prisma.project.findFirst({ + where: { uuid: projectUuid, companyUuid }, + select: { ownerType: true, ownerUuid: true }, + }); + if (!project) return false; + + return project.ownerType === type && project.ownerUuid === actorUuid; +} + +// =========================================================================== +// Group visibility (the GROUP-VISIBILITY set — distinct from project-union). +// These decide which GROUPS an actor may see/open. They DO include shared +// groups, and must NEVER be used for project access (see module header). +// =========================================================================== + +/** + * Project-group UUIDs the actor may see: all SHARED groups of the company, plus + * any group they own or are a member of. ALL_PROJECTS sentinel for super admins. + */ +export async function getAccessibleGroupUuids( + auth: AnyAuth, +): Promise { + if (isSuperAdmin(auth)) return ALL_PROJECTS; + + const { companyUuid, actorUuid, type } = auth; + + const visibleGroups = await prisma.projectGroup.findMany({ + where: { + companyUuid, + OR: [{ visibility: "shared" }, { ownerType: type, ownerUuid: actorUuid }], + }, + select: { uuid: true }, + }); + const memberships = await prisma.projectGroupMember.findMany({ + where: { companyUuid, memberType: type, memberUuid: actorUuid }, + select: { projectGroupUuid: true }, + }); + + const uuids = new Set(); + for (const g of visibleGroups) uuids.add(g.uuid); + for (const m of memberships) uuids.add(m.projectGroupUuid); + return [...uuids]; +} + +/** + * Whether the actor may access (see/open) the given group: shared → anyone in + * the company; otherwise owner or ProjectGroupMember. (For PROJECT inheritance + * use the shared-agnostic internal check, not this.) + */ +export async function canAccessGroup( + auth: AnyAuth, + groupUuid: string, +): Promise { + if (isSuperAdmin(auth)) return true; + if (!groupUuid) return false; + + const { companyUuid, actorUuid, type } = auth; + const group = await prisma.projectGroup.findFirst({ + where: { uuid: groupUuid, companyUuid }, + select: { visibility: true, ownerType: true, ownerUuid: true }, + }); + if (!group) return false; + if (group.visibility === "shared") return true; + if (group.ownerType === type && group.ownerUuid === actorUuid) return true; + return isGroupOwnerOrMember(auth, groupUuid); +} + +/** + * Whether the actor may MANAGE the group — change visibility, manage members. + * Restricted to the group owner (or super admin); plain members cannot manage. + */ +export async function canManageGroup( + auth: AnyAuth, + groupUuid: string, +): Promise { + if (isSuperAdmin(auth)) return true; + if (!groupUuid) return false; + + const { companyUuid, actorUuid, type } = auth; + const group = await prisma.projectGroup.findFirst({ + where: { uuid: groupUuid, companyUuid }, + select: { ownerType: true, ownerUuid: true }, + }); + if (!group) return false; + return group.ownerType === type && group.ownerUuid === actorUuid; +} + +// =========================================================================== +// Claim-on-manage: legacy entities created before the visibility feature have +// a NULL owner (the migration set them shared/private with no owner). The +// manage gates above would reject everyone but super_admin for those rows. To +// keep them manageable, the FIRST accessible actor to perform a manage action +// claims ownership. CRITICAL: the claim is ACCESS-GATED — an actor who cannot +// access the entity (e.g. a non-member of a private null-owner project) can +// NEVER claim it, so this opens no privacy hole. Existing owners are never +// reassigned. +// =========================================================================== + +/** + * Gate-then-claim for project management. Returns true if the actor may manage + * the project — claiming ownership of a NULL-owner project they can access. + * Has a side effect (the claim UPDATE) only in the null-owner branch. + */ +export async function claimOrCanManageProject( + auth: AnyAuth, + projectUuid: string, +): Promise { + if (isSuperAdmin(auth)) return true; + if (!projectUuid) return false; + + // ACCESS PRECHECK FIRST — the security guarantee. A non-member of a private + // null-owner project is not accessible, so it can never be claimed here. + if (!(await canAccessProject(auth, projectUuid))) return false; + + const { companyUuid, actorUuid, type } = auth; + const project = await prisma.project.findFirst({ + where: { uuid: projectUuid, companyUuid }, + select: { ownerType: true, ownerUuid: true }, + }); + if (!project) return false; + + // Already owned → exact-owner match only; never reassign. + if (project.ownerType !== null || project.ownerUuid !== null) { + return project.ownerType === type && project.ownerUuid === actorUuid; + } + + // Null owner → claim atomically (guard on ownerUuid: null for race-safety). + const claimed = await prisma.project.updateMany({ + where: { uuid: projectUuid, companyUuid, ownerType: null, ownerUuid: null }, + data: { ownerType: type, ownerUuid: actorUuid }, + }); + if (claimed.count === 0) { + // Lost the race — someone claimed first. We manage only if it was us. + const reread = await prisma.project.findFirst({ + where: { uuid: projectUuid, companyUuid }, + select: { ownerType: true, ownerUuid: true }, + }); + return reread?.ownerType === type && reread?.ownerUuid === actorUuid; + } + // Seed the owner as a member (idempotent on the unique key). + await prisma.projectMember.upsert({ + where: { + projectUuid_memberType_memberUuid: { projectUuid, memberType: type, memberUuid: actorUuid }, + }, + create: { companyUuid, projectUuid, memberType: type, memberUuid: actorUuid }, + update: {}, + }); + return true; +} + +/** Gate-then-claim for group management (mirror of claimOrCanManageProject). */ +export async function claimOrCanManageGroup( + auth: AnyAuth, + groupUuid: string, +): Promise { + if (isSuperAdmin(auth)) return true; + if (!groupUuid) return false; + + if (!(await canAccessGroup(auth, groupUuid))) return false; + + const { companyUuid, actorUuid, type } = auth; + const group = await prisma.projectGroup.findFirst({ + where: { uuid: groupUuid, companyUuid }, + select: { ownerType: true, ownerUuid: true }, + }); + if (!group) return false; + + if (group.ownerType !== null || group.ownerUuid !== null) { + return group.ownerType === type && group.ownerUuid === actorUuid; + } + + const claimed = await prisma.projectGroup.updateMany({ + where: { uuid: groupUuid, companyUuid, ownerType: null, ownerUuid: null }, + data: { ownerType: type, ownerUuid: actorUuid }, + }); + if (claimed.count === 0) { + const reread = await prisma.projectGroup.findFirst({ + where: { uuid: groupUuid, companyUuid }, + select: { ownerType: true, ownerUuid: true }, + }); + return reread?.ownerType === type && reread?.ownerUuid === actorUuid; + } + await prisma.projectGroupMember.upsert({ + where: { + projectGroupUuid_memberType_memberUuid: { projectGroupUuid: groupUuid, memberType: type, memberUuid: actorUuid }, + }, + create: { companyUuid, projectGroupUuid: groupUuid, memberType: type, memberUuid: actorUuid }, + update: {}, + }); + return true; +} + +/** + * PURE predicate (no DB writes) for dashboards/UI: whether the actor manages + * the entity OR could claim it (null owner + accessible + user/agent). Lets the + * UI show manage controls for claimable legacy entities without mutating on a + * read; the actual claim happens server-side via claimOrCanManage* on a manage + * action. `owner` is the entity's current {ownerType, ownerUuid}. + */ +export async function canManageOrClaimableProject( + auth: AnyAuth, + projectUuid: string, +): Promise { + if (isSuperAdmin(auth)) return true; + if (!projectUuid) return false; + const { companyUuid, actorUuid, type } = auth; + const project = await prisma.project.findFirst({ + where: { uuid: projectUuid, companyUuid }, + select: { ownerType: true, ownerUuid: true }, + }); + if (!project) return false; + if (project.ownerType === type && project.ownerUuid === actorUuid) return true; + // Claimable: null owner + the actor can access it. + if (project.ownerType === null && project.ownerUuid === null) { + return canAccessProject(auth, projectUuid); + } + return false; +} + +export async function canManageOrClaimableGroup( + auth: AnyAuth, + groupUuid: string, +): Promise { + if (isSuperAdmin(auth)) return true; + if (!groupUuid) return false; + const { companyUuid, actorUuid, type } = auth; + const group = await prisma.projectGroup.findFirst({ + where: { uuid: groupUuid, companyUuid }, + select: { ownerType: true, ownerUuid: true }, + }); + if (!group) return false; + if (group.ownerType === type && group.ownerUuid === actorUuid) return true; + if (group.ownerType === null && group.ownerUuid === null) { + return canAccessGroup(auth, groupUuid); + } + return false; +} + +/** + * Query-injection helper. Given an existing Prisma `where` (already scoped by + * companyUuid) and an accessible-projects result, returns a `where` that also + * restricts `projectUuid` to the accessible set — UNLESS the set is the + * ALL_PROJECTS sentinel, in which case the original `where` is returned + * unchanged (super admin: no visibility filtering). + * + * `projectField` lets callers target a differently-named column (default + * "projectUuid"), e.g. when filtering the Project table itself by "uuid". + */ +export function applyProjectFilter>( + where: T, + accessible: AccessibleProjects, + projectField: string = "projectUuid", +): T { + if (accessible === ALL_PROJECTS) return where; + return { ...where, [projectField]: { in: accessible } }; +} diff --git a/src/mcp/__tests__/get-proposal-section.test.ts b/src/mcp/__tests__/get-proposal-section.test.ts index 2fc877ed..e921d4b5 100644 --- a/src/mcp/__tests__/get-proposal-section.test.ts +++ b/src/mcp/__tests__/get-proposal-section.test.ts @@ -67,7 +67,7 @@ describe("chorus_get_proposal — section parameter", () => { await toolHandlers["chorus_get_proposal"]({ proposalUuid: "p1" }); - expect(mockProposalService.getProposalSection).toHaveBeenCalledWith("company-1", "p1", "basic"); + expect(mockProposalService.getProposalSection).toHaveBeenCalledWith("company-1", "p1", "basic", expect.anything()); // The legacy full getProposal path must no longer be used by the tool expect(mockProposalService.getProposal).not.toHaveBeenCalled(); }); @@ -79,7 +79,7 @@ describe("chorus_get_proposal — section parameter", () => { await toolHandlers["chorus_get_proposal"]({ proposalUuid: "p1", section }); - expect(mockProposalService.getProposalSection).toHaveBeenCalledWith("company-1", "p1", section); + expect(mockProposalService.getProposalSection).toHaveBeenCalledWith("company-1", "p1", section, expect.anything()); }, ); diff --git a/src/mcp/__tests__/move-idea.integration.test.ts b/src/mcp/__tests__/move-idea.integration.test.ts index 7c2f90e0..a323673b 100644 --- a/src/mcp/__tests__/move-idea.integration.test.ts +++ b/src/mcp/__tests__/move-idea.integration.test.ts @@ -38,6 +38,14 @@ const { hoistedPrisma, hoistedActivity } = vi.hoisted(() => ({ })); const mockPrisma = buildMockPrisma(); +// Project-visibility gate: moveIdea now resolves access via canAccessProject, +// which queries prisma.projectMember.findUnique for membership into the source +// and target projects. The shared fixture's project rows have no `visibility` +// field, so satisfy the gate by treating the actor as a member of every project. +(mockPrisma as Record).projectMember = { + findUnique: vi.fn().mockResolvedValue({ id: 1 }), + findMany: vi.fn().mockResolvedValue([]), +}; const mockActivityService = buildActivityServiceMock(COMPANY_UUID); hoistedPrisma.current = mockPrisma; hoistedActivity.current = mockActivityService; diff --git a/src/mcp/__tests__/pm-assign-task-permission-gate.test.ts b/src/mcp/__tests__/pm-assign-task-permission-gate.test.ts index a2979724..76267506 100644 --- a/src/mcp/__tests__/pm-assign-task-permission-gate.test.ts +++ b/src/mcp/__tests__/pm-assign-task-permission-gate.test.ts @@ -114,6 +114,7 @@ describe("chorus_pm_assign_task — assignee gate uses effective task:write", () assigneeType: "agent", assigneeUuid: targetUuid, }), + expect.anything(), ); }); diff --git a/src/mcp/__tests__/pm-proposal-admin-gate.test.ts b/src/mcp/__tests__/pm-proposal-admin-gate.test.ts index 17762da5..82d2f899 100644 --- a/src/mcp/__tests__/pm-proposal-admin-gate.test.ts +++ b/src/mcp/__tests__/pm-proposal-admin-gate.test.ts @@ -111,6 +111,7 @@ describe("chorus_pm_reject_proposal — author gate uses proposal:admin, not rol proposalUuid, agentUuid, "not ready", + expect.anything(), ); }); diff --git a/src/mcp/__tests__/project-group-member-tools.test.ts b/src/mcp/__tests__/project-group-member-tools.test.ts new file mode 100644 index 00000000..32d189f0 --- /dev/null +++ b/src/mcp/__tests__/project-group-member-tools.test.ts @@ -0,0 +1,275 @@ +// Tests for the project-group-visibility MCP surface (Tech Design §6): +// - the three new group member-management tools are mapped to the right +// permission in permission-map.ts (project:admin for mutations, project:read +// for list) +// - the guard rejects an inaccessible groupUuid: chorus_list_project_group_members +// returns an MCP error when canAccessGroup is false, and the mutating tools +// return an MCP error when canManageGroup is false. +// - chorus_admin_create_project_group passes visibility, owner (calling actor), +// and memberUuids through to the service. + +import { vi, describe, it, expect, beforeEach } from "vitest"; + +const mockProjectAccess = vi.hoisted(() => ({ + canAccessProject: vi.fn(), + canManageProject: vi.fn(), + claimOrCanManageProject: vi.fn(), + canAccessGroup: vi.fn(), + canManageGroup: vi.fn(), + claimOrCanManageGroup: vi.fn(), +})); + +const mockProjectGroupService = vi.hoisted(() => ({ + createProjectGroup: vi.fn(), + listGroupMembers: vi.fn(), + addGroupMember: vi.fn(), + removeGroupMember: vi.fn(), + moveProjectToGroup: vi.fn(), + updateProjectGroup: vi.fn(), + deleteProjectGroup: vi.fn(), +})); + +vi.mock("@/lib/authz/project-access", () => mockProjectAccess); +vi.mock("@/services/project.service", () => ({})); +vi.mock("@/services/proposal.service", () => ({})); +vi.mock("@/services/task.service", () => ({})); +vi.mock("@/services/idea.service", () => ({})); +vi.mock("@/services/document.service", () => ({})); +vi.mock("@/services/activity.service", () => ({})); +vi.mock("@/services/project-group.service", () => mockProjectGroupService); + +type ToolHandler = (params: Record) => Promise<{ + content: Array<{ type: string; text: string }>; + isError?: boolean; +}>; +const toolHandlers: Record = {}; + +const fakeMcpServer = { + registerTool: (name: string, _meta: unknown, handler: ToolHandler) => { + toolHandlers[name] = handler; + }, +}; + +import type { AgentAuthContext } from "@/types/auth"; +import type { Permission } from "@/lib/authz/types"; +import { registerAdminTools } from "@/mcp/tools/admin"; +import { TOOL_PERMISSIONS } from "@/mcp/tools/permission-map"; + +const companyUuid = "company-1"; +const actorUuid = "agent-1"; +const groupUuid = "group-1"; + +function buildAuth(permissions: Permission[]): AgentAuthContext { + return { + type: "agent", + companyUuid, + actorUuid, + roles: [], + permissions, + agentName: "admin", + }; +} + +function registerWith(auth: AgentAuthContext) { + for (const k of Object.keys(toolHandlers)) delete toolHandlers[k]; + registerAdminTools( + fakeMcpServer as unknown as Parameters[0], + auth, + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockProjectAccess.canAccessGroup.mockResolvedValue(true); + mockProjectAccess.canManageGroup.mockResolvedValue(true); + mockProjectAccess.claimOrCanManageGroup.mockResolvedValue(true); + mockProjectGroupService.listGroupMembers.mockResolvedValue([]); + mockProjectGroupService.addGroupMember.mockResolvedValue({ uuid: "m-1" }); + mockProjectGroupService.removeGroupMember.mockResolvedValue(true); + mockProjectGroupService.updateProjectGroup.mockResolvedValue({ uuid: "group-1", name: "G" }); + mockProjectGroupService.deleteProjectGroup.mockResolvedValue(true); + mockProjectGroupService.createProjectGroup.mockResolvedValue({ + uuid: "g-new", + name: "G", + description: "", + visibility: "private", + projectCount: 0, + createdAt: "now", + updatedAt: "now", + }); +}); + +describe("project group member tools — permission-map wiring", () => { + it("maps the mutating member tools to project:admin and the list tool to project:read", () => { + const map = TOOL_PERMISSIONS as Record; + expect(map.chorus_admin_add_project_group_member).toBe("project:admin"); + expect(map.chorus_admin_remove_project_group_member).toBe("project:admin"); + expect(map.chorus_list_project_group_members).toBe("project:read"); + }); + + it("registers the group member tools only when the gating permission is present", () => { + registerWith(buildAuth(["project:admin", "project:read"])); + expect(toolHandlers.chorus_admin_add_project_group_member).toBeDefined(); + expect(toolHandlers.chorus_admin_remove_project_group_member).toBeDefined(); + expect(toolHandlers.chorus_list_project_group_members).toBeDefined(); + + registerWith(buildAuth(["project:write"])); + expect(toolHandlers.chorus_admin_add_project_group_member).toBeUndefined(); + expect(toolHandlers.chorus_admin_remove_project_group_member).toBeUndefined(); + expect(toolHandlers.chorus_list_project_group_members).toBeUndefined(); + }); +}); + +describe("project group member tools — access guards", () => { + it("chorus_list_project_group_members rejects an inaccessible group (canAccessGroup=false)", async () => { + registerWith(buildAuth(["project:read"])); + mockProjectAccess.canAccessGroup.mockResolvedValue(false); + + const res = await toolHandlers.chorus_list_project_group_members({ groupUuid }); + + expect(res.isError).toBe(true); + expect(res.content[0].text).toMatch(/access denied/i); + expect(mockProjectGroupService.listGroupMembers).not.toHaveBeenCalled(); + }); + + it("chorus_list_project_group_members returns members when access is granted", async () => { + registerWith(buildAuth(["project:read"])); + mockProjectGroupService.listGroupMembers.mockResolvedValue([ + { uuid: "m-1", memberType: "agent", memberUuid: actorUuid, role: "member", createdAt: "now" }, + ]); + + const res = await toolHandlers.chorus_list_project_group_members({ groupUuid }); + + expect(res.isError).toBeFalsy(); + expect(mockProjectGroupService.listGroupMembers).toHaveBeenCalledWith(companyUuid, groupUuid); + expect(res.content[0].text).toContain("m-1"); + }); + + it("chorus_admin_add_project_group_member rejects when canManageGroup=false", async () => { + registerWith(buildAuth(["project:admin"])); + mockProjectAccess.claimOrCanManageGroup.mockResolvedValue(false); + + const res = await toolHandlers.chorus_admin_add_project_group_member({ + groupUuid, + memberType: "user", + memberUuid: "user-9", + }); + + expect(res.isError).toBe(true); + expect(res.content[0].text).toMatch(/access denied/i); + expect(mockProjectGroupService.addGroupMember).not.toHaveBeenCalled(); + }); + + it("chorus_admin_add_project_group_member adds the member when the actor can manage", async () => { + registerWith(buildAuth(["project:admin"])); + + const res = await toolHandlers.chorus_admin_add_project_group_member({ + groupUuid, + memberType: "user", + memberUuid: "user-9", + }); + + expect(res.isError).toBeFalsy(); + expect(mockProjectGroupService.addGroupMember).toHaveBeenCalledWith( + companyUuid, + groupUuid, + "user", + "user-9", + ); + }); + + it("chorus_admin_remove_project_group_member rejects when canManageGroup=false", async () => { + registerWith(buildAuth(["project:admin"])); + mockProjectAccess.claimOrCanManageGroup.mockResolvedValue(false); + + const res = await toolHandlers.chorus_admin_remove_project_group_member({ + groupUuid, + memberType: "agent", + memberUuid: "agent-9", + }); + + expect(res.isError).toBe(true); + expect(mockProjectGroupService.removeGroupMember).not.toHaveBeenCalled(); + }); + + it("chorus_admin_remove_project_group_member removes the member when the actor can manage", async () => { + registerWith(buildAuth(["project:admin"])); + + const res = await toolHandlers.chorus_admin_remove_project_group_member({ + groupUuid, + memberType: "agent", + memberUuid: "agent-9", + }); + + expect(res.isError).toBeFalsy(); + expect(mockProjectGroupService.removeGroupMember).toHaveBeenCalledWith( + companyUuid, + groupUuid, + "agent", + "agent-9", + ); + }); +}); + +describe("chorus_admin_create_project_group — visibility + ownership", () => { + it("passes visibility, owner (calling actor), and memberUuids to the service", async () => { + registerWith(buildAuth(["project:write"])); + + const res = await toolHandlers.chorus_admin_create_project_group({ + name: "G", + visibility: "private", + memberUuids: [{ memberType: "agent", memberUuid: "agent-2" }], + }); + + expect(res.isError).toBeFalsy(); + expect(mockProjectGroupService.createProjectGroup).toHaveBeenCalledWith( + expect.objectContaining({ + companyUuid, + name: "G", + visibility: "private", + ownerType: "agent", + ownerUuid: actorUuid, + memberUuids: [{ memberType: "agent", memberUuid: "agent-2" }], + }), + ); + }); +}); + +describe("chorus_admin_update_project_group / delete_project_group — manage gate", () => { + it("update rejects a non-owner (canManageGroup=false) without updating", async () => { + registerWith(buildAuth(["project:write"])); + mockProjectAccess.claimOrCanManageGroup.mockResolvedValue(false); + + const res = await toolHandlers.chorus_admin_update_project_group({ groupUuid, name: "X" }); + expect(res.isError).toBe(true); + expect(res.content[0].text).toMatch(/not found or access denied/i); + expect(mockProjectGroupService.updateProjectGroup).not.toHaveBeenCalled(); + }); + + it("update succeeds for the owner (canManageGroup=true)", async () => { + registerWith(buildAuth(["project:write"])); + mockProjectAccess.claimOrCanManageGroup.mockResolvedValue(true); + + const res = await toolHandlers.chorus_admin_update_project_group({ groupUuid, name: "X" }); + expect(res.isError).toBeFalsy(); + expect(mockProjectGroupService.updateProjectGroup).toHaveBeenCalled(); + }); + + it("delete rejects a non-owner without deleting", async () => { + registerWith(buildAuth(["project:write"])); + mockProjectAccess.claimOrCanManageGroup.mockResolvedValue(false); + + const res = await toolHandlers.chorus_admin_delete_project_group({ groupUuid }); + expect(res.isError).toBe(true); + expect(mockProjectGroupService.deleteProjectGroup).not.toHaveBeenCalled(); + }); + + it("delete succeeds for the owner", async () => { + registerWith(buildAuth(["project:write"])); + mockProjectAccess.claimOrCanManageGroup.mockResolvedValue(true); + + const res = await toolHandlers.chorus_admin_delete_project_group({ groupUuid }); + expect(res.isError).toBeFalsy(); + expect(mockProjectGroupService.deleteProjectGroup).toHaveBeenCalledWith(companyUuid, groupUuid); + }); +}); diff --git a/src/mcp/__tests__/project-member-tools.test.ts b/src/mcp/__tests__/project-member-tools.test.ts new file mode 100644 index 00000000..e3811d8f --- /dev/null +++ b/src/mcp/__tests__/project-member-tools.test.ts @@ -0,0 +1,242 @@ +// Tests for the project-visibility MCP surface (Tech Design §6): +// - the three new member-management tools are mapped to the right permission +// in permission-map.ts (project:admin for mutations, project:read for list) +// - the guard rejects an inaccessible projectUuid: chorus_list_project_members +// returns an MCP error when canAccessProject is false, and the mutating tools +// return an MCP error when canManageProject is false. + +import { vi, describe, it, expect, beforeEach } from "vitest"; + +const mockProjectAccess = vi.hoisted(() => ({ + canAccessProject: vi.fn(), + canManageProject: vi.fn(), + claimOrCanManageProject: vi.fn(), +})); + +const mockProjectService = vi.hoisted(() => ({ + listProjectMembers: vi.fn(), + addProjectMember: vi.fn(), + removeProjectMember: vi.fn(), + createProject: vi.fn(), +})); + +const mockProjectGroupService = vi.hoisted(() => ({ + moveProjectToGroup: vi.fn(), +})); + +vi.mock("@/lib/authz/project-access", () => mockProjectAccess); +vi.mock("@/services/project.service", () => mockProjectService); +vi.mock("@/services/proposal.service", () => ({})); +vi.mock("@/services/task.service", () => ({})); +vi.mock("@/services/idea.service", () => ({})); +vi.mock("@/services/document.service", () => ({})); +vi.mock("@/services/activity.service", () => ({})); +vi.mock("@/services/project-group.service", () => mockProjectGroupService); + +type ToolHandler = (params: Record) => Promise<{ + content: Array<{ type: string; text: string }>; + isError?: boolean; +}>; +const toolHandlers: Record = {}; + +const fakeMcpServer = { + registerTool: (name: string, _meta: unknown, handler: ToolHandler) => { + toolHandlers[name] = handler; + }, +}; + +import type { AgentAuthContext } from "@/types/auth"; +import type { Permission } from "@/lib/authz/types"; +import { registerAdminTools } from "@/mcp/tools/admin"; +import { TOOL_PERMISSIONS } from "@/mcp/tools/permission-map"; + +const companyUuid = "company-1"; +const actorUuid = "agent-1"; +const projectUuid = "project-1"; + +function buildAuth(permissions: Permission[]): AgentAuthContext { + return { + type: "agent", + companyUuid, + actorUuid, + roles: [], + permissions, + agentName: "admin", + }; +} + +function registerWith(auth: AgentAuthContext) { + for (const k of Object.keys(toolHandlers)) delete toolHandlers[k]; + registerAdminTools( + fakeMcpServer as unknown as Parameters[0], + auth, + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockProjectAccess.canAccessProject.mockResolvedValue(true); + mockProjectAccess.canManageProject.mockResolvedValue(true); + mockProjectAccess.claimOrCanManageProject.mockResolvedValue(true); + mockProjectService.listProjectMembers.mockResolvedValue([]); + mockProjectService.addProjectMember.mockResolvedValue({ uuid: "m-1" }); + mockProjectService.removeProjectMember.mockResolvedValue(true); + mockProjectGroupService.moveProjectToGroup.mockResolvedValue({ uuid: projectUuid, name: "P", groupUuid: null }); +}); + +describe("chorus_admin_move_project_to_group — visibility guard", () => { + it("rejects a non-manager (canManageProject=false) without moving", async () => { + registerWith(buildAuth(["project:write"])); + mockProjectAccess.claimOrCanManageProject.mockResolvedValue(false); + + const res = await toolHandlers.chorus_admin_move_project_to_group({ + projectUuid, + groupUuid: null, + }); + + expect(res.isError).toBe(true); + expect(res.content[0].text).toMatch(/access denied/i); + expect(mockProjectGroupService.moveProjectToGroup).not.toHaveBeenCalled(); + }); + + it("moves the project when the actor can manage it", async () => { + registerWith(buildAuth(["project:write"])); + mockProjectAccess.claimOrCanManageProject.mockResolvedValue(true); + + const res = await toolHandlers.chorus_admin_move_project_to_group({ + projectUuid, + groupUuid: null, + }); + + expect(res.isError).toBeFalsy(); + expect(mockProjectGroupService.moveProjectToGroup).toHaveBeenCalledWith( + companyUuid, + projectUuid, + null, + ); + }); +}); + +describe("project member tools — permission-map wiring", () => { + it("maps the mutating member tools to project:admin and the list tool to project:read", () => { + const map = TOOL_PERMISSIONS as Record; + expect(map.chorus_admin_add_project_member).toBe("project:admin"); + expect(map.chorus_admin_remove_project_member).toBe("project:admin"); + expect(map.chorus_list_project_members).toBe("project:read"); + }); + + it("registers the member tools only when the gating permission is present", () => { + registerWith(buildAuth(["project:admin", "project:read"])); + expect(toolHandlers.chorus_admin_add_project_member).toBeDefined(); + expect(toolHandlers.chorus_admin_remove_project_member).toBeDefined(); + expect(toolHandlers.chorus_list_project_members).toBeDefined(); + + registerWith(buildAuth(["project:write"])); + expect(toolHandlers.chorus_admin_add_project_member).toBeUndefined(); + expect(toolHandlers.chorus_admin_remove_project_member).toBeUndefined(); + expect(toolHandlers.chorus_list_project_members).toBeUndefined(); + }); +}); + +describe("project member tools — access guards", () => { + it("chorus_list_project_members rejects an inaccessible project (canAccessProject=false)", async () => { + registerWith(buildAuth(["project:read"])); + mockProjectAccess.canAccessProject.mockResolvedValue(false); + + const res = await toolHandlers.chorus_list_project_members({ projectUuid }); + + expect(res.isError).toBe(true); + expect(res.content[0].text).toMatch(/access denied/i); + expect(mockProjectService.listProjectMembers).not.toHaveBeenCalled(); + }); + + it("chorus_list_project_members returns members when access is granted", async () => { + registerWith(buildAuth(["project:read"])); + mockProjectService.listProjectMembers.mockResolvedValue([ + { uuid: "m-1", memberType: "agent", memberUuid: actorUuid, role: "member", createdAt: "now" }, + ]); + + const res = await toolHandlers.chorus_list_project_members({ projectUuid }); + + expect(res.isError).toBeFalsy(); + expect(mockProjectService.listProjectMembers).toHaveBeenCalledWith(companyUuid, projectUuid); + expect(res.content[0].text).toContain("m-1"); + }); + + it("chorus_admin_add_project_member rejects when canManageProject=false", async () => { + registerWith(buildAuth(["project:admin"])); + mockProjectAccess.claimOrCanManageProject.mockResolvedValue(false); + + const res = await toolHandlers.chorus_admin_add_project_member({ + projectUuid, + memberType: "user", + memberUuid: "user-9", + }); + + expect(res.isError).toBe(true); + expect(res.content[0].text).toMatch(/access denied/i); + expect(mockProjectService.addProjectMember).not.toHaveBeenCalled(); + }); + + it("chorus_admin_add_project_member adds the member when the actor can manage", async () => { + registerWith(buildAuth(["project:admin"])); + + const res = await toolHandlers.chorus_admin_add_project_member({ + projectUuid, + memberType: "user", + memberUuid: "user-9", + }); + + expect(res.isError).toBeFalsy(); + expect(mockProjectService.addProjectMember).toHaveBeenCalledWith( + companyUuid, + projectUuid, + "user", + "user-9", + ); + }); + + it("chorus_admin_remove_project_member rejects when canManageProject=false", async () => { + registerWith(buildAuth(["project:admin"])); + mockProjectAccess.claimOrCanManageProject.mockResolvedValue(false); + + const res = await toolHandlers.chorus_admin_remove_project_member({ + projectUuid, + memberType: "agent", + memberUuid: "agent-9", + }); + + expect(res.isError).toBe(true); + expect(mockProjectService.removeProjectMember).not.toHaveBeenCalled(); + }); +}); + +describe("chorus_admin_create_project — visibility + ownership", () => { + it("passes visibility, owner (calling actor), and memberUuids to the service", async () => { + registerWith(buildAuth(["project:write"])); + mockProjectService.createProject.mockResolvedValue({ + uuid: "p-new", + name: "P", + groupUuid: null, + visibility: "private", + }); + + const res = await toolHandlers.chorus_admin_create_project({ + name: "P", + visibility: "private", + memberUuids: [{ memberType: "agent", memberUuid: "agent-2" }], + }); + + expect(res.isError).toBeFalsy(); + expect(mockProjectService.createProject).toHaveBeenCalledWith( + expect.objectContaining({ + companyUuid, + name: "P", + visibility: "private", + ownerType: "agent", + ownerUuid: actorUuid, + memberUuids: [{ memberType: "agent", memberUuid: "agent-2" }], + }), + ); + }); +}); diff --git a/src/mcp/__tests__/public-tools-task-ops.test.ts b/src/mcp/__tests__/public-tools-task-ops.test.ts index eb8802b1..56da1447 100644 --- a/src/mcp/__tests__/public-tools-task-ops.test.ts +++ b/src/mcp/__tests__/public-tools-task-ops.test.ts @@ -112,6 +112,7 @@ describe("chorus_create_tasks", () => { title: "Fix bug", proposalUuid: null, }), + expect.anything(), ); expect(mockActivityService.createActivity).toHaveBeenCalledWith( @@ -144,6 +145,7 @@ describe("chorus_create_tasks", () => { expect(mockTaskService.createTask).toHaveBeenCalledWith( expect.objectContaining({ proposalUuid: "prop-1" }), + expect.anything(), ); expect(mockActivityService.createActivity).toHaveBeenCalledWith( @@ -222,6 +224,7 @@ describe("chorus_update_task", () => { expect(mockTaskService.updateTask).toHaveBeenCalledWith( "task-1", expect.objectContaining({ title: "New title", priority: "high" }), + expect.anything(), expect.objectContaining({ actorType: "agent" }), ); @@ -284,10 +287,10 @@ describe("chorus_update_task", () => { }); expect(mockTaskService.addTaskDependency).toHaveBeenCalledTimes(2); - expect(mockTaskService.addTaskDependency).toHaveBeenCalledWith("company-1", "task-1", "dep-1"); - expect(mockTaskService.addTaskDependency).toHaveBeenCalledWith("company-1", "task-1", "dep-2"); + expect(mockTaskService.addTaskDependency).toHaveBeenCalledWith("company-1", "task-1", "dep-1", expect.anything()); + expect(mockTaskService.addTaskDependency).toHaveBeenCalledWith("company-1", "task-1", "dep-2", expect.anything()); expect(mockTaskService.removeTaskDependency).toHaveBeenCalledTimes(1); - expect(mockTaskService.removeTaskDependency).toHaveBeenCalledWith("company-1", "task-1", "dep-3"); + expect(mockTaskService.removeTaskDependency).toHaveBeenCalledWith("company-1", "task-1", "dep-3", expect.anything()); }); it("returns error for nonexistent task", async () => { @@ -319,6 +322,7 @@ describe("chorus_update_task", () => { expect.objectContaining({ description: " new crit ", required: false }), expect.objectContaining({ description: " " }), ], + expect.anything(), ); expect(mockActivityService.createActivity).toHaveBeenCalledWith( expect.objectContaining({ @@ -363,7 +367,7 @@ describe("chorus_update_task", () => { addDependsOn: ["dep-1"], }); - expect(mockTaskService.addTaskDependency).toHaveBeenCalledWith("company-1", "task-1", "dep-1"); + expect(mockTaskService.addTaskDependency).toHaveBeenCalledWith("company-1", "task-1", "dep-1", expect.anything()); expect(mockTaskService.replaceAcceptanceCriteria).not.toHaveBeenCalled(); }); }); diff --git a/src/mcp/__tests__/server.test.ts b/src/mcp/__tests__/server.test.ts index 74e6fbc6..0cf87766 100644 --- a/src/mcp/__tests__/server.test.ts +++ b/src/mcp/__tests__/server.test.ts @@ -123,6 +123,24 @@ const OLD_DEVELOPER_TOOLS = [ "chorus_report_work", ]; +// Project-visibility member tools (Tech Design §6). chorus_list_project_members +// is gated on project:read, so EVERY preset (developer/pm/admin) carries it — +// the read bit is in all three presets. The two mutating member tools are gated +// on project:admin, so only admin_agent sees them. +// The group-visibility member tools mirror the project ones: the list tool is +// project:read-gated (all presets see it), the two mutators are project:admin- +// gated (only admin_agent sees them). +const PROJECT_READ_MEMBER_TOOLS = [ + "chorus_list_project_members", + "chorus_list_project_group_members", +]; +const PROJECT_ADMIN_MEMBER_TOOLS = [ + "chorus_admin_add_project_member", + "chorus_admin_remove_project_member", + "chorus_admin_add_project_group_member", + "chorus_admin_remove_project_group_member", +]; + const OLD_ADMIN_TOOLS = [ "chorus_admin_create_project", "chorus_admin_approve_proposal", @@ -156,9 +174,11 @@ describe("MCP tool permission wiring", () => { }); describe("backward-compat: developer_agent preset (AC4)", () => { - it("developer_agent with empty custom permissions sees exactly the 0.6.x developer tool set", () => { + it("developer_agent with empty custom permissions sees exactly the 0.6.x developer tool set plus the project:read member-list tool", () => { const registered = registeredFor([...ROLE_PRESETS.developer_agent]); - expect(registered).toEqual(new Set(OLD_DEVELOPER_TOOLS)); + expect(registered).toEqual( + new Set([...OLD_DEVELOPER_TOOLS, ...PROJECT_READ_MEMBER_TOOLS]), + ); }); }); @@ -195,6 +215,11 @@ describe("MCP tool permission wiring", () => { "chorus_admin_delete_task", "chorus_admin_delete_idea", "chorus_admin_delete_document", + // project:admin member tools — pm_agent has project:write but not project:admin. + "chorus_admin_add_project_member", + "chorus_admin_remove_project_member", + "chorus_admin_add_project_group_member", + "chorus_admin_remove_project_group_member", ]) { expect(registered.has(adminOnly)).toBe(false); } @@ -215,6 +240,10 @@ describe("MCP tool permission wiring", () => { // now idea:admin-gated (the simplified resolve action). admin_agent // carries idea:admin; pm_agent (idea:write only) does not. "chorus_pm_validate_elaboration", + // Project-visibility member tools (Tech Design §6). admin_agent holds + // project:read AND project:admin, so it sees all three member tools. + ...PROJECT_READ_MEMBER_TOOLS, + ...PROJECT_ADMIN_MEMBER_TOOLS, ]); expect(registered).toEqual(expected); }); diff --git a/src/mcp/__tests__/wave3-integration-smoke.test.ts b/src/mcp/__tests__/wave3-integration-smoke.test.ts index a6061706..5783f243 100644 --- a/src/mcp/__tests__/wave3-integration-smoke.test.ts +++ b/src/mcp/__tests__/wave3-integration-smoke.test.ts @@ -225,6 +225,7 @@ describe("Wave 3 — MCP tool surface convergence: integration smoke", () => { projectUuid: "project-1", proposalUuid: null, }), + expect.anything(), ); expect(mockActivityService.createActivity).toHaveBeenCalledWith( expect.objectContaining({ @@ -261,6 +262,7 @@ describe("Wave 3 — MCP tool surface convergence: integration smoke", () => { expect(result.isError).toBeFalsy(); expect(mockTaskService.createTask).toHaveBeenCalledWith( expect.objectContaining({ proposalUuid: "prop-1" }), + expect.anything(), ); expect(mockActivityService.createActivity).toHaveBeenCalledWith( expect.objectContaining({ @@ -298,6 +300,7 @@ describe("Wave 3 — MCP tool surface convergence: integration smoke", () => { "company-1", "task-B", "task-A", + expect.anything(), ); expect(mockActivityService.createActivity).toHaveBeenCalledWith( expect.objectContaining({ @@ -332,6 +335,7 @@ describe("Wave 3 — MCP tool surface convergence: integration smoke", () => { "company-1", "task-B", "task-A", + expect.anything(), ); expect(mockActivityService.createActivity).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/mcp/tools/admin.ts b/src/mcp/tools/admin.ts index 5c2c3045..8a1ad3f3 100644 --- a/src/mcp/tools/admin.ts +++ b/src/mcp/tools/admin.ts @@ -14,7 +14,7 @@ import * as documentService from "@/services/document.service"; import * as activityService from "@/services/activity.service"; import * as projectGroupService from "@/services/project-group.service"; import { zArray } from "./schema-utils"; -import { registerPermissionedTool } from "./register-helpers"; +import { registerPermissionedTool, assertProjectAccess, assertProjectManageOrClaim, assertGroupAccess, assertGroupManageOrClaim } from "./register-helpers"; export function registerAdminTools(server: McpServer, auth: AgentAuthContext) { // chorus_admin_create_project - Create a new project @@ -24,23 +24,35 @@ export function registerAdminTools(server: McpServer, auth: AgentAuthContext) { "project:write", "chorus_admin_create_project", { - description: "Create a new project (Admin exclusive, acts on behalf of humans). To assign to a project group, first call chorus_get_project_groups to list available groups, then pass the groupUuid.", + description: "Create a new project (Admin exclusive, acts on behalf of humans). Defaults to private visibility, owned by the calling actor (who is auto-added as a member). Pass visibility=\"shared\" to make it visible to everyone in the company, or supply memberUuids to grant other users/agents access to a private project. To assign to a project group, first call chorus_get_project_groups to list available groups, then pass the groupUuid.", inputSchema: z.object({ name: z.string().describe("Project name"), description: z.string().optional().describe("Project description"), groupUuid: z.string().optional().describe("Optional project group UUID to assign this project to. Use chorus_get_project_groups to list available groups."), + visibility: z.enum(["shared", "private"]).optional().describe("Project visibility. \"shared\" = visible to everyone in the company; \"private\" (default) = only the owner and explicit members."), + memberUuids: zArray(z.object({ + memberType: z.enum(["user", "agent"]).describe("Member actor type"), + memberUuid: z.string().describe("Member actor UUID"), + })).optional().describe("Optional initial members (users/agents) to grant access to a private project. The owner is added automatically."), }), }, - async ({ name, description, groupUuid }) => { + async ({ name, description, groupUuid, visibility, memberUuids }) => { + // MCP tools always run under an AgentAuthContext, so the calling actor is + // the agent itself and becomes the project owner (auto-added as a member + // by the service). The auth shape here is never super_admin. const project = await projectService.createProject({ companyUuid: auth.companyUuid, name, description: description || null, groupUuid: groupUuid || null, + visibility, + ownerType: auth.type, + ownerUuid: auth.actorUuid, + memberUuids, }); return { - content: [{ type: "text", text: JSON.stringify({ uuid: project.uuid, name: project.name, groupUuid: project.groupUuid }) }], + content: [{ type: "text", text: JSON.stringify({ uuid: project.uuid, name: project.name, groupUuid: project.groupUuid, visibility: project.visibility }) }], }; } ); @@ -74,7 +86,8 @@ export function registerAdminTools(server: McpServer, auth: AgentAuthContext) { proposalUuid, auth.companyUuid, auth.actorUuid, // Admin Agent as reviewer - reviewNote || null + reviewNote || null, + auth ); await activityService.createActivity({ @@ -124,7 +137,8 @@ export function registerAdminTools(server: McpServer, auth: AgentAuthContext) { const updated = await proposalService.closeProposal( proposalUuid, auth.actorUuid, - reviewNote + reviewNote, + auth ); await activityService.createActivity({ @@ -172,7 +186,7 @@ export function registerAdminTools(server: McpServer, auth: AgentAuthContext) { return { content: [{ type: "text", text: `Cannot verify task: ${gate.reason}` }], isError: true }; } - const updated = await taskService.updateTask(task.uuid, { status: "done" }); + const updated = await taskService.updateTask(task.uuid, { status: "done" }, auth); await activityService.createActivity({ companyUuid: auth.companyUuid, @@ -231,7 +245,7 @@ export function registerAdminTools(server: McpServer, auth: AgentAuthContext) { } } - const updated = await taskService.updateTask(task.uuid, { status: "in_progress" }); + const updated = await taskService.updateTask(task.uuid, { status: "in_progress" }, auth); // Log force_status_change activity when force is used if (force === true) { @@ -286,6 +300,7 @@ export function registerAdminTools(server: McpServer, auth: AgentAuthContext) { taskUuid, criteria, { type: auth.type, actorUuid: auth.actorUuid }, + auth, ); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; } @@ -313,7 +328,7 @@ export function registerAdminTools(server: McpServer, auth: AgentAuthContext) { return { content: [{ type: "text", text: "Task is already in closed status" }], isError: true }; } - const updated = await taskService.updateTask(task.uuid, { status: "closed" }); + const updated = await taskService.updateTask(task.uuid, { status: "closed" }, auth); await activityService.createActivity({ companyUuid: auth.companyUuid, @@ -349,7 +364,7 @@ export function registerAdminTools(server: McpServer, auth: AgentAuthContext) { return { content: [{ type: "text", text: "Idea not found" }], isError: true }; } - await ideaService.deleteIdea(ideaUuid); + await ideaService.deleteIdea(ideaUuid, auth); return { content: [{ type: "text", text: `Idea ${ideaUuid} deleted` }], @@ -375,7 +390,7 @@ export function registerAdminTools(server: McpServer, auth: AgentAuthContext) { return { content: [{ type: "text", text: "Task not found" }], isError: true }; } - await taskService.deleteTask(taskUuid); + await taskService.deleteTask(taskUuid, auth); return { content: [{ type: "text", text: `Task ${taskUuid} deleted` }], @@ -396,12 +411,12 @@ export function registerAdminTools(server: McpServer, auth: AgentAuthContext) { }), }, async ({ documentUuid }) => { - const doc = await documentService.getDocument(auth.companyUuid, documentUuid); + const doc = await documentService.getDocument(auth.companyUuid, documentUuid, auth); if (!doc) { return { content: [{ type: "text", text: "Document not found" }], isError: true }; } - await documentService.deleteDocument(documentUuid); + await documentService.deleteDocument(documentUuid, auth); return { content: [{ type: "text", text: `Document ${documentUuid} deleted` }], @@ -418,17 +433,29 @@ export function registerAdminTools(server: McpServer, auth: AgentAuthContext) { "project:write", "chorus_admin_create_project_group", { - description: "Create a new project group (Admin exclusive)", + description: "Create a new project group (Admin exclusive). Defaults to private visibility, owned by the calling actor (who is auto-added as a member). Pass visibility=\"shared\" to make it visible to everyone in the company, or supply memberUuids to grant other users/agents access to a private group.", inputSchema: z.object({ name: z.string().describe("Project group name"), description: z.string().optional().describe("Project group description"), + visibility: z.enum(["shared", "private"]).optional().describe("Group visibility. \"shared\" = visible to everyone in the company; \"private\" (default) = only the owner and explicit members."), + memberUuids: zArray(z.object({ + memberType: z.enum(["user", "agent"]).describe("Member actor type"), + memberUuid: z.string().describe("Member actor UUID"), + })).optional().describe("Optional initial members (users/agents) to grant access to a private group. The owner is added automatically."), }), }, - async ({ name, description }) => { + async ({ name, description, visibility, memberUuids }) => { + // MCP tools always run under an AgentAuthContext, so the calling actor is + // the agent itself and becomes the group owner (auto-added as a member by + // the service). The auth shape here is never super_admin. const group = await projectGroupService.createProjectGroup({ companyUuid: auth.companyUuid, name, description: description || null, + visibility, + ownerType: auth.type, + ownerUuid: auth.actorUuid, + memberUuids, }); return { @@ -452,6 +479,12 @@ export function registerAdminTools(server: McpServer, auth: AgentAuthContext) { }), }, async ({ groupUuid, name, description }) => { + // Visibility guard: only the group owner (or super admin) may rename/retag + // a group. assertGroupManage returns the same not-found-or-denied error for + // an inaccessible group — no existence leak. + const denied = await assertGroupManageOrClaim(auth, groupUuid); + if (denied) return denied; + const group = await projectGroupService.updateProjectGroup({ companyUuid: auth.companyUuid, groupUuid, @@ -482,6 +515,10 @@ export function registerAdminTools(server: McpServer, auth: AgentAuthContext) { }), }, async ({ groupUuid }) => { + // Visibility guard: only the group owner (or super admin) may delete it. + const denied = await assertGroupManageOrClaim(auth, groupUuid); + if (denied) return denied; + const deleted = await projectGroupService.deleteProjectGroup(auth.companyUuid, groupUuid); if (!deleted) { @@ -508,6 +545,12 @@ export function registerAdminTools(server: McpServer, auth: AgentAuthContext) { }), }, async ({ projectUuid, groupUuid }) => { + // Visibility guard: moving a project between groups is a structural change, + // so require management rights (owner / super admin). Non-members get the + // same not-found-or-denied error — no existence leak. + const denied = await assertProjectManageOrClaim(auth, projectUuid); + if (denied) return denied; + const result = await projectGroupService.moveProjectToGroup( auth.companyUuid, projectUuid, @@ -523,4 +566,186 @@ export function registerAdminTools(server: McpServer, auth: AgentAuthContext) { }; } ); + + // ===== Project Member Management (visibility feature, Tech Design §6) ===== + + // chorus_list_project_members - List members of a project + registerPermissionedTool( + server, + auth, + "project:read", + "chorus_list_project_members", + { + description: "List the members (users and agents) of a project. Requires access to the project.", + inputSchema: z.object({ + projectUuid: z.string().describe("Project UUID"), + }), + }, + async ({ projectUuid }) => { + const denied = await assertProjectAccess(auth, projectUuid); + if (denied) return denied; + + const members = await projectService.listProjectMembers(auth.companyUuid, projectUuid); + return { + content: [{ type: "text", text: JSON.stringify({ members }, null, 2) }], + }; + } + ); + + // chorus_admin_add_project_member - Add a member to a project + registerPermissionedTool( + server, + auth, + "project:admin", + "chorus_admin_add_project_member", + { + description: "Add a member (user or agent) to a project, granting them access to a private project. Only the project owner (or super admin) can manage members.", + inputSchema: z.object({ + projectUuid: z.string().describe("Project UUID"), + memberType: z.enum(["user", "agent"]).describe("Member actor type"), + memberUuid: z.string().describe("Member actor UUID"), + }), + }, + async ({ projectUuid, memberType, memberUuid }) => { + const denied = await assertProjectManageOrClaim(auth, projectUuid); + if (denied) return denied; + + const member = await projectService.addProjectMember( + auth.companyUuid, + projectUuid, + memberType, + memberUuid + ); + if (!member) { + return { content: [{ type: "text", text: "Project not found" }], isError: true }; + } + return { + content: [{ type: "text", text: JSON.stringify({ member }, null, 2) }], + }; + } + ); + + // chorus_admin_remove_project_member - Remove a member from a project + registerPermissionedTool( + server, + auth, + "project:admin", + "chorus_admin_remove_project_member", + { + description: "Remove a member (user or agent) from a project. The owner cannot be removed. Only the project owner (or super admin) can manage members.", + inputSchema: z.object({ + projectUuid: z.string().describe("Project UUID"), + memberType: z.enum(["user", "agent"]).describe("Member actor type"), + memberUuid: z.string().describe("Member actor UUID"), + }), + }, + async ({ projectUuid, memberType, memberUuid }) => { + const denied = await assertProjectManageOrClaim(auth, projectUuid); + if (denied) return denied; + + const removed = await projectService.removeProjectMember( + auth.companyUuid, + projectUuid, + memberType, + memberUuid + ); + if (!removed) { + return { content: [{ type: "text", text: "Member not found or cannot be removed (owner)" }], isError: true }; + } + return { + content: [{ type: "text", text: JSON.stringify({ projectUuid, memberType, memberUuid, removed: true }, null, 2) }], + }; + } + ); + + // ===== Project Group Member Management (visibility feature, Tech Design §6) ===== + + // chorus_list_project_group_members - List members of a project group + registerPermissionedTool( + server, + auth, + "project:read", + "chorus_list_project_group_members", + { + description: "List the members (users and agents) of a project group. Requires access to the group.", + inputSchema: z.object({ + groupUuid: z.string().describe("Project Group UUID"), + }), + }, + async ({ groupUuid }) => { + const denied = await assertGroupAccess(auth, groupUuid); + if (denied) return denied; + + const members = await projectGroupService.listGroupMembers(auth.companyUuid, groupUuid); + return { + content: [{ type: "text", text: JSON.stringify({ members }, null, 2) }], + }; + } + ); + + // chorus_admin_add_project_group_member - Add a member to a project group + registerPermissionedTool( + server, + auth, + "project:admin", + "chorus_admin_add_project_group_member", + { + description: "Add a member (user or agent) to a project group, granting them access to a private group (and, by inheritance, its projects). Only the group owner (or super admin) can manage members.", + inputSchema: z.object({ + groupUuid: z.string().describe("Project Group UUID"), + memberType: z.enum(["user", "agent"]).describe("Member actor type"), + memberUuid: z.string().describe("Member actor UUID"), + }), + }, + async ({ groupUuid, memberType, memberUuid }) => { + const denied = await assertGroupManageOrClaim(auth, groupUuid); + if (denied) return denied; + + const member = await projectGroupService.addGroupMember( + auth.companyUuid, + groupUuid, + memberType, + memberUuid + ); + if (!member) { + return { content: [{ type: "text", text: "Project group not found" }], isError: true }; + } + return { + content: [{ type: "text", text: JSON.stringify({ member }, null, 2) }], + }; + } + ); + + // chorus_admin_remove_project_group_member - Remove a member from a project group + registerPermissionedTool( + server, + auth, + "project:admin", + "chorus_admin_remove_project_group_member", + { + description: "Remove a member (user or agent) from a project group. The owner cannot be removed. Only the group owner (or super admin) can manage members.", + inputSchema: z.object({ + groupUuid: z.string().describe("Project Group UUID"), + memberType: z.enum(["user", "agent"]).describe("Member actor type"), + memberUuid: z.string().describe("Member actor UUID"), + }), + }, + async ({ groupUuid, memberType, memberUuid }) => { + const denied = await assertGroupManageOrClaim(auth, groupUuid); + if (denied) return denied; + + const removed = await projectGroupService.removeGroupMember( + auth.companyUuid, + groupUuid, + memberType, + memberUuid + ); + if (!removed) { + return { content: [{ type: "text", text: "Member not found or cannot be removed (owner)" }], isError: true }; + } + return { + content: [{ type: "text", text: JSON.stringify({ groupUuid, memberType, memberUuid, removed: true }, null, 2) }], + }; + } + ); } diff --git a/src/mcp/tools/developer.ts b/src/mcp/tools/developer.ts index 217172a4..2c85f467 100644 --- a/src/mcp/tools/developer.ts +++ b/src/mcp/tools/developer.ts @@ -38,7 +38,7 @@ export function registerDeveloperTools(server: McpServer, auth: AgentAuthContext companyUuid: auth.companyUuid, assigneeType: "agent", assigneeUuid: auth.actorUuid, - }); + }, auth); await activityService.createActivity({ companyUuid: auth.companyUuid, @@ -52,7 +52,7 @@ export function registerDeveloperTools(server: McpServer, auth: AgentAuthContext }); // Fetch full task details with dependencies - const fullTask = await taskService.getTask(auth.companyUuid, task.uuid); + const fullTask = await taskService.getTask(auth.companyUuid, task.uuid, auth); // Build compact response with only essential fields const compact: Record = { @@ -132,7 +132,7 @@ export function registerDeveloperTools(server: McpServer, auth: AgentAuthContext } try { - const updated = await taskService.releaseTask(task.uuid); + const updated = await taskService.releaseTask(task.uuid, auth); await activityService.createActivity({ companyUuid: auth.companyUuid, @@ -190,7 +190,7 @@ export function registerDeveloperTools(server: McpServer, auth: AgentAuthContext return { content: [{ type: "text", text: "Can only submit for verification from in_progress status" }], isError: true }; } - const updated = await taskService.updateTask(task.uuid, { status: "to_verify" }); + const updated = await taskService.updateTask(task.uuid, { status: "to_verify" }, auth); // Log activity await activityService.createActivity({ @@ -241,6 +241,7 @@ export function registerDeveloperTools(server: McpServer, auth: AgentAuthContext taskUuid, criteria, { type: auth.type, actorUuid: auth.actorUuid }, + auth, ); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; } @@ -288,7 +289,7 @@ export function registerDeveloperTools(server: McpServer, auth: AgentAuthContext // Update status if requested if (status && taskService.isValidTaskStatusTransition(task.status, status)) { - await taskService.updateTask(task.uuid, { status }); + await taskService.updateTask(task.uuid, { status }, auth); } // Write comment @@ -299,6 +300,7 @@ export function registerDeveloperTools(server: McpServer, auth: AgentAuthContext content: report, authorType: "agent", authorUuid: auth.actorUuid, + auth, }); // Log activity diff --git a/src/mcp/tools/permission-map.ts b/src/mcp/tools/permission-map.ts index efee460d..c8390ab9 100644 --- a/src/mcp/tools/permission-map.ts +++ b/src/mcp/tools/permission-map.ts @@ -72,6 +72,19 @@ export const TOOL_PERMISSIONS = { chorus_admin_update_project_group: "project:write", chorus_admin_delete_project_group: "project:write", chorus_admin_move_project_to_group: "project:write", + // Project member management (visibility feature, Tech Design §6). + // The two mutating member tools are owner-gated at the service/guard layer + // (canManageProject) but exposed under project:admin so plain project:write + // agents don't see them. The list tool is project:read-gated. + chorus_admin_add_project_member: "project:admin", + chorus_admin_remove_project_member: "project:admin", + chorus_list_project_members: "project:read", + // Project-group member management (group visibility, Tech Design §6). Same + // gating shape as the project member tools: list under project:read, the two + // mutators owner-gated (canManageGroup) but exposed under project:admin. + chorus_admin_add_project_group_member: "project:admin", + chorus_admin_remove_project_group_member: "project:admin", + chorus_list_project_group_members: "project:read", // Proposal admin (approve + admin-only close) chorus_admin_approve_proposal: "proposal:admin", chorus_admin_close_proposal: "proposal:admin", diff --git a/src/mcp/tools/pm.ts b/src/mcp/tools/pm.ts index c5fa4563..1cf5120a 100644 --- a/src/mcp/tools/pm.ts +++ b/src/mcp/tools/pm.ts @@ -44,7 +44,7 @@ export function registerPmTools(server: McpServer, auth: AgentAuthContext) { companyUuid: auth.companyUuid, assigneeType: "agent", assigneeUuid: auth.actorUuid, - }); + }, auth); await activityService.createActivity({ companyUuid: auth.companyUuid, @@ -97,7 +97,7 @@ export function registerPmTools(server: McpServer, auth: AgentAuthContext) { } try { - const updated = await ideaService.releaseIdea(idea.uuid); + const updated = await ideaService.releaseIdea(idea.uuid, auth); await activityService.createActivity({ companyUuid: auth.companyUuid, @@ -139,7 +139,7 @@ export function registerPmTools(server: McpServer, auth: AgentAuthContext) { }, async ({ projectUuid, title, description, inputType, inputUuids }) => { // Validate project exists - if (!(await projectExists(auth.companyUuid, projectUuid))) { + if (!(await projectExists(auth.companyUuid, projectUuid, auth))) { return { content: [{ type: "text", text: "Project not found" }], isError: true }; } @@ -178,7 +178,7 @@ export function registerPmTools(server: McpServer, auth: AgentAuthContext) { inputUuids, createdByUuid: auth.actorUuid, createdByType: "agent", - }); + }, auth); return { content: [{ type: "text", text: JSON.stringify({ uuid: proposal.uuid, title: proposal.title, status: proposal.status }, null, 2) + reusedWarning }], @@ -202,7 +202,8 @@ export function registerPmTools(server: McpServer, auth: AgentAuthContext) { try { const result = await proposalService.validateProposal( auth.companyUuid, - proposalUuid + proposalUuid, + auth ); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], @@ -232,7 +233,8 @@ export function registerPmTools(server: McpServer, auth: AgentAuthContext) { try { const proposal = await proposalService.submitProposal( proposalUuid, - auth.companyUuid + auth.companyUuid, + auth ); return { content: [{ type: "text", text: JSON.stringify({ uuid: proposal.uuid, status: proposal.status }, null, 2) }], @@ -264,7 +266,7 @@ export function registerPmTools(server: McpServer, auth: AgentAuthContext) { }, async ({ projectUuid, type, title, content, proposalUuid }) => { // Validate project exists - if (!(await projectExists(auth.companyUuid, projectUuid))) { + if (!(await projectExists(auth.companyUuid, projectUuid, auth))) { return { content: [{ type: "text", text: "Project not found" }], isError: true }; } @@ -284,7 +286,7 @@ export function registerPmTools(server: McpServer, auth: AgentAuthContext) { content: content || null, proposalUuid: proposalUuid || null, createdByUuid: auth.actorUuid, - }); + }, auth); return { content: [{ type: "text", text: JSON.stringify({ uuid: document.uuid, title: document.title, type: document.type }, null, 2) }], @@ -307,7 +309,7 @@ export function registerPmTools(server: McpServer, auth: AgentAuthContext) { }), }, async ({ documentUuid, title, content }) => { - const doc = await documentService.getDocument(auth.companyUuid, documentUuid); + const doc = await documentService.getDocument(auth.companyUuid, documentUuid, auth); if (!doc) { return { content: [{ type: "text", text: "Document not found" }], isError: true }; } @@ -316,7 +318,7 @@ export function registerPmTools(server: McpServer, auth: AgentAuthContext) { title, content, incrementVersion: true, - }); + }, auth); return { content: [{ type: "text", text: JSON.stringify({ uuid: updated.uuid, version: updated.version }, null, 2) }], @@ -346,7 +348,8 @@ export function registerPmTools(server: McpServer, auth: AgentAuthContext) { const proposal = await proposalService.addDocumentDraft( proposalUuid, auth.companyUuid, - { type, title, content } + { type, title, content }, + auth ); const documentDrafts = proposal.documentDrafts as Array<{ uuid: string; title: string }> | null; const newDraft = documentDrafts?.[documentDrafts.length - 1]; @@ -388,7 +391,8 @@ export function registerPmTools(server: McpServer, auth: AgentAuthContext) { const proposal = await proposalService.addTaskDraft( proposalUuid, auth.companyUuid, - { title, description, storyPoints, priority, acceptanceCriteriaItems, dependsOnDraftUuids } + { title, description, storyPoints, priority, acceptanceCriteriaItems, dependsOnDraftUuids }, + auth ); const taskDrafts = proposal.taskDrafts as Array<{ uuid: string; title: string }> | null; const newDraft = taskDrafts?.[taskDrafts.length - 1]; @@ -431,7 +435,8 @@ export function registerPmTools(server: McpServer, auth: AgentAuthContext) { proposalUuid, auth.companyUuid, draftUuid, - updates + updates, + auth ); return { content: [{ type: "text", text: JSON.stringify({ proposalUuid: proposal.uuid, draftUuid, action: "document_draft_updated" }, null, 2) }], @@ -481,7 +486,8 @@ export function registerPmTools(server: McpServer, auth: AgentAuthContext) { proposalUuid, auth.companyUuid, draftUuid, - updates + updates, + auth ); return { content: [{ type: "text", text: JSON.stringify({ proposalUuid: proposal.uuid, draftUuid, action: "task_draft_updated" }, null, 2) }], @@ -513,7 +519,8 @@ export function registerPmTools(server: McpServer, auth: AgentAuthContext) { const proposal = await proposalService.removeDocumentDraft( proposalUuid, auth.companyUuid, - draftUuid + draftUuid, + auth ); return { content: [{ type: "text", text: JSON.stringify({ proposalUuid: proposal.uuid, draftUuid, action: "document_draft_removed" }, null, 2) }], @@ -545,7 +552,8 @@ export function registerPmTools(server: McpServer, auth: AgentAuthContext) { const proposal = await proposalService.removeTaskDraft( proposalUuid, auth.companyUuid, - draftUuid + draftUuid, + auth ); return { content: [{ type: "text", text: JSON.stringify({ proposalUuid: proposal.uuid, draftUuid, action: "task_draft_removed" }, null, 2) }], @@ -614,7 +622,7 @@ export function registerPmTools(server: McpServer, auth: AgentAuthContext) { assigneeType: "agent", assigneeUuid: agentUuid, assignedByUuid: auth.actorUuid, - }); + }, auth); // Log activity await activityService.createActivity({ @@ -629,7 +637,7 @@ export function registerPmTools(server: McpServer, auth: AgentAuthContext) { }); // Fetch full task details with dependencies - const fullTask = await taskService.getTask(auth.companyUuid, task.uuid); + const fullTask = await taskService.getTask(auth.companyUuid, task.uuid, auth); // Build compact response with only essential fields const compact: Record = { @@ -823,7 +831,8 @@ export function registerPmTools(server: McpServer, auth: AgentAuthContext) { ideaUuid, targetProjectUuid, auth.actorUuid, - auth.type + auth.type, + auth ); // Surface both the updated idea identity and the cascade counts so @@ -876,7 +885,8 @@ export function registerPmTools(server: McpServer, auth: AgentAuthContext) { const updated = await proposalService.rejectProposal( proposalUuid, auth.actorUuid, - reviewNote + reviewNote, + auth ); await activityService.createActivity({ @@ -927,7 +937,8 @@ export function registerPmTools(server: McpServer, auth: AgentAuthContext) { proposal.uuid, auth.companyUuid, auth.actorUuid, - reviewNote + reviewNote, + auth ); await activityService.createActivity({ @@ -971,7 +982,7 @@ export function registerPmTools(server: McpServer, auth: AgentAuthContext) { }), }, async ({ projectUuid, title, content }) => { - const exists = await projectExists(auth.companyUuid, projectUuid); + const exists = await projectExists(auth.companyUuid, projectUuid, auth); if (!exists) { return { content: [{ type: "text", text: "Project not found" }], isError: true }; } @@ -982,7 +993,7 @@ export function registerPmTools(server: McpServer, auth: AgentAuthContext) { title, content: content || null, createdByUuid: auth.actorUuid, - }); + }, auth); return { content: [{ type: "text", text: JSON.stringify({ uuid: idea.uuid, title: idea.title }) }], diff --git a/src/mcp/tools/public.ts b/src/mcp/tools/public.ts index 5c69bfd2..32457ee4 100644 --- a/src/mcp/tools/public.ts +++ b/src/mcp/tools/public.ts @@ -40,7 +40,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { }), }, async ({ projectUuid }) => { - const project = await projectService.getProjectByUuid(auth.companyUuid, projectUuid); + const project = await projectService.getProjectByUuid(auth.companyUuid, projectUuid, auth); if (!project) { return { content: [{ type: "text", text: "Project not found" }], isError: true }; } @@ -66,6 +66,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { companyUuid: auth.companyUuid, skip, take: pageSize, + auth, }); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], @@ -87,7 +88,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { }, async ({ projectUuid, status, page = 1, pageSize = 20 }) => { // Verify project exists - const project = await projectService.getProjectByUuid(auth.companyUuid, projectUuid); + const project = await projectService.getProjectByUuid(auth.companyUuid, projectUuid, auth); if (!project) { return { content: [{ type: "text", text: "Project not found" }], isError: true }; } @@ -99,6 +100,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { skip, take: pageSize, status, + auth, }); return { @@ -121,7 +123,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { }, async ({ projectUuid, type, page = 1, pageSize = 20 }) => { // Verify project exists - const project = await projectService.getProjectByUuid(auth.companyUuid, projectUuid); + const project = await projectService.getProjectByUuid(auth.companyUuid, projectUuid, auth); if (!project) { return { content: [{ type: "text", text: "Project not found" }], isError: true }; } @@ -133,6 +135,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { skip, take: pageSize, type, + auth, }); return { @@ -151,7 +154,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { }), }, async ({ documentUuid }) => { - const document = await documentService.getDocument(auth.companyUuid, documentUuid); + const document = await documentService.getDocument(auth.companyUuid, documentUuid, auth); if (!document) { return { content: [{ type: "text", text: "Document not found" }], isError: true }; } @@ -175,7 +178,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { }, async ({ projectUuid, status, page = 1, pageSize = 20 }) => { // Verify project exists - const project = await projectService.getProjectByUuid(auth.companyUuid, projectUuid); + const project = await projectService.getProjectByUuid(auth.companyUuid, projectUuid, auth); if (!project) { return { content: [{ type: "text", text: "Project not found" }], isError: true }; } @@ -187,6 +190,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { skip, take: pageSize, status, + auth, }); return { @@ -205,7 +209,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { }), }, async ({ taskUuid }) => { - const task = await taskService.getTask(auth.companyUuid, taskUuid); + const task = await taskService.getTask(auth.companyUuid, taskUuid, auth); if (!task) { return { content: [{ type: "text", text: "Task not found" }], isError: true }; } @@ -231,7 +235,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { }, async ({ projectUuid, status, priority, proposalUuids, page = 1, pageSize = 20 }) => { // Verify project exists - const project = await projectService.getProjectByUuid(auth.companyUuid, projectUuid); + const project = await projectService.getProjectByUuid(auth.companyUuid, projectUuid, auth); if (!project) { return { content: [{ type: "text", text: "Project not found" }], isError: true }; } @@ -245,6 +249,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { status, priority, proposalUuids, + auth, }); return { @@ -266,7 +271,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { }, async ({ projectUuid, page = 1, pageSize = 50 }) => { // Verify project exists - const project = await projectService.getProjectByUuid(auth.companyUuid, projectUuid); + const project = await projectService.getProjectByUuid(auth.companyUuid, projectUuid, auth); if (!project) { return { content: [{ type: "text", text: "Project not found" }], isError: true }; } @@ -277,6 +282,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { projectUuid, skip, take: pageSize, + auth, }); return { @@ -305,6 +311,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { content, authorType: "agent", authorUuid: auth.actorUuid, + auth, }); // Resolve projectUuid from the target entity @@ -377,7 +384,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { }, async ({ projectUuid }) => { // Verify project exists - const project = await projectService.getProjectByUuid(auth.companyUuid, projectUuid); + const project = await projectService.getProjectByUuid(auth.companyUuid, projectUuid, auth); if (!project) { return { content: [{ type: "text", text: "Project not found" }], isError: true }; } @@ -407,7 +414,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { }, async ({ projectUuid, proposalUuids }) => { // Verify project exists - const project = await projectService.getProjectByUuid(auth.companyUuid, projectUuid); + const project = await projectService.getProjectByUuid(auth.companyUuid, projectUuid, auth); if (!project) { return { content: [{ type: "text", text: "Project not found" }], isError: true }; } @@ -436,7 +443,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { }), }, async ({ ideaUuid }) => { - const idea = await ideaService.getIdea(auth.companyUuid, ideaUuid); + const idea = await ideaService.getIdea(auth.companyUuid, ideaUuid, auth); if (!idea) { return { content: [{ type: "text", text: "Idea not found" }], isError: true }; } @@ -474,7 +481,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { }, async ({ proposalUuid, section }) => { const view = section ?? "basic"; - const proposal = await proposalService.getProposalSection(auth.companyUuid, proposalUuid, view); + const proposal = await proposalService.getProposalSection(auth.companyUuid, proposalUuid, view, auth); if (!proposal) { return { content: [{ type: "text", text: "Proposal not found" }], isError: true }; } @@ -496,7 +503,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { }, async ({ projectUuid, proposalUuids }) => { // Verify project exists - const project = await projectService.getProjectByUuid(auth.companyUuid, projectUuid); + const project = await projectService.getProjectByUuid(auth.companyUuid, projectUuid, auth); if (!project) { return { content: [{ type: "text", text: "Project not found" }], isError: true }; } @@ -505,6 +512,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { companyUuid: auth.companyUuid, projectUuid, proposalUuids, + auth, }); return { @@ -533,6 +541,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { targetUuid, skip, take: pageSize, + auth, }); return { @@ -562,6 +571,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { readFilter: statusValue === "unread" ? "unread" : statusValue === "read" ? "read" : "all", skip: params.offset ?? 0, take: params.limit ?? 20, + auth, }); // Auto-mark fetched unread notifications as read @@ -683,7 +693,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { inputSchema: z.object({}), }, async () => { - const result = await projectGroupService.listProjectGroups(auth.companyUuid); + const result = await projectGroupService.listProjectGroups(auth.companyUuid, auth); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], }; @@ -700,7 +710,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { }), }, async ({ groupUuid }) => { - const group = await projectGroupService.getProjectGroup(auth.companyUuid, groupUuid); + const group = await projectGroupService.getProjectGroup(auth.companyUuid, groupUuid, auth); if (!group) { return { content: [{ type: "text", text: "Project group not found" }], isError: true }; } @@ -720,7 +730,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { }), }, async ({ groupUuid }) => { - const dashboard = await projectGroupService.getGroupDashboard(auth.companyUuid, groupUuid); + const dashboard = await projectGroupService.getGroupDashboard(auth.companyUuid, groupUuid, auth); if (!dashboard) { return { content: [{ type: "text", text: "Project group not found" }], isError: true }; } @@ -774,6 +784,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { scope, scopeUuid, entityTypes, + auth, }); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], @@ -812,7 +823,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { }), }, async ({ projectUuid, proposalUuid, tasks }) => { - if (!(await projectExists(auth.companyUuid, projectUuid))) { + if (!(await projectExists(auth.companyUuid, projectUuid, auth))) { return { content: [{ type: "text", text: "Project not found" }], isError: true }; } @@ -845,7 +856,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { storyPoints: task.storyPoints ?? null, proposalUuid: proposalUuid || null, createdByUuid: auth.actorUuid, - }) + }, auth) ) ); @@ -869,7 +880,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { continue; } try { - await taskService.addTaskDependency(auth.companyUuid, realUuid, depRealUuid); + await taskService.addTaskDependency(auth.companyUuid, realUuid, depRealUuid, auth); } catch (error) { warnings.push(`Task "${task.title}" -> draftUuid "${draftUuid}": ${error instanceof Error ? error.message : "unknown error"}`); } @@ -879,7 +890,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { if (task.dependsOnTaskUuids) { for (const depUuid of task.dependsOnTaskUuids) { try { - await taskService.addTaskDependency(auth.companyUuid, realUuid, depUuid); + await taskService.addTaskDependency(auth.companyUuid, realUuid, depUuid, auth); } catch (error) { warnings.push(`Task "${task.title}" -> taskUuid "${depUuid}": ${error instanceof Error ? error.message : "unknown error"}`); } @@ -1030,7 +1041,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { let updatedStatus = task.status; if (hasFieldUpdates) { - const updated = await taskService.updateTask(task.uuid, updateData, { + const updated = await taskService.updateTask(task.uuid, updateData, auth, { actorType: auth.type, actorUuid: auth.actorUuid, }); @@ -1043,7 +1054,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { if (addDependsOn) { for (const depUuid of addDependsOn) { try { - await taskService.addTaskDependency(auth.companyUuid, task.uuid, depUuid); + await taskService.addTaskDependency(auth.companyUuid, task.uuid, depUuid, auth); } catch (error) { warnings.push(`addDependsOn "${depUuid}": ${error instanceof Error ? error.message : "unknown error"}`); } @@ -1054,7 +1065,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { if (removeDependsOn) { for (const depUuid of removeDependsOn) { try { - await taskService.removeTaskDependency(auth.companyUuid, task.uuid, depUuid); + await taskService.removeTaskDependency(auth.companyUuid, task.uuid, depUuid, auth); } catch (error) { warnings.push(`removeDependsOn "${depUuid}": ${error instanceof Error ? error.message : "unknown error"}`); } @@ -1067,7 +1078,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { // verification marks, which is correct since the AC changed. let acReplaced = false; if (acceptanceCriteriaItems !== undefined) { - await taskService.replaceAcceptanceCriteria(auth.companyUuid, task.uuid, acceptanceCriteriaItems); + await taskService.replaceAcceptanceCriteria(auth.companyUuid, task.uuid, acceptanceCriteriaItems, auth); acReplaced = true; } @@ -1188,7 +1199,7 @@ export function registerPublicTools(server: McpServer, auth: AgentAuthContext) { content, proposalUuid, createdByUuid: auth.actorUuid, - }); + }, auth); return { content: [ diff --git a/src/mcp/tools/register-helpers.ts b/src/mcp/tools/register-helpers.ts index 70702a08..ee7d7c0d 100644 --- a/src/mcp/tools/register-helpers.ts +++ b/src/mcp/tools/register-helpers.ts @@ -6,6 +6,113 @@ import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; import type { AnySchema, ZodRawShapeCompat } from "@modelcontextprotocol/sdk/server/zod-compat.js"; import type { AgentAuthContext } from "@/types/auth"; import type { Permission } from "@/lib/authz/types"; +import { + canAccessProject, + canManageProject, + claimOrCanManageProject, + canAccessGroup, + canManageGroup, + claimOrCanManageGroup, + type AnyAuth, +} from "@/lib/authz/project-access"; + +/** Standard MCP error content shape returned when a project is inaccessible. */ +type McpErrorResult = { content: [{ type: "text"; text: string }]; isError: true }; + +function projectDeniedError(): McpErrorResult { + return { + content: [{ type: "text", text: "Project not found or access denied" }], + isError: true, + }; +} + +function groupDeniedError(): McpErrorResult { + return { + content: [{ type: "text", text: "Project group not found or access denied" }], + isError: true, + }; +} + +/** + * Project-visibility guard for MCP tools that take a `projectUuid` directly + * (i.e. tools that do NOT route through a gated service that already filters + * by accessible projects). Returns an MCP error content object when the actor + * cannot access the project, or null when access is granted. See Tech Design §6. + */ +export async function assertProjectAccess( + auth: AnyAuth, + projectUuid: string, +): Promise { + const allowed = await canAccessProject(auth, projectUuid); + return allowed ? null : projectDeniedError(); +} + +/** + * Project-management guard for MCP tools that mutate a project's membership or + * visibility. Restricted to the owner (or super admin). Returns an MCP error + * content object when the actor cannot manage the project, or null otherwise. + */ +export async function assertProjectManage( + auth: AnyAuth, + projectUuid: string, +): Promise { + const allowed = await canManageProject(auth, projectUuid); + return allowed ? null : projectDeniedError(); +} + +/** + * Claim-aware project-management guard. Identical to assertProjectManage but + * uses claimOrCanManageProject, so a NULL-owner project the actor can access is + * claimed (ownership assigned) on the first manage action. Use this at MUTATING + * entry points; the access-gated claim opens no privacy hole (see + * project-access.ts). Returns an MCP error when the actor cannot manage, else null. + */ +export async function assertProjectManageOrClaim( + auth: AnyAuth, + projectUuid: string, +): Promise { + const allowed = await claimOrCanManageProject(auth, projectUuid); + return allowed ? null : projectDeniedError(); +} + +/** + * Group-visibility guard for MCP tools that take a `groupUuid` directly. Returns + * an MCP error content object when the actor cannot access the group, or null + * when access is granted. Mirror of assertProjectAccess. See Tech Design §6. + */ +export async function assertGroupAccess( + auth: AnyAuth, + groupUuid: string, +): Promise { + const allowed = await canAccessGroup(auth, groupUuid); + return allowed ? null : groupDeniedError(); +} + +/** + * Group-management guard for MCP tools that mutate a group's membership or + * visibility. Restricted to the owner (or super admin). Mirror of + * assertProjectManage. + */ +export async function assertGroupManage( + auth: AnyAuth, + groupUuid: string, +): Promise { + const allowed = await canManageGroup(auth, groupUuid); + return allowed ? null : groupDeniedError(); +} + +/** + * Claim-aware group-management guard. Mirror of assertProjectManageOrClaim: + * uses claimOrCanManageGroup so a NULL-owner group the actor can access is + * claimed on the first manage action. Use this at MUTATING entry points. + */ +export async function assertGroupManageOrClaim( + auth: AnyAuth, + groupUuid: string, +): Promise { + const allowed = await claimOrCanManageGroup(auth, groupUuid); + return allowed ? null : groupDeniedError(); +} type ToolInputSchema = ZodRawShapeCompat | AnySchema | undefined; diff --git a/src/services/__tests__/activity.service.test.ts b/src/services/__tests__/activity.service.test.ts index d99ac1ca..291d2aea 100644 --- a/src/services/__tests__/activity.service.test.ts +++ b/src/services/__tests__/activity.service.test.ts @@ -7,6 +7,12 @@ const mockPrisma = vi.hoisted(() => ({ findMany: vi.fn(), count: vi.fn(), }, + project: { + findFirst: vi.fn(), + }, + projectMember: { + findUnique: vi.fn(), + }, })); vi.mock("@/lib/prisma", () => ({ prisma: mockPrisma })); @@ -36,6 +42,18 @@ const targetUuid = "task-0000-0000-0000-000000000001"; const actorUuid = "user-0000-0000-0000-000000000001"; const activityUuid = "activity-0000-0000-0000-000000000001"; +// Super-admin auth bypasses the project-visibility filter (canAccessProject +// returns true without touching prisma), keeping the existing query-shape +// assertions valid. +const superAdminAuth = { type: "super_admin" as const, email: "admin@chorus.local" }; + +// A regular user auth for visibility tests. +const userAuth = { + type: "user" as const, + companyUuid, + actorUuid, +}; + function makeActivity(overrides: Record = {}) { return { uuid: activityUuid, @@ -69,6 +87,7 @@ describe("listActivities", () => { const result = await listActivities({ companyUuid, projectUuid, + auth: superAdminAuth, skip: 0, take: 20, }); @@ -86,6 +105,7 @@ describe("listActivities", () => { await listActivities({ companyUuid, projectUuid, + auth: superAdminAuth, skip: 0, take: 20, targetType: "idea", @@ -105,6 +125,7 @@ describe("listActivities", () => { await listActivities({ companyUuid, projectUuid, + auth: superAdminAuth, skip: 0, take: 20, targetUuid: "specific-uuid", @@ -124,6 +145,7 @@ describe("listActivities", () => { await listActivities({ companyUuid, projectUuid, + auth: superAdminAuth, skip: 0, take: 20, }); @@ -142,6 +164,7 @@ describe("listActivities", () => { await listActivities({ companyUuid, projectUuid, + auth: superAdminAuth, skip: 10, take: 5, }); @@ -150,6 +173,29 @@ describe("listActivities", () => { expect.objectContaining({ skip: 10, take: 5 }) ); }); + + it("returns empty for a non-member of a private project (visibility gate)", async () => { + // Private project not owned by the user, with no membership row. + mockPrisma.project.findFirst.mockResolvedValue({ + visibility: "private", + ownerType: "user", + ownerUuid: "other-user", + }); + mockPrisma.projectMember.findUnique.mockResolvedValue(null); + + const result = await listActivities({ + companyUuid, + projectUuid, + skip: 0, + take: 20, + auth: userAuth, + }); + + expect(result.activities).toEqual([]); + expect(result.total).toBe(0); + // Gate short-circuits before any activity query. + expect(mockPrisma.activity.findMany).not.toHaveBeenCalled(); + }); }); // ===== listActivitiesWithActorNames ===== @@ -163,6 +209,7 @@ describe("listActivitiesWithActorNames", () => { const result = await listActivitiesWithActorNames({ companyUuid, projectUuid, + auth: superAdminAuth, skip: 0, take: 20, }); @@ -182,6 +229,7 @@ describe("listActivitiesWithActorNames", () => { const result = await listActivitiesWithActorNames({ companyUuid, projectUuid, + auth: superAdminAuth, skip: 0, take: 20, }); @@ -200,6 +248,7 @@ describe("listActivitiesWithActorNames", () => { const result = await listActivitiesWithActorNames({ companyUuid, projectUuid, + auth: superAdminAuth, skip: 0, take: 20, }); @@ -223,6 +272,7 @@ describe("listActivitiesWithActorNames", () => { const result = await listActivitiesWithActorNames({ companyUuid, projectUuid, + auth: superAdminAuth, skip: 0, take: 20, }); diff --git a/src/services/__tests__/checkin.service.test.ts b/src/services/__tests__/checkin.service.test.ts index f40b9759..db4d5b67 100644 --- a/src/services/__tests__/checkin.service.test.ts +++ b/src/services/__tests__/checkin.service.test.ts @@ -19,6 +19,15 @@ const { mockPrisma } = vi.hoisted(() => ({ project: { findMany: vi.fn(), }, + projectMember: { + findMany: vi.fn(), + }, + projectGroup: { + findMany: vi.fn(), + }, + projectGroupMember: { + findMany: vi.fn(), + }, }, })); @@ -97,6 +106,17 @@ beforeEach(() => { mockPrisma.proposal.findMany.mockResolvedValue([]); mockPrisma.task.findMany.mockResolvedValue([]); mockPrisma.project.findMany.mockResolvedValue([]); + // The agent is a member of both fixture projects, so the idea-tracker + // visibility gate (getAccessibleProjectUuids) treats every fixture project as + // accessible and existing checkin assertions are unaffected. + mockPrisma.projectMember.findMany.mockResolvedValue([ + { projectUuid: PROJECT_A }, + { projectUuid: PROJECT_B }, + ]); + // No group ownership/membership in checkin fixtures (access comes from the + // ProjectMember rows above); default the group queries to empty. + mockPrisma.projectGroup.findMany.mockResolvedValue([]); + mockPrisma.projectGroupMember.findMany.mockResolvedValue([]); mockNotificationService.list.mockResolvedValue(emptyNotifications()); mockNotificationService.markRead.mockResolvedValue({}); mockNotificationService.emitAgentCheckin.mockReturnValue(undefined); @@ -384,7 +404,10 @@ describe("buildCheckinResponse — ideaTracker", () => { expect(mockPrisma.idea.findMany).toHaveBeenCalledTimes(1); expect(mockPrisma.proposal.findMany).toHaveBeenCalledTimes(1); expect(mockPrisma.task.findMany).toHaveBeenCalledTimes(1); - expect(mockPrisma.project.findMany).toHaveBeenCalledTimes(1); + // project.findMany is now hit twice: once by the visibility gate + // (getAccessibleProjectUuids: shared/owned lookup) and once by the + // tracker's project-name resolution (Q4). + expect(mockPrisma.project.findMany).toHaveBeenCalledTimes(2); }); }); @@ -458,6 +481,7 @@ describe("buildCheckinResponse — notifications", () => { await buildCheckinResponse(auth); expect(mockNotificationService.list).toHaveBeenCalledWith({ + auth, companyUuid: COMPANY_UUID, recipientType: "agent", recipientUuid: AGENT_UUID, diff --git a/src/services/__tests__/comment.service.test.ts b/src/services/__tests__/comment.service.test.ts index 16d428de..48ff1c45 100644 --- a/src/services/__tests__/comment.service.test.ts +++ b/src/services/__tests__/comment.service.test.ts @@ -30,6 +30,12 @@ const mockPrisma = vi.hoisted(() => ({ user: { findMany: vi.fn(), }, + project: { + findFirst: vi.fn(), + }, + projectMember: { + findUnique: vi.fn(), + }, })); vi.mock("@/lib/prisma", () => ({ prisma: mockPrisma })); @@ -71,6 +77,17 @@ const targetUuid = "task-0000-0000-0000-000000000001"; const authorUuid = "user-0000-0000-0000-000000000001"; const commentUuid = "comment-0000-0000-0000-000000000001"; +// Super-admin auth: canAccessProject returns true without touching prisma, so +// the visibility gate is a no-op and existing comment behavior is unchanged. +const superAdminAuth = { type: "super_admin" as const, email: "admin@chorus.local" }; + +// Regular user auth for the denial tests. +const userAuth = { + type: "user" as const, + companyUuid, + actorUuid: authorUuid, +}; + function makeCommentRecord(overrides: Record = {}) { return { uuid: commentUuid, @@ -98,6 +115,7 @@ describe("createComment", () => { mockPrisma.comment.create.mockResolvedValue(record); const result = await createComment({ + auth: superAdminAuth, companyUuid, targetType: "task", targetUuid, @@ -119,6 +137,7 @@ describe("createComment", () => { await expect( createComment({ + auth: superAdminAuth, companyUuid, targetType: "task", targetUuid: "nonexistent", @@ -134,6 +153,7 @@ describe("createComment", () => { mockPrisma.comment.create.mockResolvedValue(makeCommentRecord()); const result = await createComment({ + auth: superAdminAuth, companyUuid, targetType: "task", targetUuid, @@ -152,6 +172,7 @@ describe("createComment", () => { mockPrisma.task.findFirst.mockResolvedValue({ projectUuid }); await createComment({ + auth: superAdminAuth, companyUuid, targetType: "task", targetUuid, @@ -185,6 +206,7 @@ describe("createComment", () => { ]); await createComment({ + auth: superAdminAuth, companyUuid, targetType: "task", targetUuid, @@ -236,6 +258,7 @@ describe("createComment", () => { ]); await createComment({ + auth: superAdminAuth, companyUuid, targetType: "task", targetUuid, @@ -262,6 +285,7 @@ describe("createComment", () => { ]); await createComment({ + auth: superAdminAuth, companyUuid, targetType: "task", targetUuid, @@ -282,6 +306,7 @@ describe("createComment", () => { (parseMentions as ReturnType).mockReturnValue([]); await createComment({ + auth: superAdminAuth, companyUuid, targetType: "task", targetUuid, @@ -309,6 +334,7 @@ describe("createComment", () => { (createMentions as ReturnType).mockRejectedValue(new Error("DB error")); await createComment({ + auth: superAdminAuth, companyUuid, targetType: "task", targetUuid, @@ -334,6 +360,7 @@ describe("createComment", () => { ]); await createComment({ + auth: superAdminAuth, companyUuid, targetType: "idea", targetUuid: ideaUuid, @@ -365,6 +392,7 @@ describe("createComment", () => { ]); await createComment({ + auth: superAdminAuth, companyUuid, targetType: "proposal", targetUuid: proposalUuid, @@ -396,6 +424,7 @@ describe("createComment", () => { ]); await createComment({ + auth: superAdminAuth, companyUuid, targetType: "document", targetUuid: docUuid, @@ -430,6 +459,7 @@ describe("createComment", () => { ]); await createComment({ + auth: superAdminAuth, companyUuid, targetType: "unknown" as "task", targetUuid: unknownUuid, @@ -467,6 +497,7 @@ describe("createComment", () => { ]); await createComment({ + auth: superAdminAuth, companyUuid, targetType: "task", targetUuid, @@ -491,6 +522,7 @@ describe("createComment", () => { mockPrisma.task.findFirst.mockResolvedValue(null); await createComment({ + auth: superAdminAuth, companyUuid, targetType: "task", targetUuid, @@ -512,6 +544,7 @@ describe("createComment", () => { mockPrisma.task.findUnique.mockRejectedValue(new Error("DB error")); await createComment({ + auth: superAdminAuth, companyUuid, targetType: "task", targetUuid, @@ -526,6 +559,33 @@ describe("createComment", () => { // Should not throw, fire-and-forget handles errors expect(eventBus.emitChange).not.toHaveBeenCalled(); }); + + it("rejects a non-member of the target's private project (visibility gate)", async () => { + const projectUuid = "project-private-0000-0000-000000000001"; + // Target resolves to a private project the user neither owns nor belongs to. + mockPrisma.task.findFirst.mockResolvedValue({ projectUuid }); + mockPrisma.project.findFirst.mockResolvedValue({ + visibility: "private", + ownerType: "user", + ownerUuid: "other-user", + }); + mockPrisma.projectMember.findUnique.mockResolvedValue(null); + + await expect( + createComment({ + auth: userAuth, + companyUuid, + targetType: "task", + targetUuid, + content: "Hello", + authorType: "user", + authorUuid, + }) + ).rejects.toThrow(`Target task with UUID ${targetUuid} not found`); + + // The comment is never written. + expect(mockPrisma.comment.create).not.toHaveBeenCalled(); + }); }); // ===== listComments ===== @@ -536,6 +596,7 @@ describe("listComments", () => { mockPrisma.comment.count.mockResolvedValue(1); const result = await listComments({ + auth: superAdminAuth, companyUuid, targetType: "task", targetUuid, @@ -552,6 +613,7 @@ describe("listComments", () => { mockValidateTargetExists.mockResolvedValue(false); const result = await listComments({ + auth: superAdminAuth, companyUuid, targetType: "task", targetUuid: "nonexistent", @@ -572,6 +634,7 @@ describe("listComments", () => { mockPrisma.comment.count.mockResolvedValue(10); const result = await listComments({ + auth: superAdminAuth, companyUuid, targetType: "task", targetUuid, @@ -595,6 +658,7 @@ describe("listComments", () => { mockPrisma.comment.count.mockResolvedValue(1); const result = await listComments({ + auth: superAdminAuth, companyUuid, targetType: "task", targetUuid, @@ -604,6 +668,32 @@ describe("listComments", () => { expect(result.comments[0].author.name).toBe("Unknown"); }); + + it("returns empty for a non-member of the target's private project (visibility gate)", async () => { + const projectUuid = "project-private-0000-0000-000000000001"; + // Target exists and resolves to a private project the user cannot access. + mockPrisma.task.findFirst.mockResolvedValue({ projectUuid }); + mockPrisma.project.findFirst.mockResolvedValue({ + visibility: "private", + ownerType: "user", + ownerUuid: "other-user", + }); + mockPrisma.projectMember.findUnique.mockResolvedValue(null); + + const result = await listComments({ + auth: userAuth, + companyUuid, + targetType: "task", + targetUuid, + skip: 0, + take: 20, + }); + + expect(result.comments).toEqual([]); + expect(result.total).toBe(0); + // Gate short-circuits before querying comments. + expect(mockPrisma.comment.findMany).not.toHaveBeenCalled(); + }); }); // ===== batchCommentCounts ===== diff --git a/src/services/__tests__/document.service.test.ts b/src/services/__tests__/document.service.test.ts index 299be952..eb572b6c 100644 --- a/src/services/__tests__/document.service.test.ts +++ b/src/services/__tests__/document.service.test.ts @@ -5,6 +5,7 @@ const mockPrisma = vi.hoisted(() => ({ document: { create: vi.fn(), findFirst: vi.fn(), + findUnique: vi.fn(), findMany: vi.fn(), count: vi.fn(), update: vi.fn(), @@ -13,6 +14,12 @@ const mockPrisma = vi.hoisted(() => ({ proposal: { findFirst: vi.fn(), }, + project: { + findFirst: vi.fn(), + }, + projectMember: { + findUnique: vi.fn(), + }, })); vi.mock("@/lib/prisma", () => ({ prisma: mockPrisma })); @@ -51,6 +58,7 @@ import { listDocuments, createDocumentFromProposal, } from "@/services/document.service"; +import type { AuthContext, SuperAdminAuthContext } from "@/types/auth"; // ===== Helpers ===== const now = new Date("2026-03-13T00:00:00Z"); @@ -59,6 +67,12 @@ const projectUuid = "project-0000-0000-0000-000000000001"; const docUuid = "doc-0000-0000-0000-000000000001"; const createdByUuid = "agent-0000-0000-0000-000000000001"; +// super_admin bypasses access gating (no extra prisma queries), so existing +// query-behavior tests thread it to isolate the function's own logic. +const adminAuth: SuperAdminAuthContext = { type: "super_admin", email: "root@chorus.local" }; +// A regular user used for access-gating tests. +const userAuth: AuthContext = { type: "user", companyUuid, actorUuid: "user-1" }; + function makeDocRecord(overrides: Record = {}) { return { uuid: docUuid, @@ -67,6 +81,7 @@ function makeDocRecord(overrides: Record = {}) { content: "# Test", version: 1, proposalUuid: null, + projectUuid, createdByUuid, createdAt: now, updatedAt: now, @@ -84,6 +99,27 @@ beforeEach(() => { mockPrisma.proposal.findFirst.mockResolvedValue(null); mockActivityService.createActivity.mockResolvedValue(undefined); mockLogger.child.mockReturnValue(mockLogger); + // Default: the update/delete gate's project lookup resolves to an accessible + // document. Tests needing a missing document override this explicitly. + mockPrisma.document.findUnique.mockResolvedValue(makeDocRecord()); +}); + +// ===== visibility write-gate ===== +describe("createDocument visibility gate", () => { + it("rejects creating a document in a project the actor cannot access", async () => { + // canAccessProject(userAuth): private project owned by someone else, no membership. + mockPrisma.project.findFirst.mockResolvedValue({ + visibility: "private", + ownerType: "user", + ownerUuid: "other-owner", + }); + mockPrisma.projectMember.findUnique.mockResolvedValue(null); + + await expect( + createDocument({ companyUuid, projectUuid, type: "prd", title: "X", createdByUuid }, userAuth), + ).rejects.toThrow("Project not found"); + expect(mockPrisma.document.create).not.toHaveBeenCalled(); + }); }); // ===== createDocument ===== @@ -99,7 +135,7 @@ describe("createDocument", () => { title: "Test Document", content: "# Test", createdByUuid, - }); + }, adminAuth); expect(result.uuid).toBe(docUuid); expect(result.version).toBe(1); @@ -124,7 +160,7 @@ describe("createDocument", () => { title: "From Proposal", createdByUuid, proposalUuid, - }); + }, adminAuth); expect(result.proposalUuid).toBe(proposalUuid); }); @@ -158,7 +194,7 @@ describe("createDocument", () => { content: reportContent, proposalUuid, createdByUuid, - }); + }, adminAuth); // Output preserves type, title, content, proposalUuid, version=1. expect(result.type).toBe("report"); @@ -203,7 +239,7 @@ describe("createDocument", () => { content: "## Summary\nA", proposalUuid, createdByUuid, - }); + }, adminAuth); expect(first.type).toBe("report"); // Second write to the same Proposal — service must not error. @@ -224,7 +260,7 @@ describe("createDocument", () => { content: "## Summary\nB", proposalUuid, createdByUuid, - }); + }, adminAuth); expect(second.type).toBe("report"); expect(second.uuid).toBe("doc-report-2"); @@ -271,7 +307,7 @@ describe("createDocument — report-realtime side effects", () => { content: reportContent, proposalUuid, createdByUuid, - }); + }, adminAuth); expect(result.uuid).toBe(docUuid); @@ -324,7 +360,7 @@ describe("createDocument — report-realtime side effects", () => { content: "...", proposalUuid, createdByUuid, - }); + }, adminAuth); expect(mockEventBus.emitChange).not.toHaveBeenCalled(); expect(mockActivityService.createActivity).not.toHaveBeenCalled(); @@ -348,7 +384,7 @@ describe("createDocument — report-realtime side effects", () => { content: reportContent, proposalUuid, createdByUuid, - }); + }, adminAuth); // document/created fires — Requirement 1 still holds. expect(mockEventBus.emitChange).toHaveBeenCalledTimes(1); @@ -377,7 +413,7 @@ describe("createDocument — report-realtime side effects", () => { title: reportTitle, content: reportContent, createdByUuid, - }); + }, adminAuth); expect(mockEventBus.emitChange).toHaveBeenCalledTimes(1); expect(mockEventBus.emitChange).toHaveBeenCalledWith( @@ -405,7 +441,7 @@ describe("createDocument — report-realtime side effects", () => { content: reportContent, proposalUuid, createdByUuid, - }); + }, adminAuth); // Document insert is the source of truth — it MUST succeed end-to-end. expect(result.uuid).toBe(docUuid); @@ -438,7 +474,7 @@ describe("createDocument — report-realtime side effects", () => { content: reportContent, proposalUuid, createdByUuid, - }); + }, adminAuth); expect(result.uuid).toBe(docUuid); expect(mockEventBus.emitChange).toHaveBeenCalledTimes(2); @@ -460,7 +496,7 @@ describe("createDocument — report-realtime side effects", () => { content: reportContent, proposalUuid, createdByUuid, - }); + }, adminAuth); expect(mockEventBus.emitChange).toHaveBeenCalledTimes(1); expect(mockEventBus.emitChange).toHaveBeenCalledWith( @@ -478,7 +514,7 @@ describe("getDocument", () => { }); mockPrisma.document.findFirst.mockResolvedValue(record); - const result = await getDocument(companyUuid, docUuid); + const result = await getDocument(companyUuid, docUuid, adminAuth); expect(result).not.toBeNull(); expect(result!.uuid).toBe(docUuid); @@ -489,7 +525,7 @@ describe("getDocument", () => { it("should return null when document not found", async () => { mockPrisma.document.findFirst.mockResolvedValue(null); - const result = await getDocument(companyUuid, "nonexistent"); + const result = await getDocument(companyUuid, "nonexistent", adminAuth); expect(result).toBeNull(); }); }); @@ -507,7 +543,7 @@ describe("updateDocument", () => { const result = await updateDocument(docUuid, { title: "Updated Title", content: "# Updated", - }); + }, adminAuth); expect(result.title).toBe("Updated Title"); expect(result.content).toBe("# Updated"); @@ -523,7 +559,7 @@ describe("updateDocument", () => { const result = await updateDocument(docUuid, { content: "# V2", incrementVersion: true, - }); + }, adminAuth); expect(result.version).toBe(2); expect(mockPrisma.document.update).toHaveBeenCalledWith( @@ -541,7 +577,7 @@ describe("updateDocument", () => { }); mockPrisma.document.update.mockResolvedValue(updated); - await updateDocument(docUuid, { title: "New Title" }); + await updateDocument(docUuid, { title: "New Title" }, adminAuth); const callData = mockPrisma.document.update.mock.calls[0][0].data; expect(callData.version).toBeUndefined(); @@ -554,7 +590,7 @@ describe("updateDocument", () => { }); mockPrisma.document.update.mockResolvedValue(updated); - await updateDocument(docUuid, { title: "Only Title Changed" }); + await updateDocument(docUuid, { title: "Only Title Changed" }, adminAuth); const callData = mockPrisma.document.update.mock.calls[0][0].data; expect(callData.title).toBe("Only Title Changed"); @@ -569,7 +605,7 @@ describe("updateDocument", () => { }); mockPrisma.document.update.mockResolvedValue(updated); - await updateDocument(docUuid, { content: "# Only content changed" }); + await updateDocument(docUuid, { content: "# Only content changed" }, adminAuth); const callData = mockPrisma.document.update.mock.calls[0][0].data; expect(callData.content).toBe("# Only content changed"); @@ -583,7 +619,7 @@ describe("updateDocument", () => { }); mockPrisma.document.update.mockResolvedValue(updated); - await updateDocument(docUuid, { content: null }); + await updateDocument(docUuid, { content: null }, adminAuth); const callData = mockPrisma.document.update.mock.calls[0][0].data; expect(callData.content).toBeNull(); @@ -602,7 +638,7 @@ describe("updateDocument", () => { title: "All Updated", content: "# All fields", incrementVersion: true, - }); + }, adminAuth); const callData = mockPrisma.document.update.mock.calls[0][0].data; expect(callData.title).toBe("All Updated"); @@ -616,7 +652,7 @@ describe("updateDocument", () => { mockPrisma.document.update.mockRejectedValue(notFoundError); await expect( - updateDocument("nonexistent-uuid", { title: "New Title" }) + updateDocument("nonexistent-uuid", { title: "New Title" }, adminAuth) ).rejects.toThrow("Record to update not found."); }); }); @@ -626,7 +662,7 @@ describe("deleteDocument", () => { it("should delete document by uuid", async () => { mockPrisma.document.delete.mockResolvedValue(makeDocRecord()); - await deleteDocument(docUuid); + await deleteDocument(docUuid, adminAuth); expect(mockPrisma.document.delete).toHaveBeenCalledWith({ where: { uuid: docUuid }, @@ -638,7 +674,7 @@ describe("deleteDocument", () => { (notFoundError as any).code = "P2025"; mockPrisma.document.delete.mockRejectedValue(notFoundError); - await expect(deleteDocument("nonexistent-uuid")).rejects.toThrow( + await expect(deleteDocument("nonexistent-uuid", adminAuth)).rejects.toThrow( "Record to delete does not exist." ); }); @@ -656,6 +692,7 @@ describe("listDocuments", () => { projectUuid, skip: 0, take: 20, + auth: adminAuth, }); expect(result.documents).toHaveLength(1); @@ -675,6 +712,7 @@ describe("listDocuments", () => { skip: 0, take: 20, type: "architecture", + auth: adminAuth, }); expect(mockPrisma.document.findMany).toHaveBeenCalledWith( @@ -771,3 +809,48 @@ describe("createDocumentFromProposal", () => { expect(result.content).toBe(content); }); }); + +// ===== Project-visibility access gating (non-super-admin) ===== +describe("access gating", () => { + function denyAccess() { + mockPrisma.project.findFirst.mockResolvedValue({ + visibility: "private", + ownerType: "user", + ownerUuid: "other-owner", + }); + mockPrisma.projectMember.findUnique.mockResolvedValue(null); + } + + it("listDocuments returns an empty page for a non-member of the project", async () => { + denyAccess(); + + const result = await listDocuments({ + companyUuid, + projectUuid, + skip: 0, + take: 20, + auth: userAuth, + }); + + expect(result).toEqual({ documents: [], total: 0 }); + expect(mockPrisma.document.findMany).not.toHaveBeenCalled(); + }); + + it("getDocument returns null for a non-member of the document's project", async () => { + mockPrisma.document.findFirst.mockResolvedValue(makeDocRecord()); + denyAccess(); + + const result = await getDocument(companyUuid, docUuid, userAuth); + expect(result).toBeNull(); + }); + + it("updateDocument rejects a non-member of the document's project", async () => { + mockPrisma.document.findUnique.mockResolvedValue(makeDocRecord()); + denyAccess(); + + await expect( + updateDocument(docUuid, { title: "Nope" }, userAuth), + ).rejects.toThrow("Document not found"); + expect(mockPrisma.document.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/__tests__/idea-tracker.service.test.ts b/src/services/__tests__/idea-tracker.service.test.ts index ee944a0e..51c7defe 100644 --- a/src/services/__tests__/idea-tracker.service.test.ts +++ b/src/services/__tests__/idea-tracker.service.test.ts @@ -7,6 +7,9 @@ const { mockPrisma } = vi.hoisted(() => ({ proposal: { findMany: vi.fn() }, task: { findMany: vi.fn() }, project: { findMany: vi.fn() }, + projectMember: { findMany: vi.fn() }, + projectGroup: { findMany: vi.fn() }, + projectGroupMember: { findMany: vi.fn() }, }, })); @@ -80,6 +83,18 @@ beforeEach(() => { mockPrisma.proposal.findMany.mockResolvedValue([]); mockPrisma.task.findMany.mockResolvedValue([]); mockPrisma.project.findMany.mockResolvedValue([]); + // Default: the actor is a member of both fixture projects, so the + // visibility gate (getAccessibleProjectUuids) treats every fixture project + // as accessible and existing tracker assertions are unaffected. Tests that + // exercise the gate override this with a narrower membership set. + mockPrisma.projectMember.findMany.mockResolvedValue([ + { projectUuid: PROJECT_A }, + { projectUuid: PROJECT_B }, + ]); + // No group ownership/membership in tracker fixtures (access comes from the + // ProjectMember rows above); default the group queries to empty. + mockPrisma.projectGroup.findMany.mockResolvedValue([]); + mockPrisma.projectGroupMember.findMany.mockResolvedValue([]); }); // ============================================================ @@ -384,10 +399,49 @@ describe("buildIdeaTracker — grouping & ordering & options", () => { ); }); - it("does NOT add projectUuid filter when projectUuids is empty array", async () => { + it("scopes to the accessible project set when projectUuids is an empty array (visibility gate)", async () => { + // With no explicit request filter, a non-super-admin actor is still scoped + // to the projects they can access (here: both fixture projects via the + // default membership mock). The empty array no longer means "all projects". await buildIdeaTracker(agentAuth, { projectUuids: [] }); - const callArg = mockPrisma.idea.findMany.mock.calls[0][0]; - expect(callArg.where).not.toHaveProperty("projectUuid"); + expect(mockPrisma.idea.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + projectUuid: { in: [PROJECT_A, PROJECT_B] }, + }), + }), + ); + }); + + it("drops projects outside the accessible set (visibility gate)", async () => { + // Actor is a member of PROJECT_A only; an idea assigned to them in + // PROJECT_B must not surface. + mockPrisma.projectMember.findMany.mockResolvedValue([{ projectUuid: PROJECT_A }]); + mockPrisma.idea.findMany.mockResolvedValue([ + makeIdea("i-a", PROJECT_A, "open"), + ]); + mockPrisma.project.findMany.mockResolvedValue([{ uuid: PROJECT_A, name: "A" }]); + + const tracker = await buildIdeaTracker(agentAuth); + + // Only PROJECT_A is queried; PROJECT_B is excluded at the DB layer. + expect(mockPrisma.idea.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ projectUuid: { in: [PROJECT_A] } }), + }), + ); + expect(Object.keys(tracker)).toEqual([PROJECT_A]); + }); + + it("returns empty when the actor has no accessible projects (visibility gate)", async () => { + mockPrisma.projectMember.findMany.mockResolvedValue([]); + mockPrisma.project.findMany.mockResolvedValue([]); + + const tracker = await buildIdeaTracker(agentAuth); + + expect(tracker).toEqual({}); + // Gate short-circuits before the idea query. + expect(mockPrisma.idea.findMany).not.toHaveBeenCalled(); }); }); diff --git a/src/services/__tests__/idea.service.derived-status.test.ts b/src/services/__tests__/idea.service.derived-status.test.ts index 3a8b9482..5c02ad04 100644 --- a/src/services/__tests__/idea.service.derived-status.test.ts +++ b/src/services/__tests__/idea.service.derived-status.test.ts @@ -37,6 +37,8 @@ import { getIdeasWithDerivedStatus, getTrackerGroups, } from "@/services/idea.service"; +import type { SuperAdminAuthContext } from "@/types/auth"; +const adminAuth: SuperAdminAuthContext = { type: "super_admin", email: "root@chorus.local" }; // ===== Test Data ===== @@ -307,7 +309,7 @@ describe("getIdeasWithDerivedStatus", () => { mockPrisma.proposal.findMany.mockResolvedValue([]); mockPrisma.task.findMany.mockResolvedValue([]); - const result = await getIdeasWithDerivedStatus(COMPANY_UUID, PROJECT_UUID); + const result = await getIdeasWithDerivedStatus(COMPANY_UUID, PROJECT_UUID, adminAuth); expect(result).toHaveLength(1); expect(result[0].derivedStatus).toBe("todo"); @@ -319,7 +321,7 @@ describe("getIdeasWithDerivedStatus", () => { mockPrisma.proposal.findMany.mockResolvedValue([]); mockPrisma.task.findMany.mockResolvedValue([]); - const result = await getIdeasWithDerivedStatus(COMPANY_UUID, PROJECT_UUID); + const result = await getIdeasWithDerivedStatus(COMPANY_UUID, PROJECT_UUID, adminAuth); expect(result[0].derivedStatus).toBe("in_progress"); expect(result[0].badgeHint).toBe("researching"); @@ -330,7 +332,7 @@ describe("getIdeasWithDerivedStatus", () => { mockPrisma.proposal.findMany.mockResolvedValue([]); mockPrisma.task.findMany.mockResolvedValue([]); - const result = await getIdeasWithDerivedStatus(COMPANY_UUID, PROJECT_UUID); + const result = await getIdeasWithDerivedStatus(COMPANY_UUID, PROJECT_UUID, adminAuth); expect(result[0].derivedStatus).toBe("human_conduct_required"); expect(result[0].badgeHint).toBe("answer_questions"); @@ -343,7 +345,7 @@ describe("getIdeasWithDerivedStatus", () => { ]); mockPrisma.task.findMany.mockResolvedValue([]); - const result = await getIdeasWithDerivedStatus(COMPANY_UUID, PROJECT_UUID); + const result = await getIdeasWithDerivedStatus(COMPANY_UUID, PROJECT_UUID, adminAuth); expect(result[0].derivedStatus).toBe("human_conduct_required"); expect(result[0].badgeHint).toBe("review_proposal"); @@ -362,7 +364,7 @@ describe("getIdeasWithDerivedStatus", () => { { proposalUuid, status: "done" }, ]); - const result = await getIdeasWithDerivedStatus(COMPANY_UUID, PROJECT_UUID); + const result = await getIdeasWithDerivedStatus(COMPANY_UUID, PROJECT_UUID, adminAuth); expect(result[0].derivedStatus).toBe("human_conduct_required"); expect(result[0].badgeHint).toBe("verify_work"); @@ -377,7 +379,7 @@ describe("getIdeasWithDerivedStatus", () => { mockPrisma.proposal.findMany.mockResolvedValue([]); mockPrisma.task.findMany.mockResolvedValue([]); - const result = await getIdeasWithDerivedStatus(COMPANY_UUID, PROJECT_UUID); + const result = await getIdeasWithDerivedStatus(COMPANY_UUID, PROJECT_UUID, adminAuth); expect(result[0].derivedStatus).toBe("in_progress"); expect(result[0].badgeHint).toBe("planning"); @@ -388,7 +390,7 @@ describe("getIdeasWithDerivedStatus", () => { mockPrisma.proposal.findMany.mockResolvedValue([]); mockPrisma.task.findMany.mockResolvedValue([]); - const result = await getIdeasWithDerivedStatus(COMPANY_UUID, PROJECT_UUID); + const result = await getIdeasWithDerivedStatus(COMPANY_UUID, PROJECT_UUID, adminAuth); expect(result[0].derivedStatus).toBe("in_progress"); expect(result[0].badgeHint).toBe("planning"); @@ -408,7 +410,7 @@ describe("getIdeasWithDerivedStatus", () => { { proposalUuid: newProposalUuid, status: "done" }, ]); - const result = await getIdeasWithDerivedStatus(COMPANY_UUID, PROJECT_UUID); + const result = await getIdeasWithDerivedStatus(COMPANY_UUID, PROJECT_UUID, adminAuth); // Should use the NEW proposal — all tasks done → done expect(result[0].derivedStatus).toBe("done"); @@ -438,7 +440,7 @@ describe("getIdeasWithDerivedStatus", () => { { proposalUuid: "proposal-done", status: "done" }, ]); - const result = await getIdeasWithDerivedStatus(COMPANY_UUID, PROJECT_UUID); + const result = await getIdeasWithDerivedStatus(COMPANY_UUID, PROJECT_UUID, adminAuth); const statusMap = Object.fromEntries(result.map((r) => [r.uuid, r.derivedStatus])); expect(statusMap["idea-open"]).toBe("todo"); @@ -463,7 +465,7 @@ describe("getIdeasWithDerivedStatus", () => { mockPrisma.idea.findMany.mockResolvedValue([makeIdea("idea-1", "open")]); mockPrisma.proposal.findMany.mockResolvedValue([]); - await getIdeasWithDerivedStatus(COMPANY_UUID, PROJECT_UUID); + await getIdeasWithDerivedStatus(COMPANY_UUID, PROJECT_UUID, adminAuth); expect(mockPrisma.task.findMany).not.toHaveBeenCalled(); }); @@ -474,7 +476,7 @@ describe("getIdeasWithDerivedStatus", () => { { uuid: "proposal-bad", status: "approved", inputUuids: "not-an-array", createdAt: now }, ]); - const result = await getIdeasWithDerivedStatus(COMPANY_UUID, PROJECT_UUID); + const result = await getIdeasWithDerivedStatus(COMPANY_UUID, PROJECT_UUID, adminAuth); // Should not crash; no valid proposal mapping → no approved, no pending → in_progress/planning expect(result[0].derivedStatus).toBe("in_progress"); @@ -493,7 +495,7 @@ describe("getIdeaWithDerivedStatus", () => { it("returns null when idea not found", async () => { mockPrisma.idea.findFirst.mockResolvedValue(null); - const result = await getIdeaWithDerivedStatus(COMPANY_UUID, "nonexistent"); + const result = await getIdeaWithDerivedStatus(COMPANY_UUID, "nonexistent", adminAuth); expect(result).toBeNull(); }); @@ -501,7 +503,7 @@ describe("getIdeaWithDerivedStatus", () => { mockPrisma.idea.findFirst.mockResolvedValue(makeFullIdea("idea-1", "open")); mockPrisma.proposal.findMany.mockResolvedValue([]); - const result = await getIdeaWithDerivedStatus(COMPANY_UUID, "idea-1"); + const result = await getIdeaWithDerivedStatus(COMPANY_UUID, "idea-1", adminAuth); expect(result).not.toBeNull(); expect(result!.derivedStatus).toBe("todo"); @@ -519,7 +521,7 @@ describe("getIdeaWithDerivedStatus", () => { { status: "open" }, ]); - const result = await getIdeaWithDerivedStatus(COMPANY_UUID, "idea-1"); + const result = await getIdeaWithDerivedStatus(COMPANY_UUID, "idea-1", adminAuth); expect(result!.derivedStatus).toBe("in_progress"); expect(result!.badgeHint).toBe("building"); @@ -535,7 +537,7 @@ describe("getIdeaWithDerivedStatus", () => { { status: "closed" }, ]); - const result = await getIdeaWithDerivedStatus(COMPANY_UUID, "idea-1"); + const result = await getIdeaWithDerivedStatus(COMPANY_UUID, "idea-1", adminAuth); expect(result!.derivedStatus).toBe("done"); expect(result!.badgeHint).toBe("done"); @@ -551,7 +553,7 @@ describe("getIdeaWithDerivedStatus", () => { { status: "to_verify" }, ]); - const result = await getIdeaWithDerivedStatus(COMPANY_UUID, "idea-1"); + const result = await getIdeaWithDerivedStatus(COMPANY_UUID, "idea-1", adminAuth); expect(result!.derivedStatus).toBe("human_conduct_required"); expect(result!.badgeHint).toBe("verify_work"); @@ -563,7 +565,7 @@ describe("getIdeaWithDerivedStatus", () => { { uuid: "proposal-1", status: "pending" }, ]); - const result = await getIdeaWithDerivedStatus(COMPANY_UUID, "idea-1"); + const result = await getIdeaWithDerivedStatus(COMPANY_UUID, "idea-1", adminAuth); expect(result!.derivedStatus).toBe("human_conduct_required"); expect(result!.badgeHint).toBe("review_proposal"); @@ -574,7 +576,7 @@ describe("getIdeaWithDerivedStatus", () => { mockPrisma.idea.findFirst.mockResolvedValue(makeFullIdea("idea-1", "elaborated")); mockPrisma.proposal.findMany.mockResolvedValue([]); - await getIdeaWithDerivedStatus(COMPANY_UUID, "idea-1"); + await getIdeaWithDerivedStatus(COMPANY_UUID, "idea-1", adminAuth); expect(mockPrisma.task.findMany).not.toHaveBeenCalled(); }); @@ -591,7 +593,7 @@ describe("getTrackerGroups", () => { mockPrisma.idea.findMany.mockResolvedValue([]); mockPrisma.proposal.findMany.mockResolvedValue([]); - const result = await getTrackerGroups(COMPANY_UUID, PROJECT_UUID); + const result = await getTrackerGroups(COMPANY_UUID, PROJECT_UUID, adminAuth); expect(result.groups.todo).toEqual([]); expect(result.groups.in_progress).toEqual([]); @@ -610,7 +612,7 @@ describe("getTrackerGroups", () => { mockPrisma.proposal.findMany.mockResolvedValue([]); mockPrisma.task.findMany.mockResolvedValue([]); - const result = await getTrackerGroups(COMPANY_UUID, PROJECT_UUID); + const result = await getTrackerGroups(COMPANY_UUID, PROJECT_UUID, adminAuth); // Legacy "closed" normalizes to "elaborated", which with no proposal becomes in_progress/planning const allItems = Object.values(result.groups).flat(); @@ -640,7 +642,7 @@ describe("getTrackerGroups", () => { { proposalUuid: doneProposalUuid, status: "done" }, ]); - const result = await getTrackerGroups(COMPANY_UUID, PROJECT_UUID); + const result = await getTrackerGroups(COMPANY_UUID, PROJECT_UUID, adminAuth); expect(result.counts.todo).toBe(1); expect(result.counts.in_progress).toBe(2); // elaborating + building @@ -655,7 +657,7 @@ describe("getTrackerGroups", () => { mockPrisma.idea.findMany.mockResolvedValue([makeIdea("idea-1", "open")]); mockPrisma.proposal.findMany.mockResolvedValue([]); - const result = await getTrackerGroups(COMPANY_UUID, PROJECT_UUID); + const result = await getTrackerGroups(COMPANY_UUID, PROJECT_UUID, adminAuth); const item = result.groups.todo[0]; expect(item).toEqual({ @@ -676,7 +678,7 @@ describe("getTrackerGroups", () => { ]); mockPrisma.proposal.findMany.mockResolvedValue([]); - const result = await getTrackerGroups(COMPANY_UUID, PROJECT_UUID); + const result = await getTrackerGroups(COMPANY_UUID, PROJECT_UUID, adminAuth); expect(result.groups.todo).toHaveLength(3); expect(result.counts.todo).toBe(3); diff --git a/src/services/__tests__/idea.service.test.ts b/src/services/__tests__/idea.service.test.ts index f124d7ba..78e4c433 100644 --- a/src/services/__tests__/idea.service.test.ts +++ b/src/services/__tests__/idea.service.test.ts @@ -15,6 +15,11 @@ const { mockPrisma, mockEventBus, mockFormatAssigneeComplete, mockFormatCreatedB }, project: { findFirst: vi.fn(), + findMany: vi.fn(), + }, + projectMember: { + findMany: vi.fn(), + findUnique: vi.fn(), }, proposal: { findMany: vi.fn(), @@ -64,6 +69,7 @@ vi.mock("@/services/activity.service", () => ({ import { createIdea, claimIdea, assignIdea, releaseIdea, moveIdea, moveIdeaPreview, deleteIdea, updateIdea, listIdeas, getIdea } from "@/services/idea.service"; import { AlreadyClaimedError } from "@/lib/errors"; +import type { AuthContext, SuperAdminAuthContext } from "@/types/auth"; // ===== Test Data ===== @@ -72,6 +78,12 @@ const PROJECT_UUID = "project-2222-2222-2222-222222222222"; const IDEA_UUID = "idea-3333-3333-3333-333333333333"; const ACTOR_UUID = "actor-4444-4444-4444-444444444444"; +// super_admin bypasses access gating (no extra prisma queries), so the existing +// query-behavior tests below thread it to isolate the function's own logic. +const adminAuth: SuperAdminAuthContext = { type: "super_admin", email: "root@chorus.local" }; +// A regular user used for access-gating tests. +const userAuth: AuthContext = { type: "user", companyUuid: COMPANY_UUID, actorUuid: "user-1" }; + const now = new Date("2026-01-15T10:00:00Z"); function makeIdeaRecord(overrides: Record = {}) { @@ -101,6 +113,9 @@ function makeIdeaRecord(overrides: Record = {}) { beforeEach(() => { vi.clearAllMocks(); + // Default: the updateIdea gate's project lookup resolves to an accessible + // idea. Tests needing a missing idea or a specific record override this. + mockPrisma.idea.findFirst.mockResolvedValue(makeIdeaRecord()); }); describe("createIdea", () => { @@ -114,7 +129,7 @@ describe("createIdea", () => { title: "Test Idea", content: "Some content", createdByUuid: ACTOR_UUID, - }); + }, adminAuth); expect(mockPrisma.idea.create).toHaveBeenCalledWith( expect.objectContaining({ @@ -153,7 +168,7 @@ describe("createIdea", () => { title: "No Content Idea", content: null, createdByUuid: ACTOR_UUID, - }); + }, adminAuth); expect(result.content).toBeNull(); }); @@ -178,7 +193,7 @@ describe("claimIdea", () => { companyUuid: COMPANY_UUID, assigneeType: "agent", assigneeUuid: ACTOR_UUID, - }); + }, adminAuth); expect(mockPrisma.idea.update).toHaveBeenCalledWith( expect.objectContaining({ @@ -208,7 +223,7 @@ describe("claimIdea", () => { companyUuid: COMPANY_UUID, assigneeType: "agent", assigneeUuid: ACTOR_UUID, - }) + }, adminAuth) ).rejects.toThrow(AlreadyClaimedError); }); @@ -221,7 +236,7 @@ describe("claimIdea", () => { companyUuid: COMPANY_UUID, assigneeType: "agent", assigneeUuid: ACTOR_UUID, - }) + }, adminAuth) ).rejects.toThrow(AlreadyClaimedError); }); @@ -235,7 +250,7 @@ describe("claimIdea", () => { companyUuid: COMPANY_UUID, assigneeType: "agent", assigneeUuid: ACTOR_UUID, - }) + }, adminAuth) ).rejects.toThrow("Cannot claim an elaborated Idea"); }); @@ -249,7 +264,7 @@ describe("claimIdea", () => { companyUuid: COMPANY_UUID, assigneeType: "agent", assigneeUuid: ACTOR_UUID, - }) + }, adminAuth) ).rejects.toThrow("Cannot claim an elaborated Idea"); }); }); @@ -274,7 +289,7 @@ describe("assignIdea", () => { assigneeType: "user", assigneeUuid: ACTOR_UUID, assignedByUuid: "admin-uuid", - }); + }, adminAuth); expect(mockPrisma.idea.update).toHaveBeenCalledWith( expect.objectContaining({ @@ -314,7 +329,7 @@ describe("assignIdea", () => { assigneeType: "user", assigneeUuid: ACTOR_UUID, assignedByUuid: "admin-uuid", - }); + }, adminAuth); expect(mockPrisma.idea.update).toHaveBeenCalledWith( expect.objectContaining({ @@ -336,7 +351,7 @@ describe("assignIdea", () => { companyUuid: COMPANY_UUID, assigneeType: "user", assigneeUuid: ACTOR_UUID, - }) + }, adminAuth) ).rejects.toThrow("Idea not found"); }); @@ -350,7 +365,7 @@ describe("assignIdea", () => { companyUuid: COMPANY_UUID, assigneeType: "user", assigneeUuid: ACTOR_UUID, - }) + }, adminAuth) ).rejects.toThrow("Cannot assign an elaborated Idea"); }); @@ -364,7 +379,7 @@ describe("assignIdea", () => { companyUuid: COMPANY_UUID, assigneeType: "user", assigneeUuid: ACTOR_UUID, - }) + }, adminAuth) ).rejects.toThrow("Cannot assign an elaborated Idea"); }); }); @@ -389,7 +404,7 @@ describe("releaseIdea", () => { mockPrisma.idea.findUnique.mockResolvedValue(existing); mockPrisma.idea.update.mockResolvedValue(released); - const result = await releaseIdea(IDEA_UUID); + const result = await releaseIdea(IDEA_UUID, adminAuth); expect(mockPrisma.idea.update).toHaveBeenCalledWith( expect.objectContaining({ @@ -413,13 +428,13 @@ describe("releaseIdea", () => { it("should throw if idea not found", async () => { mockPrisma.idea.findUnique.mockResolvedValue(null); - await expect(releaseIdea(IDEA_UUID)).rejects.toThrow("Idea not found"); + await expect(releaseIdea(IDEA_UUID, adminAuth)).rejects.toThrow("Idea not found"); }); it("should throw if idea is elaborated", async () => { mockPrisma.idea.findUnique.mockResolvedValue(makeIdeaRecord({ status: "elaborated" })); - await expect(releaseIdea(IDEA_UUID)).rejects.toThrow( + await expect(releaseIdea(IDEA_UUID, adminAuth)).rejects.toThrow( "Cannot release an elaborated Idea" ); }); @@ -427,7 +442,7 @@ describe("releaseIdea", () => { it("should throw if idea has legacy closed status (normalizes to elaborated)", async () => { mockPrisma.idea.findUnique.mockResolvedValue(makeIdeaRecord({ status: "closed" })); - await expect(releaseIdea(IDEA_UUID)).rejects.toThrow( + await expect(releaseIdea(IDEA_UUID, adminAuth)).rejects.toThrow( "Cannot release an elaborated Idea" ); }); @@ -495,7 +510,8 @@ describe("moveIdea", () => { IDEA_UUID, TARGET_PROJECT_UUID, ACTOR_UUID, - "user" + "user", + adminAuth ); // Idea row updated. @@ -587,7 +603,7 @@ describe("moveIdea", () => { return await fn(mockPrisma); }); - const result = await moveIdea(COMPANY_UUID, IDEA_UUID, TARGET_PROJECT_UUID, ACTOR_UUID); + const result = await moveIdea(COMPANY_UUID, IDEA_UUID, TARGET_PROJECT_UUID, ACTOR_UUID, "user", adminAuth); expect(result.moved).toEqual({ proposals: 2, documents: 1, tasks: 3, activities: 7 }); }); @@ -604,7 +620,7 @@ describe("moveIdea", () => { return await fn(mockPrisma); }); - await moveIdea(COMPANY_UUID, IDEA_UUID, TARGET_PROJECT_UUID, ACTOR_UUID); + await moveIdea(COMPANY_UUID, IDEA_UUID, TARGET_PROJECT_UUID, ACTOR_UUID, "user", adminAuth); // Every findMany / updateMany inside the transaction must carry companyUuid. for (const call of mockPrisma.proposal.findMany.mock.calls) { @@ -647,7 +663,7 @@ describe("moveIdea", () => { return await fn(mockPrisma); }); - await moveIdea(COMPANY_UUID, IDEA_UUID, TARGET_PROJECT_UUID, ACTOR_UUID); + await moveIdea(COMPANY_UUID, IDEA_UUID, TARGET_PROJECT_UUID, ACTOR_UUID, "user", adminAuth); // The mockPrisma object only declares idea/project/proposal/document/task/activity. // If moveIdea ever reached for a forbidden table, TypeScript would have @@ -686,7 +702,7 @@ describe("moveIdea", () => { return await fn(mockPrisma); }); - const result = await moveIdea(COMPANY_UUID, IDEA_UUID, TARGET_PROJECT_UUID, ACTOR_UUID); + const result = await moveIdea(COMPANY_UUID, IDEA_UUID, TARGET_PROJECT_UUID, ACTOR_UUID, "user", adminAuth); expect(result.moved).toEqual({ proposals: 0, documents: 0, tasks: 0, activities: 2 }); // Idea + activity ran; everything in between is short-circuited. @@ -703,7 +719,7 @@ describe("moveIdea", () => { mockPrisma.idea.findFirst.mockResolvedValue(null); await expect( - moveIdea(COMPANY_UUID, IDEA_UUID, TARGET_PROJECT_UUID, ACTOR_UUID) + moveIdea(COMPANY_UUID, IDEA_UUID, TARGET_PROJECT_UUID, ACTOR_UUID, "user", adminAuth) ).rejects.toThrow("Idea not found"); }); @@ -712,7 +728,7 @@ describe("moveIdea", () => { mockPrisma.project.findFirst.mockResolvedValue(null); await expect( - moveIdea(COMPANY_UUID, IDEA_UUID, TARGET_PROJECT_UUID, ACTOR_UUID) + moveIdea(COMPANY_UUID, IDEA_UUID, TARGET_PROJECT_UUID, ACTOR_UUID, "user", adminAuth) ).rejects.toThrow("Target project not found"); }); @@ -724,7 +740,7 @@ describe("moveIdea", () => { }); await expect( - moveIdea(COMPANY_UUID, IDEA_UUID, PROJECT_UUID, ACTOR_UUID) + moveIdea(COMPANY_UUID, IDEA_UUID, PROJECT_UUID, ACTOR_UUID, "user", adminAuth) ).rejects.toThrow("Idea is already in the target project"); }); }); @@ -754,7 +770,7 @@ describe("moveIdeaPreview", () => { mockPrisma.task.count.mockResolvedValueOnce(1); mockPrisma.activity.count.mockResolvedValueOnce(4); - const preview = await moveIdeaPreview(COMPANY_UUID, IDEA_UUID, TARGET_PROJECT_UUID); + const preview = await moveIdeaPreview(COMPANY_UUID, IDEA_UUID, TARGET_PROJECT_UUID, adminAuth); expect(preview.moved).toEqual({ proposals: 1, documents: 1, tasks: 1, activities: 4 }); // ----- real move (same fixture, no concurrent writes) ----- @@ -771,7 +787,7 @@ describe("moveIdeaPreview", () => { return await fn(mockPrisma); }); - const real = await moveIdea(COMPANY_UUID, IDEA_UUID, TARGET_PROJECT_UUID, ACTOR_UUID); + const real = await moveIdea(COMPANY_UUID, IDEA_UUID, TARGET_PROJECT_UUID, ACTOR_UUID, "user", adminAuth); expect(real.moved).toEqual(preview.moved); }); @@ -784,7 +800,7 @@ describe("moveIdeaPreview", () => { mockPrisma.proposal.findMany.mockResolvedValueOnce([]); mockPrisma.activity.count.mockResolvedValueOnce(0); - await moveIdeaPreview(COMPANY_UUID, IDEA_UUID, TARGET_PROJECT_UUID); + await moveIdeaPreview(COMPANY_UUID, IDEA_UUID, TARGET_PROJECT_UUID, adminAuth); // No mutations on any cascaded table. expect(mockPrisma.idea.update).not.toHaveBeenCalled(); @@ -811,7 +827,7 @@ describe("moveIdeaPreview", () => { it("throws if idea not found", async () => { mockPrisma.idea.findFirst.mockResolvedValueOnce(null); await expect( - moveIdeaPreview(COMPANY_UUID, IDEA_UUID, TARGET_PROJECT_UUID) + moveIdeaPreview(COMPANY_UUID, IDEA_UUID, TARGET_PROJECT_UUID, adminAuth) ).rejects.toThrow("Idea not found"); }); @@ -819,7 +835,7 @@ describe("moveIdeaPreview", () => { mockPrisma.idea.findFirst.mockResolvedValueOnce(makeIdeaRecord()); mockPrisma.project.findFirst.mockResolvedValueOnce(null); await expect( - moveIdeaPreview(COMPANY_UUID, IDEA_UUID, TARGET_PROJECT_UUID) + moveIdeaPreview(COMPANY_UUID, IDEA_UUID, TARGET_PROJECT_UUID, adminAuth) ).rejects.toThrow("Target project not found"); }); @@ -827,7 +843,7 @@ describe("moveIdeaPreview", () => { mockPrisma.idea.findFirst.mockResolvedValueOnce(makeIdeaRecord()); mockPrisma.project.findFirst.mockResolvedValueOnce({ uuid: PROJECT_UUID, name: "Same" }); await expect( - moveIdeaPreview(COMPANY_UUID, IDEA_UUID, PROJECT_UUID) + moveIdeaPreview(COMPANY_UUID, IDEA_UUID, PROJECT_UUID, adminAuth) ).rejects.toThrow("Idea is already in the target project"); }); }); @@ -837,7 +853,7 @@ describe("updateIdea", () => { const updated = makeIdeaRecord({ title: "Updated Title" }); mockPrisma.idea.update.mockResolvedValue(updated); - const result = await updateIdea(IDEA_UUID, COMPANY_UUID, { title: "Updated Title" }); + const result = await updateIdea(IDEA_UUID, COMPANY_UUID, { title: "Updated Title" }, adminAuth); expect(mockPrisma.idea.update).toHaveBeenCalledWith( expect.objectContaining({ @@ -853,7 +869,7 @@ describe("updateIdea", () => { const updated = makeIdeaRecord({ status: "elaborated" }); mockPrisma.idea.update.mockResolvedValue(updated); - const result = await updateIdea(IDEA_UUID, COMPANY_UUID, { status: "elaborated" }); + const result = await updateIdea(IDEA_UUID, COMPANY_UUID, { status: "elaborated" }, adminAuth); expect(result.status).toBe("elaborated"); }); @@ -879,6 +895,7 @@ describe("updateIdea", () => { IDEA_UUID, COMPANY_UUID, { content: newContent }, + adminAuth, { actorType: "user", actorUuid: ACTOR_UUID } ); @@ -927,7 +944,7 @@ describe("updateIdea", () => { await updateIdea(IDEA_UUID, COMPANY_UUID, { content: "Content with @user[user-uuid]", - }); + }, adminAuth); expect(mockPrisma.idea.findUnique).not.toHaveBeenCalled(); expect(mockParseMentions).not.toHaveBeenCalled(); @@ -942,6 +959,7 @@ describe("updateIdea", () => { IDEA_UUID, COMPANY_UUID, { title: "Updated Title" }, + adminAuth, { actorType: "user", actorUuid: ACTOR_UUID } ); @@ -960,6 +978,7 @@ describe("updateIdea", () => { IDEA_UUID, COMPANY_UUID, { content: null }, + adminAuth, { actorType: "user", actorUuid: ACTOR_UUID } ); @@ -979,6 +998,7 @@ describe("updateIdea", () => { IDEA_UUID, COMPANY_UUID, { content: "" }, + adminAuth, { actorType: "user", actorUuid: ACTOR_UUID } ); @@ -993,7 +1013,7 @@ describe("deleteIdea", () => { const deleted = makeIdeaRecord(); mockPrisma.idea.delete.mockResolvedValue(deleted); - const result = await deleteIdea(IDEA_UUID); + const result = await deleteIdea(IDEA_UUID, adminAuth); expect(mockPrisma.idea.delete).toHaveBeenCalledWith({ where: { uuid: IDEA_UUID }, @@ -1025,6 +1045,7 @@ describe("listIdeas — reportCount aggregation", () => { projectUuid: PROJECT_UUID, skip: 0, take: 20, + auth: adminAuth, }); expect(result.ideas).toHaveLength(2); @@ -1048,6 +1069,7 @@ describe("listIdeas — reportCount aggregation", () => { projectUuid: PROJECT_UUID, skip: 0, take: 20, + auth: adminAuth, }); expect(result.ideas[0].reportCount).toBe(0); @@ -1074,6 +1096,7 @@ describe("listIdeas — reportCount aggregation", () => { projectUuid: PROJECT_UUID, skip: 0, take: 20, + auth: adminAuth, }); // Bad rows ignored; good row counted: 2 reports under idea-A. @@ -1099,6 +1122,7 @@ describe("listIdeas — reportCount aggregation", () => { projectUuid: PROJECT_UUID, skip: 0, take: 20, + auth: adminAuth, }); const a = result.ideas.find((i) => i.uuid === "idea-A"); @@ -1125,6 +1149,7 @@ describe("listIdeas — reportCount aggregation", () => { projectUuid: PROJECT_UUID, skip: 0, take: 20, + auth: adminAuth, }); expect(result.ideas[0].reportCount).toBe(2); @@ -1145,7 +1170,7 @@ describe("getIdea — reports[] aggregation", () => { // No proposals point at this idea. mockPrisma.proposal.findMany.mockResolvedValue([]); - const result = await getIdea(COMPANY_UUID, IDEA_UUID); + const result = await getIdea(COMPANY_UUID, IDEA_UUID, adminAuth); expect(result?.reports).toEqual([]); // Skipped the document fetch entirely. @@ -1235,7 +1260,7 @@ describe("getIdea — reports[] aggregation", () => { }, ]); - const result = await getIdea(COMPANY_UUID, IDEA_UUID); + const result = await getIdea(COMPANY_UUID, IDEA_UUID, adminAuth); expect(result?.reports).toHaveLength(2); expect(result?.reports?.map((r) => r.uuid).sort()).toEqual( @@ -1260,9 +1285,64 @@ describe("getIdea — reports[] aggregation", () => { it("returns null when the idea does not exist", async () => { mockPrisma.idea.findFirst.mockResolvedValue(null); - const result = await getIdea(COMPANY_UUID, IDEA_UUID); + const result = await getIdea(COMPANY_UUID, IDEA_UUID, adminAuth); expect(result).toBeNull(); expect(mockPrisma.proposal.findMany).not.toHaveBeenCalled(); }); }); + +// ===== Project-visibility access gating (non-super-admin) ===== + +describe("access gating", () => { + // canAccessProject for a regular user: project is private, owned by someone + // else, with no membership row -> access denied. + function denyAccess() { + mockPrisma.project.findFirst.mockResolvedValue({ + visibility: "private", + ownerType: "user", + ownerUuid: "other-owner", + }); + mockPrisma.projectMember.findUnique.mockResolvedValue(null); + } + + it("listIdeas returns an empty page for a non-member of the project", async () => { + denyAccess(); + + const result = await listIdeas({ + companyUuid: COMPANY_UUID, + projectUuid: PROJECT_UUID, + skip: 0, + take: 20, + auth: userAuth, + }); + + expect(result).toEqual({ ideas: [], total: 0 }); + // The list query must be short-circuited before hitting the idea table. + expect(mockPrisma.idea.findMany).not.toHaveBeenCalled(); + }); + + it("getIdea returns null for a non-member of the idea's project", async () => { + mockPrisma.idea.findFirst.mockResolvedValue(makeIdeaRecord()); + denyAccess(); + + const result = await getIdea(COMPANY_UUID, IDEA_UUID, userAuth); + expect(result).toBeNull(); + }); + + it("claimIdea rejects a non-member of the idea's project", async () => { + mockPrisma.idea.findFirst.mockResolvedValue(makeIdeaRecord({ assigneeUuid: null })); + denyAccess(); + + await expect( + claimIdea({ + ideaUuid: IDEA_UUID, + companyUuid: COMPANY_UUID, + assigneeType: "agent", + assigneeUuid: ACTOR_UUID, + }, userAuth) + ).rejects.toThrow(AlreadyClaimedError); + // Must not have mutated the idea. + expect(mockPrisma.idea.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/__tests__/mention.service.test.ts b/src/services/__tests__/mention.service.test.ts index 8056765f..2ac70d54 100644 --- a/src/services/__tests__/mention.service.test.ts +++ b/src/services/__tests__/mention.service.test.ts @@ -288,6 +288,75 @@ describe("searchMentionables", () => { expect(mockPrisma.user.findMany).not.toHaveBeenCalled(); }); + it("should NOT include users on empty query when includeUsersOnEmpty is false (default)", async () => { + mockPrisma.agent.findMany.mockResolvedValue([ + { uuid: AGENT_UUID, name: "MyBot", roles: ["developer_agent"] }, + ]); + + const results = await searchMentionables({ + companyUuid: COMPANY_UUID, + query: "", + actorType: "user", + actorUuid: ACTOR_UUID, + includeUsersOnEmpty: false, + }); + + expect(results).toHaveLength(1); + expect(results.every((r) => r.type === "agent")).toBe(true); + expect(mockPrisma.user.findMany).not.toHaveBeenCalled(); + }); + + it("should also return recent company users on empty query when includeUsersOnEmpty is true", async () => { + mockPrisma.agent.findMany.mockResolvedValue([ + { uuid: AGENT_UUID, name: "MyBot", roles: ["developer_agent"] }, + ]); + mockPrisma.user.findMany.mockResolvedValue([ + { uuid: USER_UUID, name: "Alice", email: "alice@example.com", avatarUrl: null }, + ]); + + const results = await searchMentionables({ + companyUuid: COMPANY_UUID, + query: "", + actorType: "user", + actorUuid: ACTOR_UUID, + includeUsersOnEmpty: true, + }); + + // Both the own agent and the recent company user are present. + expect(results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "agent", uuid: AGENT_UUID }), + expect.objectContaining({ type: "user", uuid: USER_UUID, name: "Alice" }), + ]) + ); + // Users are queried company-wide, ordered by createdAt desc. + expect(mockPrisma.user.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { companyUuid: COMPANY_UUID }, + orderBy: { createdAt: "desc" }, + }) + ); + }); + + it("should fall back to email/Unknown for users with no name on empty query (includeUsersOnEmpty)", async () => { + mockPrisma.agent.findMany.mockResolvedValue([]); + mockPrisma.user.findMany.mockResolvedValue([ + { uuid: USER_UUID, name: null, email: "noname@example.com", avatarUrl: null }, + ]); + + const results = await searchMentionables({ + companyUuid: COMPANY_UUID, + query: "", + actorType: "user", + actorUuid: ACTOR_UUID, + includeUsersOnEmpty: true, + }); + + expect(results).toEqual([ + expect.objectContaining({ type: "user", uuid: USER_UUID, name: "noname@example.com" }), + ]); + }); + it("should scope agents by ownerUuid for agent caller", async () => { const ownerUuid = "77777777-7777-7777-7777-777777777777"; diff --git a/src/services/__tests__/notification.service.test.ts b/src/services/__tests__/notification.service.test.ts index 0f67ff72..eb5f0a49 100644 --- a/src/services/__tests__/notification.service.test.ts +++ b/src/services/__tests__/notification.service.test.ts @@ -14,6 +14,18 @@ const mockPrisma = vi.hoisted(() => ({ create: vi.fn(), upsert: vi.fn(), }, + project: { + findMany: vi.fn(), + }, + projectMember: { + findMany: vi.fn(), + }, + projectGroup: { + findMany: vi.fn(), + }, + projectGroupMember: { + findMany: vi.fn(), + }, })); vi.mock("@/lib/prisma", () => ({ prisma: mockPrisma })); @@ -40,6 +52,18 @@ const companyUuid = "company-0000-0000-0000-000000000001"; const recipientUuid = "user-0000-0000-0000-000000000001"; const notifUuid = "notif-0000-0000-0000-000000000001"; +// Super-admin auth: getAccessibleProjectUuids returns the ALL sentinel without +// touching prisma, so no visibility OR-clause is added and existing where-shape +// assertions stay valid. +const superAdminAuth = { type: "super_admin" as const, email: "admin@chorus.local" }; + +// Regular user auth for the visibility test. +const userAuth = { + type: "user" as const, + companyUuid, + actorUuid: recipientUuid, +}; + function makeNotifParams(overrides: Record = {}) { return { companyUuid, @@ -72,6 +96,10 @@ function makeNotifRecord(overrides: Record = {}) { beforeEach(() => { vi.clearAllMocks(); + // getAccessibleProjectUuids (non-super-admin) also consults group ownership/ + // membership; default empty so existing assertions are unaffected. + mockPrisma.projectGroup.findMany.mockResolvedValue([]); + mockPrisma.projectGroupMember.findMany.mockResolvedValue([]); }); // ===== create ===== @@ -141,6 +169,7 @@ describe("list", () => { .mockResolvedValueOnce(5); // unreadCount const result = await list({ + auth: superAdminAuth, companyUuid, recipientType: "user", recipientUuid, @@ -158,6 +187,7 @@ describe("list", () => { mockPrisma.notification.count.mockResolvedValue(0); await list({ + auth: superAdminAuth, companyUuid, recipientType: "user", recipientUuid, @@ -178,6 +208,7 @@ describe("list", () => { mockPrisma.notification.count.mockResolvedValue(0); await list({ + auth: superAdminAuth, companyUuid, recipientType: "user", recipientUuid, @@ -199,6 +230,7 @@ describe("list", () => { mockPrisma.notification.count.mockResolvedValue(0); await list({ + auth: superAdminAuth, companyUuid, recipientType: "user", recipientUuid, @@ -213,6 +245,33 @@ describe("list", () => { }) ); }); + + it("restricts project-scoped rows to the accessible set but preserves non-project ones", async () => { + // User is a member of exactly one shared project; no extra memberships. + mockPrisma.project.findMany.mockResolvedValue([{ uuid: "project-accessible" }]); + mockPrisma.projectMember.findMany.mockResolvedValue([]); + mockPrisma.notification.findMany.mockResolvedValue([]); + mockPrisma.notification.count.mockResolvedValue(0); + + await list({ + auth: userAuth, + companyUuid, + recipientType: "user", + recipientUuid, + skip: 0, + take: 20, + }); + + // The where carries an additive OR: accessible projects OR the + // empty-projectUuid sentinel (non-project notifications are never hidden). + expect(mockPrisma.notification.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + OR: [{ projectUuid: { in: ["project-accessible"] } }, { projectUuid: "" }], + }), + }) + ); + }); }); // ===== markRead ===== diff --git a/src/services/__tests__/project-group.service.test.ts b/src/services/__tests__/project-group.service.test.ts index 6defef8a..3101e669 100644 --- a/src/services/__tests__/project-group.service.test.ts +++ b/src/services/__tests__/project-group.service.test.ts @@ -9,6 +9,16 @@ const mockPrisma = vi.hoisted(() => ({ update: vi.fn(), delete: vi.fn(), }, + projectMember: { + findMany: vi.fn(), + }, + projectGroupMember: { + findMany: vi.fn(), + findUnique: vi.fn(), + create: vi.fn(), + createMany: vi.fn(), + delete: vi.fn(), + }, project: { findFirst: vi.fn(), findMany: vi.fn(), @@ -31,6 +41,13 @@ const mockPrisma = vi.hoisted(() => ({ activity: { findMany: vi.fn(), }, + // getActorName (uuid-resolver) resolves member display names via these. + user: { + findUnique: vi.fn(), + }, + agent: { + findUnique: vi.fn(), + }, })); vi.mock("@/lib/prisma", () => ({ prisma: mockPrisma })); @@ -47,11 +64,20 @@ import { listProjectGroups, moveProjectToGroup, getGroupDashboard, + setGroupVisibility, + listGroupMembers, + addGroupMember, + removeGroupMember, } from "@/services/project-group.service"; +import type { SuperAdminAuthContext, AuthContext } from "@/types/auth"; // ===== Helpers ===== +// super_admin bypasses access gating (getAccessibleProjectUuids => ALL), so the +// existing query-behavior assertions remain valid without extra mock setup. +const adminAuth: SuperAdminAuthContext = { type: "super_admin", email: "root@chorus.local" }; const now = new Date("2026-03-13T00:00:00Z"); const companyUuid = "company-0000-0000-0000-000000000001"; +const userAuth: AuthContext = { type: "user", companyUuid, actorUuid: "user-1" }; const groupUuid = "group-0000-0000-0000-000000000001"; const projectUuid = "project-0000-0000-0000-000000000001"; @@ -105,6 +131,9 @@ describe("createProjectGroup", () => { companyUuid, name: "Test Group", description: "A test group", + visibility: "private", + ownerType: null, + ownerUuid: null, }, }); }); @@ -124,6 +153,9 @@ describe("createProjectGroup", () => { companyUuid, name: "Test Group", description: "", + visibility: "private", + ownerType: null, + ownerUuid: null, }, }); }); @@ -142,8 +174,203 @@ describe("createProjectGroup", () => { companyUuid, name: "Test Group", description: "", + visibility: "private", + ownerType: null, + ownerUuid: null, + }, + }); + }); + + it("persists visibility/owner and seeds the owner + members (de-duped)", async () => { + const group = makeProjectGroup({ + visibility: "shared", + ownerType: "user", + ownerUuid: "user-1", + }); + mockPrisma.projectGroup.create.mockResolvedValue(group); + mockPrisma.projectGroupMember.createMany.mockResolvedValue({ count: 2 }); + + await createProjectGroup({ + companyUuid, + name: "Test Group", + visibility: "shared", + ownerType: "user", + ownerUuid: "user-1", + // owner duplicated in memberUuids — must be de-duped; agent-9 is distinct. + memberUuids: [ + { memberType: "user", memberUuid: "user-1" }, + { memberType: "agent", memberUuid: "agent-9" }, + ], + }); + + expect(mockPrisma.projectGroup.create).toHaveBeenCalledWith({ + data: { + companyUuid, + name: "Test Group", + description: "", + visibility: "shared", + ownerType: "user", + ownerUuid: "user-1", }, }); + // Owner + agent-9 = 2 rows (user-1 not duplicated). + expect(mockPrisma.projectGroupMember.createMany).toHaveBeenCalledWith({ + data: [ + { companyUuid, projectGroupUuid: groupUuid, memberType: "user", memberUuid: "user-1" }, + { companyUuid, projectGroupUuid: groupUuid, memberType: "agent", memberUuid: "agent-9" }, + ], + }); + }); + + it("does not seed members when there is no owner and no members", async () => { + mockPrisma.projectGroup.create.mockResolvedValue(makeProjectGroup()); + + await createProjectGroup({ companyUuid, name: "Test Group" }); + + expect(mockPrisma.projectGroupMember.createMany).not.toHaveBeenCalled(); + }); +}); + +// ===== setGroupVisibility ===== +describe("setGroupVisibility", () => { + it("updates visibility when the group exists", async () => { + mockPrisma.projectGroup.findFirst.mockResolvedValue({ uuid: groupUuid }); + mockPrisma.projectGroup.update.mockResolvedValue({ uuid: groupUuid, visibility: "shared" }); + + const result = await setGroupVisibility(companyUuid, groupUuid, "shared"); + + expect(result).toEqual({ uuid: groupUuid, visibility: "shared" }); + expect(mockPrisma.projectGroup.update).toHaveBeenCalledWith( + expect.objectContaining({ data: { visibility: "shared" } }) + ); + }); + + it("returns null when the group does not exist", async () => { + mockPrisma.projectGroup.findFirst.mockResolvedValue(null); + + const result = await setGroupVisibility(companyUuid, groupUuid, "shared"); + + expect(result).toBeNull(); + expect(mockPrisma.projectGroup.update).not.toHaveBeenCalled(); + }); +}); + +// ===== Group member CRUD ===== +describe("listGroupMembers", () => { + it("returns members ordered by createdAt asc with resolved names", async () => { + mockPrisma.projectGroupMember.findMany.mockResolvedValue([ + { uuid: "m1", memberType: "user", memberUuid: "user-1", role: "member", createdAt: now }, + ]); + // getActorName resolves a user's display name via prisma.user.findUnique. + mockPrisma.user.findUnique.mockResolvedValue({ name: "Alice", email: "alice@example.com" }); + + const result = await listGroupMembers(companyUuid, groupUuid); + + expect(result).toHaveLength(1); + expect(result[0].memberUuid).toBe("user-1"); + expect(result[0].name).toBe("Alice"); + expect(mockPrisma.projectGroupMember.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { companyUuid, projectGroupUuid: groupUuid }, + orderBy: { createdAt: "asc" }, + }) + ); + }); +}); + +describe("addGroupMember", () => { + it("adds a new member when not already present", async () => { + mockPrisma.projectGroup.findFirst.mockResolvedValue({ uuid: groupUuid }); + mockPrisma.projectGroupMember.findUnique.mockResolvedValue(null); + mockPrisma.projectGroupMember.create.mockResolvedValue({ + uuid: "m2", + memberType: "user", + memberUuid: "new-user", + role: "member", + createdAt: now, + }); + + const result = await addGroupMember(companyUuid, groupUuid, "user", "new-user"); + + expect(result!.memberUuid).toBe("new-user"); + expect(mockPrisma.projectGroupMember.create).toHaveBeenCalled(); + }); + + it("is idempotent — returns the existing member without creating", async () => { + mockPrisma.projectGroup.findFirst.mockResolvedValue({ uuid: groupUuid }); + mockPrisma.projectGroupMember.findUnique.mockResolvedValue({ + uuid: "m1", + memberType: "user", + memberUuid: "user-1", + role: "member", + createdAt: now, + }); + + const result = await addGroupMember(companyUuid, groupUuid, "user", "user-1"); + + expect(result!.uuid).toBe("m1"); + expect(mockPrisma.projectGroupMember.create).not.toHaveBeenCalled(); + }); + + it("returns null when the group does not exist", async () => { + mockPrisma.projectGroup.findFirst.mockResolvedValue(null); + + const result = await addGroupMember(companyUuid, groupUuid, "user", "x"); + + expect(result).toBeNull(); + expect(mockPrisma.projectGroupMember.create).not.toHaveBeenCalled(); + }); +}); + +describe("removeGroupMember", () => { + it("removes a non-owner member", async () => { + mockPrisma.projectGroup.findFirst.mockResolvedValue({ + uuid: groupUuid, + ownerType: "user", + ownerUuid: "user-1", + }); + mockPrisma.projectGroupMember.findUnique.mockResolvedValue({ id: 5 }); + mockPrisma.projectGroupMember.delete.mockResolvedValue({}); + + const result = await removeGroupMember(companyUuid, groupUuid, "user", "victim"); + + expect(result).toBe(true); + expect(mockPrisma.projectGroupMember.delete).toHaveBeenCalled(); + }); + + it("refuses to remove the owner", async () => { + mockPrisma.projectGroup.findFirst.mockResolvedValue({ + uuid: groupUuid, + ownerType: "user", + ownerUuid: "user-1", + }); + + const result = await removeGroupMember(companyUuid, groupUuid, "user", "user-1"); + + expect(result).toBe(false); + expect(mockPrisma.projectGroupMember.delete).not.toHaveBeenCalled(); + }); + + it("returns false when the group does not exist", async () => { + mockPrisma.projectGroup.findFirst.mockResolvedValue(null); + + const result = await removeGroupMember(companyUuid, groupUuid, "user", "x"); + + expect(result).toBe(false); + }); + + it("returns false when the member is not present", async () => { + mockPrisma.projectGroup.findFirst.mockResolvedValue({ + uuid: groupUuid, + ownerType: "user", + ownerUuid: "user-1", + }); + mockPrisma.projectGroupMember.findUnique.mockResolvedValue(null); + + const result = await removeGroupMember(companyUuid, groupUuid, "user", "ghost"); + + expect(result).toBe(false); + expect(mockPrisma.projectGroupMember.delete).not.toHaveBeenCalled(); }); }); @@ -306,7 +533,7 @@ describe("getProjectGroup", () => { mockPrisma.projectGroup.findFirst.mockResolvedValue(group); mockPrisma.project.findMany.mockResolvedValue([project]); - const result = await getProjectGroup(companyUuid, groupUuid); + const result = await getProjectGroup(companyUuid, groupUuid, adminAuth); expect(result).not.toBeNull(); expect(result!.uuid).toBe(groupUuid); @@ -318,7 +545,7 @@ describe("getProjectGroup", () => { it("should return null when group not found", async () => { mockPrisma.projectGroup.findFirst.mockResolvedValue(null); - const result = await getProjectGroup(companyUuid, groupUuid); + const result = await getProjectGroup(companyUuid, groupUuid, adminAuth); expect(result).toBeNull(); expect(mockPrisma.project.findMany).not.toHaveBeenCalled(); @@ -329,7 +556,7 @@ describe("getProjectGroup", () => { mockPrisma.projectGroup.findFirst.mockResolvedValue(group); mockPrisma.project.findMany.mockResolvedValue([]); - await getProjectGroup(companyUuid, groupUuid); + await getProjectGroup(companyUuid, groupUuid, adminAuth); expect(mockPrisma.project.findMany).toHaveBeenCalledWith( expect.objectContaining({ @@ -352,7 +579,7 @@ describe("listProjectGroups", () => { ]); mockPrisma.project.count.mockResolvedValue(2); - const result = await listProjectGroups(companyUuid); + const result = await listProjectGroups(companyUuid, adminAuth); expect(result.groups).toHaveLength(2); expect(result.total).toBe(2); @@ -367,7 +594,7 @@ describe("listProjectGroups", () => { mockPrisma.project.groupBy.mockResolvedValue([]); mockPrisma.project.count.mockResolvedValue(0); - const result = await listProjectGroups(companyUuid); + const result = await listProjectGroups(companyUuid, adminAuth); expect(result.groups[0].projectCount).toBe(0); }); @@ -377,7 +604,7 @@ describe("listProjectGroups", () => { mockPrisma.project.groupBy.mockResolvedValue([]); mockPrisma.project.count.mockResolvedValue(0); - await listProjectGroups(companyUuid); + await listProjectGroups(companyUuid, adminAuth); expect(mockPrisma.projectGroup.findMany).toHaveBeenCalledWith( expect.objectContaining({ @@ -386,12 +613,99 @@ describe("listProjectGroups", () => { ); }); + it("regression: a regular user still sees a freshly-created empty PRIVATE group they own (no accessible-project filter on the group list)", async () => { + // A brand-new group has zero projects. The creator OWNS it, so it must + // STILL appear even though they have no accessible projects yet — hiding it + // made the UI "create group" button look broken. + const freshGroup = makeProjectGroup({ + uuid: "group-new", + name: "Fresh", + visibility: "private", + ownerType: "user", + ownerUuid: "user-1", + }); + mockPrisma.projectGroup.findMany.mockResolvedValue([freshGroup]); + // getAccessibleGroupUuids: this user owns freshGroup (findMany returns it), + // and has no extra group memberships. + mockPrisma.projectGroupMember.findMany.mockResolvedValue([]); + // getAccessibleProjectUuids: no accessible projects for this user. + mockPrisma.project.findMany.mockResolvedValue([]); + mockPrisma.projectMember.findMany.mockResolvedValue([]); + mockPrisma.project.groupBy.mockResolvedValue([]); // 0 projects in the group + mockPrisma.project.count.mockResolvedValue(0); + + const result = await listProjectGroups(companyUuid, userAuth); + + expect(result.groups).toHaveLength(1); + expect(result.groups[0].uuid).toBe("group-new"); + expect(result.groups[0].projectCount).toBe(0); + }); + + it("gating: a non-member does NOT see another user's private group", async () => { + // Two groups in the company: a shared one, and another user's PRIVATE group. + const sharedGroup = makeProjectGroup({ uuid: "group-shared", visibility: "shared" }); + const othersPrivate = makeProjectGroup({ + uuid: "group-other-private", + visibility: "private", + ownerType: "user", + ownerUuid: "user-2", + }); + // listProjectGroups' own findMany returns ALL company groups; the + // getAccessibleGroupUuids findMany (shared OR owned-by-user-1) returns only + // the shared one. We model both calls returning the full list — the service + // filters by the accessible-group set, which the helper computes. + mockPrisma.projectGroup.findMany.mockImplementation(({ where }) => { + // getAccessibleGroupUuids passes an OR clause; emulate by returning only + // groups visible to user-1 (shared OR owned by user-1). + if (where?.OR) { + return Promise.resolve([sharedGroup]); + } + return Promise.resolve([sharedGroup, othersPrivate]); + }); + mockPrisma.projectGroupMember.findMany.mockResolvedValue([]); + mockPrisma.project.groupBy.mockResolvedValue([]); + mockPrisma.project.count.mockResolvedValue(0); + + const result = await listProjectGroups(companyUuid, userAuth); + + expect(result.groups).toHaveLength(1); + expect(result.groups[0].uuid).toBe("group-shared"); + }); + + it("gating: shared groups are visible to any user in the company", async () => { + const sharedGroup = makeProjectGroup({ uuid: "group-shared", visibility: "shared" }); + mockPrisma.projectGroup.findMany.mockImplementation(({ where }) => { + if (where?.OR) return Promise.resolve([sharedGroup]); + return Promise.resolve([sharedGroup]); + }); + mockPrisma.projectGroupMember.findMany.mockResolvedValue([]); + mockPrisma.project.groupBy.mockResolvedValue([]); + mockPrisma.project.count.mockResolvedValue(0); + + const result = await listProjectGroups(companyUuid, userAuth); + + expect(result.groups).toHaveLength(1); + expect(result.groups[0].uuid).toBe("group-shared"); + }); + + it("gating: super_admin sees all groups (no filter)", async () => { + const g1 = makeProjectGroup({ uuid: "g1", visibility: "private", ownerUuid: "user-9" }); + const g2 = makeProjectGroup({ uuid: "g2", visibility: "shared" }); + mockPrisma.projectGroup.findMany.mockResolvedValue([g1, g2]); + mockPrisma.project.groupBy.mockResolvedValue([]); + mockPrisma.project.count.mockResolvedValue(0); + + const result = await listProjectGroups(companyUuid, adminAuth); + + expect(result.groups).toHaveLength(2); + }); + it("should handle empty groups list", async () => { mockPrisma.projectGroup.findMany.mockResolvedValue([]); mockPrisma.project.groupBy.mockResolvedValue([]); mockPrisma.project.count.mockResolvedValue(10); - const result = await listProjectGroups(companyUuid); + const result = await listProjectGroups(companyUuid, adminAuth); expect(result.groups).toEqual([]); expect(result.total).toBe(0); @@ -523,7 +837,7 @@ describe("getGroupDashboard", () => { }, ]); - const result = await getGroupDashboard(companyUuid, groupUuid); + const result = await getGroupDashboard(companyUuid, groupUuid, adminAuth); expect(result).not.toBeNull(); expect(result!.group.uuid).toBe(groupUuid); @@ -541,7 +855,7 @@ describe("getGroupDashboard", () => { it("should return null when group not found", async () => { mockPrisma.projectGroup.findFirst.mockResolvedValue(null); - const result = await getGroupDashboard(companyUuid, groupUuid); + const result = await getGroupDashboard(companyUuid, groupUuid, adminAuth); expect(result).toBeNull(); }); @@ -551,7 +865,7 @@ describe("getGroupDashboard", () => { mockPrisma.projectGroup.findFirst.mockResolvedValue(group); mockPrisma.project.findMany.mockResolvedValue([]); - const result = await getGroupDashboard(companyUuid, groupUuid); + const result = await getGroupDashboard(companyUuid, groupUuid, adminAuth); expect(result).not.toBeNull(); expect(result!.stats.projectCount).toBe(0); @@ -578,7 +892,7 @@ describe("getGroupDashboard", () => { .mockResolvedValueOnce([]); mockPrisma.activity.findMany.mockResolvedValue([]); - const result = await getGroupDashboard(companyUuid, groupUuid); + const result = await getGroupDashboard(companyUuid, groupUuid, adminAuth); expect(result!.stats.completionRate).toBe(0); }); @@ -595,7 +909,7 @@ describe("getGroupDashboard", () => { mockPrisma.task.groupBy.mockResolvedValue([]); mockPrisma.activity.findMany.mockResolvedValue([]); - await getGroupDashboard(companyUuid, groupUuid); + await getGroupDashboard(companyUuid, groupUuid, adminAuth); expect(mockPrisma.activity.findMany).toHaveBeenCalledWith( expect.objectContaining({ @@ -628,7 +942,7 @@ describe("getGroupDashboard", () => { }, ]); - const result = await getGroupDashboard(companyUuid, groupUuid); + const result = await getGroupDashboard(companyUuid, groupUuid, adminAuth); expect(result!.recentActivity[0].projectName).toBe("My Project"); }); @@ -657,7 +971,7 @@ describe("getGroupDashboard", () => { }, ]); - const result = await getGroupDashboard(companyUuid, groupUuid); + const result = await getGroupDashboard(companyUuid, groupUuid, adminAuth); expect(result!.recentActivity[0].projectName).toBe("Unknown"); }); diff --git a/src/services/__tests__/project.service.test.ts b/src/services/__tests__/project.service.test.ts index 23f62ff3..d5fda870 100644 --- a/src/services/__tests__/project.service.test.ts +++ b/src/services/__tests__/project.service.test.ts @@ -10,6 +10,27 @@ const mockPrisma = vi.hoisted(() => ({ update: vi.fn(), delete: vi.fn(), }, + projectMember: { + findMany: vi.fn(), + findUnique: vi.fn(), + create: vi.fn(), + createMany: vi.fn(), + delete: vi.fn(), + }, + // getActorName (uuid-resolver) resolves member display names via these. + user: { + findUnique: vi.fn(), + }, + agent: { + findUnique: vi.fn(), + }, + projectGroup: { + findFirst: vi.fn(), + findMany: vi.fn(), + }, + projectGroupMember: { + findMany: vi.fn(), + }, task: { count: vi.fn(), groupBy: vi.fn(), @@ -40,13 +61,24 @@ import { getCompanyOverviewStats, getProjectStats, listProjectsWithStats, + setProjectVisibility, + listProjectMembers, + addProjectMember, + removeProjectMember, } from "@/services/project.service"; +import type { AuthContext, SuperAdminAuthContext } from "@/types/auth"; // ===== Helpers ===== const now = new Date("2026-03-13T00:00:00Z"); const companyUuid = "company-0000-0000-0000-000000000001"; const projectUuid = "project-0000-0000-0000-000000000001"; +// super_admin bypasses access gating (no extra prisma queries), so the existing +// query-behavior tests below use it to isolate the function's own logic. +const adminAuth: SuperAdminAuthContext = { type: "super_admin", email: "root@chorus.local" }; +// A regular user used for access-gating tests. +const userAuth: AuthContext = { type: "user", companyUuid, actorUuid: "user-1" }; + function makeProject(overrides: Record = {}) { return { uuid: projectUuid, @@ -62,6 +94,11 @@ function makeProject(overrides: Record = {}) { beforeEach(() => { vi.clearAllMocks(); + // Default empty results for the two-level group union lookups + // (getAccessibleProjectUuids -> getOwnedOrMemberGroupUuids). Tests that care + // about group inheritance override these. + mockPrisma.projectGroup.findMany.mockResolvedValue([]); + mockPrisma.projectGroupMember.findMany.mockResolvedValue([]); }); // ===== listProjects ===== @@ -71,7 +108,7 @@ describe("listProjects", () => { mockPrisma.project.findMany.mockResolvedValue([project]); mockPrisma.project.count.mockResolvedValue(1); - const result = await listProjects({ companyUuid, skip: 0, take: 20 }); + const result = await listProjects({ companyUuid, skip: 0, take: 20, auth: adminAuth }); expect(result.projects).toHaveLength(1); expect(result.total).toBe(1); @@ -83,7 +120,7 @@ describe("listProjects", () => { mockPrisma.project.findMany.mockResolvedValue([]); mockPrisma.project.count.mockResolvedValue(0); - await listProjects({ companyUuid, skip: 10, take: 5 }); + await listProjects({ companyUuid, skip: 10, take: 5, auth: adminAuth }); expect(mockPrisma.project.findMany).toHaveBeenCalledWith( expect.objectContaining({ skip: 10, take: 5 }) @@ -97,7 +134,7 @@ describe("getProject", () => { const project = makeProject({ _count: { ideas: 5, documents: 3, tasks: 10, proposals: 2, activities: 100 } }); mockPrisma.project.findFirst.mockResolvedValue(project); - const result = await getProject(companyUuid, projectUuid); + const result = await getProject(companyUuid, projectUuid, adminAuth); expect(result).not.toBeNull(); expect(result!.uuid).toBe(projectUuid); @@ -107,7 +144,7 @@ describe("getProject", () => { it("should return null when project not found", async () => { mockPrisma.project.findFirst.mockResolvedValue(null); - const result = await getProject(companyUuid, "nonexistent"); + const result = await getProject(companyUuid, "nonexistent", adminAuth); expect(result).toBeNull(); }); @@ -116,7 +153,7 @@ describe("getProject", () => { const project = makeProject({ groupUuid, _count: { ideas: 1, documents: 0, tasks: 0, proposals: 0, activities: 0 } }); mockPrisma.project.findFirst.mockResolvedValue(project); - const result = await getProject(companyUuid, projectUuid); + const result = await getProject(companyUuid, projectUuid, adminAuth); expect(result!.groupUuid).toBe(groupUuid); expect(mockPrisma.project.findFirst).toHaveBeenCalledWith( @@ -233,14 +270,14 @@ describe("projectExists", () => { it("should return true when project exists", async () => { mockPrisma.project.findFirst.mockResolvedValue({ uuid: projectUuid }); - const result = await projectExists(companyUuid, projectUuid); + const result = await projectExists(companyUuid, projectUuid, adminAuth); expect(result).toBe(true); }); it("should return false when project does not exist", async () => { mockPrisma.project.findFirst.mockResolvedValue(null); - const result = await projectExists(companyUuid, "missing"); + const result = await projectExists(companyUuid, "missing", adminAuth); expect(result).toBe(false); }); }); @@ -253,7 +290,7 @@ describe("getProjectByUuid", () => { name: "Test Project", }); - const result = await getProjectByUuid(companyUuid, projectUuid); + const result = await getProjectByUuid(companyUuid, projectUuid, adminAuth); expect(result).toEqual({ uuid: projectUuid, @@ -268,7 +305,7 @@ describe("getProjectByUuid", () => { it("should return null when project not found", async () => { mockPrisma.project.findFirst.mockResolvedValue(null); - const result = await getProjectByUuid(companyUuid, "nonexistent"); + const result = await getProjectByUuid(companyUuid, "nonexistent", adminAuth); expect(result).toBeNull(); }); }); @@ -307,7 +344,7 @@ describe("getCompanyOverviewStats", () => { mockPrisma.proposal.count.mockResolvedValue(2); mockPrisma.idea.count.mockResolvedValue(10); - const result = await getCompanyOverviewStats(companyUuid); + const result = await getCompanyOverviewStats(companyUuid, adminAuth); expect(result).toEqual({ projects: 3, @@ -339,12 +376,12 @@ describe("getProjectStats", () => { ]); mockPrisma.document.count.mockResolvedValue(8); - const result = await getProjectStats(companyUuid, projectUuid); + const result = await getProjectStats(companyUuid, projectUuid, adminAuth); - expect(result.ideas).toEqual({ total: 8, open: 5 }); - expect(result.tasks).toEqual({ total: 16, inProgress: 4, todo: 3, toVerify: 2, done: 7 }); - expect(result.proposals).toEqual({ total: 7, pending: 2 }); - expect(result.documents).toEqual({ total: 8 }); + expect(result!.ideas).toEqual({ total: 8, open: 5 }); + expect(result!.tasks).toEqual({ total: 16, inProgress: 4, todo: 3, toVerify: 2, done: 7 }); + expect(result!.proposals).toEqual({ total: 7, pending: 2 }); + expect(result!.documents).toEqual({ total: 8 }); }); it("should default to zero when statuses are missing", async () => { @@ -353,12 +390,12 @@ describe("getProjectStats", () => { mockPrisma.proposal.groupBy.mockResolvedValue([]); mockPrisma.document.count.mockResolvedValue(0); - const result = await getProjectStats(companyUuid, projectUuid); + const result = await getProjectStats(companyUuid, projectUuid, adminAuth); - expect(result.ideas).toEqual({ total: 0, open: 0 }); - expect(result.tasks).toEqual({ total: 0, inProgress: 0, todo: 0, toVerify: 0, done: 0 }); - expect(result.proposals).toEqual({ total: 0, pending: 0 }); - expect(result.documents).toEqual({ total: 0 }); + expect(result!.ideas).toEqual({ total: 0, open: 0 }); + expect(result!.tasks).toEqual({ total: 0, inProgress: 0, todo: 0, toVerify: 0, done: 0 }); + expect(result!.proposals).toEqual({ total: 0, pending: 0 }); + expect(result!.documents).toEqual({ total: 0 }); }); }); @@ -375,7 +412,7 @@ describe("listProjectsWithStats", () => { { projectUuid: "project-0000-0000-0000-000000000002", _count: 3 }, ]); - const result = await listProjectsWithStats({ companyUuid, skip: 0, take: 20 }); + const result = await listProjectsWithStats({ companyUuid, skip: 0, take: 20, auth: adminAuth }); expect(result.projects).toHaveLength(2); expect(result.total).toBe(2); @@ -402,7 +439,7 @@ describe("listProjectsWithStats", () => { { projectUuid: "project-0000-0000-0000-000000000001", _count: 7 }, ]); - const result = await listProjectsWithStats({ companyUuid, skip: 0, take: 20 }); + const result = await listProjectsWithStats({ companyUuid, skip: 0, take: 20, auth: adminAuth }); expect(result.projects[0].tasksDone).toBe(7); expect(mockPrisma.task.groupBy).toHaveBeenCalledWith({ @@ -422,8 +459,272 @@ describe("listProjectsWithStats", () => { mockPrisma.project.count.mockResolvedValue(1); mockPrisma.task.groupBy.mockResolvedValue([]); - const result = await listProjectsWithStats({ companyUuid, skip: 0, take: 20 }); + const result = await listProjectsWithStats({ companyUuid, skip: 0, take: 20, auth: adminAuth }); expect(result.projects[0].tasksDone).toBe(0); }); }); + +// ===== Access gating (non-super-admin) ===== +describe("access gating", () => { + it("getProject returns null for a non-member of a private project", async () => { + // canAccessProject: project is private, owned by someone else, no membership. + mockPrisma.project.findFirst.mockResolvedValueOnce({ + visibility: "private", + ownerType: "user", + ownerUuid: "other-owner", + }); + mockPrisma.projectMember.findUnique.mockResolvedValueOnce(null); + + const result = await getProject(companyUuid, projectUuid, userAuth); + expect(result).toBeNull(); + }); + + it("projectExists returns false for a non-member of a private project", async () => { + mockPrisma.project.findFirst.mockResolvedValueOnce({ + visibility: "private", + ownerType: "user", + ownerUuid: "other-owner", + }); + mockPrisma.projectMember.findUnique.mockResolvedValueOnce(null); + + expect(await projectExists(companyUuid, projectUuid, userAuth)).toBe(false); + }); + + it("getProjectStats returns null for a non-member of a private project", async () => { + mockPrisma.project.findFirst.mockResolvedValueOnce({ + visibility: "private", + ownerType: "user", + ownerUuid: "other-owner", + }); + mockPrisma.projectMember.findUnique.mockResolvedValueOnce(null); + + expect(await getProjectStats(companyUuid, projectUuid, userAuth)).toBeNull(); + }); + + it("listProjects restricts the Project query to the accessible set for a user", async () => { + // getAccessibleProjectUuids: shared/owned + memberships. + mockPrisma.project.findMany + .mockResolvedValueOnce([{ uuid: "shared-1" }]) // accessible lookup + .mockResolvedValueOnce([]); // the actual list query + mockPrisma.projectMember.findMany.mockResolvedValueOnce([ + { projectUuid: "private-mine" }, + ]); + mockPrisma.project.count.mockResolvedValue(0); + + await listProjects({ companyUuid, skip: 0, take: 20, auth: userAuth }); + + // The list query must filter Project.uuid by the accessible set. + const listCall = mockPrisma.project.findMany.mock.calls[1][0]; + expect(listCall.where).toEqual({ + companyUuid, + uuid: { in: ["shared-1", "private-mine"] }, + }); + }); +}); + +// ===== createProject (owner + visibility + members) ===== +describe("createProject visibility & ownership", () => { + it("defaults to private, records owner, and seeds the owner as a member", async () => { + mockPrisma.project.create.mockResolvedValue( + makeProject({ visibility: "private", ownerType: "user", ownerUuid: "user-1" }), + ); + mockPrisma.projectMember.createMany.mockResolvedValue({ count: 1 }); + + await createProject({ + companyUuid, + name: "Private Project", + ownerType: "user", + ownerUuid: "user-1", + }); + + expect(mockPrisma.project.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + visibility: "private", + ownerType: "user", + ownerUuid: "user-1", + }), + }), + ); + // Owner seeded as a member. + expect(mockPrisma.projectMember.createMany).toHaveBeenCalledWith({ + data: [ + expect.objectContaining({ memberType: "user", memberUuid: "user-1" }), + ], + }); + }); + + it("seeds owner + explicit members, de-duplicated", async () => { + mockPrisma.project.create.mockResolvedValue( + makeProject({ visibility: "private", ownerType: "user", ownerUuid: "user-1" }), + ); + mockPrisma.projectMember.createMany.mockResolvedValue({ count: 2 }); + + await createProject({ + companyUuid, + name: "P", + ownerType: "user", + ownerUuid: "user-1", + memberUuids: [ + { memberType: "agent", memberUuid: "agent-9" }, + { memberType: "user", memberUuid: "user-1" }, // duplicate of owner + ], + }); + + const seeded = mockPrisma.projectMember.createMany.mock.calls[0][0].data; + expect(seeded).toHaveLength(2); // owner + agent-9, dedup removed the dup + }); + + it("supports an explicit shared visibility", async () => { + mockPrisma.project.create.mockResolvedValue(makeProject({ visibility: "shared" })); + mockPrisma.projectMember.createMany.mockResolvedValue({ count: 0 }); + + await createProject({ companyUuid, name: "Shared", visibility: "shared" }); + + expect(mockPrisma.project.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ visibility: "shared" }), + }), + ); + }); + + it("inherits the group's visibility when none is passed and a group is given", async () => { + const groupUuid = "group-0000-0000-0000-000000000001"; + mockPrisma.projectGroup.findFirst.mockResolvedValue({ visibility: "shared" }); + mockPrisma.project.create.mockResolvedValue(makeProject({ groupUuid, visibility: "shared" })); + mockPrisma.projectMember.createMany.mockResolvedValue({ count: 0 }); + + await createProject({ companyUuid, name: "Inherits", groupUuid }); + + expect(mockPrisma.projectGroup.findFirst).toHaveBeenCalledWith({ + where: { uuid: groupUuid, companyUuid }, + select: { visibility: true }, + }); + expect(mockPrisma.project.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ visibility: "shared", groupUuid }), + }), + ); + }); + + it("an explicit visibility overrides the group's visibility (no group lookup)", async () => { + const groupUuid = "group-0000-0000-0000-000000000001"; + mockPrisma.project.create.mockResolvedValue(makeProject({ groupUuid, visibility: "private" })); + mockPrisma.projectMember.createMany.mockResolvedValue({ count: 0 }); + + await createProject({ companyUuid, name: "Explicit", groupUuid, visibility: "private" }); + + expect(mockPrisma.projectGroup.findFirst).not.toHaveBeenCalled(); + expect(mockPrisma.project.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ visibility: "private" }), + }), + ); + }); + + it("defaults to private when a group is given but the group is not found", async () => { + const groupUuid = "group-missing"; + mockPrisma.projectGroup.findFirst.mockResolvedValue(null); + mockPrisma.project.create.mockResolvedValue(makeProject({ groupUuid, visibility: "private" })); + mockPrisma.projectMember.createMany.mockResolvedValue({ count: 0 }); + + await createProject({ companyUuid, name: "Orphan", groupUuid }); + + expect(mockPrisma.project.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ visibility: "private" }), + }), + ); + }); +}); + +// ===== setProjectVisibility ===== +describe("setProjectVisibility", () => { + it("updates visibility when the project exists", async () => { + mockPrisma.project.findFirst.mockResolvedValue({ uuid: projectUuid }); + mockPrisma.project.update.mockResolvedValue({ uuid: projectUuid, visibility: "shared" }); + + const result = await setProjectVisibility(companyUuid, projectUuid, "shared"); + expect(result).toEqual({ uuid: projectUuid, visibility: "shared" }); + expect(mockPrisma.project.update).toHaveBeenCalledWith( + expect.objectContaining({ data: { visibility: "shared" } }), + ); + }); + + it("returns null when the project is not found", async () => { + mockPrisma.project.findFirst.mockResolvedValue(null); + expect(await setProjectVisibility(companyUuid, "missing", "private")).toBeNull(); + }); +}); + +// ===== member CRUD ===== +describe("project members", () => { + it("listProjectMembers returns mapped members with resolved names", async () => { + mockPrisma.projectMember.findMany.mockResolvedValue([ + { uuid: "m1", memberType: "user", memberUuid: "user-2", role: "member", createdAt: now }, + ]); + // getActorName resolves a user's display name via prisma.user.findUnique. + mockPrisma.user.findUnique.mockResolvedValue({ name: "Bob", email: "bob@example.com" }); + const result = await listProjectMembers(companyUuid, projectUuid); + expect(result).toEqual([ + { uuid: "m1", memberType: "user", memberUuid: "user-2", name: "Bob", role: "member", createdAt: now.toISOString() }, + ]); + }); + + it("addProjectMember creates a new membership", async () => { + mockPrisma.project.findFirst.mockResolvedValue({ uuid: projectUuid }); + mockPrisma.projectMember.findUnique.mockResolvedValue(null); + mockPrisma.projectMember.create.mockResolvedValue({ + uuid: "m2", memberType: "agent", memberUuid: "agent-7", role: "member", createdAt: now, + }); + mockPrisma.agent.findUnique.mockResolvedValue({ name: "Agent Seven" }); + + const result = await addProjectMember(companyUuid, projectUuid, "agent", "agent-7"); + expect(result!.memberUuid).toBe("agent-7"); + expect(result!.name).toBe("Agent Seven"); + expect(mockPrisma.projectMember.create).toHaveBeenCalled(); + }); + + it("addProjectMember is idempotent (returns existing without creating)", async () => { + mockPrisma.project.findFirst.mockResolvedValue({ uuid: projectUuid }); + mockPrisma.projectMember.findUnique.mockResolvedValue({ + uuid: "m3", memberType: "user", memberUuid: "user-2", role: "member", createdAt: now, + }); + + await addProjectMember(companyUuid, projectUuid, "user", "user-2"); + expect(mockPrisma.projectMember.create).not.toHaveBeenCalled(); + }); + + it("addProjectMember returns null for a missing project", async () => { + mockPrisma.project.findFirst.mockResolvedValue(null); + expect(await addProjectMember(companyUuid, "missing", "user", "user-2")).toBeNull(); + }); + + it("removeProjectMember refuses to remove the owner", async () => { + mockPrisma.project.findFirst.mockResolvedValue({ + uuid: projectUuid, ownerType: "user", ownerUuid: "user-1", + }); + expect(await removeProjectMember(companyUuid, projectUuid, "user", "user-1")).toBe(false); + expect(mockPrisma.projectMember.delete).not.toHaveBeenCalled(); + }); + + it("removeProjectMember deletes a non-owner member", async () => { + mockPrisma.project.findFirst.mockResolvedValue({ + uuid: projectUuid, ownerType: "user", ownerUuid: "user-1", + }); + mockPrisma.projectMember.findUnique.mockResolvedValue({ id: 5 }); + mockPrisma.projectMember.delete.mockResolvedValue({}); + + expect(await removeProjectMember(companyUuid, projectUuid, "agent", "agent-7")).toBe(true); + expect(mockPrisma.projectMember.delete).toHaveBeenCalled(); + }); + + it("removeProjectMember returns false when the member does not exist", async () => { + mockPrisma.project.findFirst.mockResolvedValue({ + uuid: projectUuid, ownerType: "user", ownerUuid: "user-1", + }); + mockPrisma.projectMember.findUnique.mockResolvedValue(null); + expect(await removeProjectMember(companyUuid, projectUuid, "user", "user-99")).toBe(false); + }); +}); diff --git a/src/services/__tests__/proposal.service.test.ts b/src/services/__tests__/proposal.service.test.ts index b98cc291..68dd7bba 100644 --- a/src/services/__tests__/proposal.service.test.ts +++ b/src/services/__tests__/proposal.service.test.ts @@ -19,11 +19,18 @@ const { mockPrisma, mockEventBus, mockFormatCreatedBy, mockFormatReview, mockCre proposal: { create: vi.fn(), findFirst: vi.fn(), + findUnique: vi.fn(), findMany: vi.fn(), update: vi.fn(), delete: vi.fn(), count: vi.fn(), }, + project: { + findFirst: vi.fn(), + }, + projectMember: { + findUnique: vi.fn(), + }, idea: { findMany: vi.fn(), updateMany: vi.fn(), @@ -105,6 +112,7 @@ import { toTaskDraftIndex, } from "@/services/proposal.service"; import { makeProposal } from "@/__test-utils__/fixtures"; +import type { AuthContext, SuperAdminAuthContext } from "@/types/auth"; // ===== Helpers ===== @@ -112,6 +120,11 @@ const COMPANY_UUID = "00000000-0000-0000-0000-000000000001"; const PROJECT_UUID = "00000000-0000-0000-0000-000000000010"; const ACTOR_UUID = "00000000-0000-0000-0000-000000000002"; +// Super admin auth bypasses canAccessProject entirely (no prisma calls). +const adminAuth: SuperAdminAuthContext = { type: "super_admin", email: "root@chorus.local" }; +// Regular user auth — gating resolves through prisma.project/projectMember mocks. +const userAuth: AuthContext = { type: "user", companyUuid: COMPANY_UUID, actorUuid: "user-1" }; + /** A minimal valid proposal DB row for mocking findFirst/create returns */ function dbProposal(overrides: Record = {}) { return makeProposal({ @@ -159,6 +172,8 @@ beforeEach(() => { // Default: idea.findMany returns empty array (needed when validateProposal // checks E5 for idea-type proposals) mockPrisma.idea.findMany.mockResolvedValue([]); + // Default: reject/close resolve the proposal's projectUuid via findUnique. + mockPrisma.proposal.findUnique.mockResolvedValue({ projectUuid: PROJECT_UUID }); }); // ==================================================================== @@ -177,7 +192,7 @@ describe("createProposal", () => { inputType: "idea", inputUuids: ["idea-1"], createdByUuid: ACTOR_UUID, - }); + }, adminAuth); expect(mockPrisma.proposal.create).toHaveBeenCalledOnce(); expect(result.uuid).toBe(created.uuid); @@ -200,7 +215,7 @@ describe("createProposal", () => { createdByUuid: ACTOR_UUID, documentDrafts: [{ type: "prd", title: "PRD", content: "Content" }], taskDrafts: [{ title: "Task 1" }], - }); + }, adminAuth); const callData = mockPrisma.proposal.create.mock.calls[0][0].data; expect(callData.documentDrafts[0].uuid).toBeDefined(); @@ -217,7 +232,7 @@ describe("createProposal", () => { inputType: "idea", inputUuids: [], createdByUuid: ACTOR_UUID, - }); + }, adminAuth); const callData = mockPrisma.proposal.create.mock.calls[0][0].data; expect(callData.createdByType).toBe("agent"); @@ -238,7 +253,7 @@ describe("addDocumentDraft", () => { type: "tech_design", title: "Tech Design", content: "Design content", - }); + }, adminAuth); const updateCall = mockPrisma.proposal.update.mock.calls[0][0]; expect(updateCall.data.documentDrafts).toHaveLength(2); @@ -254,7 +269,7 @@ describe("addDocumentDraft", () => { type: "prd", title: "PRD", content: "Content", - }) + }, adminAuth) ).rejects.toThrow("Proposal not found or not in draft status"); }); @@ -267,7 +282,7 @@ describe("addDocumentDraft", () => { type: "prd", title: "PRD", content: "Content", - }); + }, adminAuth); const updateCall = mockPrisma.proposal.update.mock.calls[0][0]; expect(updateCall.data.documentDrafts).toHaveLength(1); @@ -288,7 +303,7 @@ describe("addTaskDraft", () => { title: "Task 2", description: "Second task", acceptanceCriteriaItems: [{ description: "Works", required: true }], - }); + }, adminAuth); const updateCall = mockPrisma.proposal.update.mock.calls[0][0]; expect(updateCall.data.taskDrafts).toHaveLength(2); @@ -303,7 +318,7 @@ describe("addTaskDraft", () => { addTaskDraft("proposal-uuid", COMPANY_UUID, { title: "Task", acceptanceCriteriaItems: [{ description: "Works" }], - }) + }, adminAuth) ).rejects.toThrow("Proposal not found or not in draft status"); }); @@ -315,7 +330,7 @@ describe("addTaskDraft", () => { await addTaskDraft("proposal-uuid", COMPANY_UUID, { title: "Task 1", acceptanceCriteriaItems: [{ description: "Works" }], - }); + }, adminAuth); const updateCall = mockPrisma.proposal.update.mock.calls[0][0]; expect(updateCall.data.taskDrafts).toHaveLength(1); @@ -326,7 +341,7 @@ describe("addTaskDraft", () => { mockPrisma.proposal.findFirst.mockResolvedValue(proposal); await expect( - addTaskDraft("proposal-uuid", COMPANY_UUID, { title: "No AC" }) + addTaskDraft("proposal-uuid", COMPANY_UUID, { title: "No AC" }, adminAuth) ).rejects.toThrow("acceptance criterion"); expect(mockPrisma.proposal.update).not.toHaveBeenCalled(); }); @@ -339,7 +354,7 @@ describe("addTaskDraft", () => { addTaskDraft("proposal-uuid", COMPANY_UUID, { title: "Blank AC", acceptanceCriteriaItems: [{ description: " " }, { description: "" }], - }) + }, adminAuth) ).rejects.toThrow("acceptance criterion"); expect(mockPrisma.proposal.update).not.toHaveBeenCalled(); }); @@ -355,7 +370,7 @@ describe("addTaskDraft", () => { { description: " kept " }, { description: " " }, ], - }); + }, adminAuth); const updateCall = mockPrisma.proposal.update.mock.calls[0][0]; expect(updateCall.data.taskDrafts[0].acceptanceCriteriaItems).toEqual([ @@ -378,7 +393,7 @@ describe("updateDocumentDraft", () => { await updateDocumentDraft("proposal-uuid", COMPANY_UUID, "dd-1", { title: "Updated PRD", content: "New content", - }); + }, adminAuth); const updateCall = mockPrisma.proposal.update.mock.calls[0][0]; expect(updateCall.data.documentDrafts[0].title).toBe("Updated PRD"); @@ -390,7 +405,7 @@ describe("updateDocumentDraft", () => { mockPrisma.proposal.findFirst.mockResolvedValue(proposal); await expect( - updateDocumentDraft("proposal-uuid", COMPANY_UUID, "nonexistent", { title: "X" }) + updateDocumentDraft("proposal-uuid", COMPANY_UUID, "nonexistent", { title: "X" }, adminAuth) ).rejects.toThrow("Document draft not found"); }); @@ -398,7 +413,7 @@ describe("updateDocumentDraft", () => { mockPrisma.proposal.findFirst.mockResolvedValue(null); await expect( - updateDocumentDraft("proposal-uuid", COMPANY_UUID, "dd-1", { title: "X" }) + updateDocumentDraft("proposal-uuid", COMPANY_UUID, "dd-1", { title: "X" }, adminAuth) ).rejects.toThrow("Proposal not found or not in draft status"); }); }); @@ -417,7 +432,7 @@ describe("updateTaskDraft", () => { await updateTaskDraft("proposal-uuid", COMPANY_UUID, "td-1", { title: "Updated Task", priority: "high", - }); + }, adminAuth); const updateCall = mockPrisma.proposal.update.mock.calls[0][0]; expect(updateCall.data.taskDrafts[0].title).toBe("Updated Task"); @@ -429,7 +444,7 @@ describe("updateTaskDraft", () => { mockPrisma.proposal.findFirst.mockResolvedValue(proposal); await expect( - updateTaskDraft("proposal-uuid", COMPANY_UUID, "nonexistent", { title: "X" }) + updateTaskDraft("proposal-uuid", COMPANY_UUID, "nonexistent", { title: "X" }, adminAuth) ).rejects.toThrow("Task draft not found"); }); @@ -437,7 +452,7 @@ describe("updateTaskDraft", () => { mockPrisma.proposal.findFirst.mockResolvedValue(null); await expect( - updateTaskDraft("proposal-uuid", COMPANY_UUID, "td-1", { title: "X" }) + updateTaskDraft("proposal-uuid", COMPANY_UUID, "td-1", { title: "X" }, adminAuth) ).rejects.toThrow("Proposal not found or not in draft status"); }); @@ -447,7 +462,7 @@ describe("updateTaskDraft", () => { mockPrisma.proposal.findFirst.mockResolvedValue(proposal); mockPrisma.proposal.update.mockResolvedValue(proposal); - await updateTaskDraft("proposal-uuid", COMPANY_UUID, "td-1", { title: "Renamed" }); + await updateTaskDraft("proposal-uuid", COMPANY_UUID, "td-1", { title: "Renamed" }, adminAuth); const updateCall = mockPrisma.proposal.update.mock.calls[0][0]; expect(updateCall.data.taskDrafts[0].title).toBe("Renamed"); @@ -465,7 +480,7 @@ describe("updateTaskDraft", () => { mockPrisma.proposal.update.mockResolvedValue(proposal); await expect( - updateTaskDraft("proposal-uuid", COMPANY_UUID, "td-1", { acceptanceCriteriaItems: [] }) + updateTaskDraft("proposal-uuid", COMPANY_UUID, "td-1", { acceptanceCriteriaItems: [] }, adminAuth) ).rejects.toThrow("acceptance criterion"); expect(mockPrisma.proposal.update).not.toHaveBeenCalled(); }); @@ -478,7 +493,7 @@ describe("updateTaskDraft", () => { await expect( updateTaskDraft("proposal-uuid", COMPANY_UUID, "td-1", { acceptanceCriteriaItems: [{ description: " " }], - }) + }, adminAuth) ).rejects.toThrow("acceptance criterion"); expect(mockPrisma.proposal.update).not.toHaveBeenCalled(); }); @@ -494,7 +509,7 @@ describe("updateTaskDraft", () => { { description: " new one ", required: false }, { description: " " }, ], - }); + }, adminAuth); const updateCall = mockPrisma.proposal.update.mock.calls[0][0]; expect(updateCall.data.taskDrafts[0].acceptanceCriteriaItems).toEqual([ @@ -517,7 +532,7 @@ describe("removeDocumentDraft", () => { mockPrisma.proposal.findFirst.mockResolvedValue(proposal); mockPrisma.proposal.update.mockResolvedValue(proposal); - await removeDocumentDraft("proposal-uuid", COMPANY_UUID, "dd-1"); + await removeDocumentDraft("proposal-uuid", COMPANY_UUID, "dd-1", adminAuth); const updateCall = mockPrisma.proposal.update.mock.calls[0][0]; expect(updateCall.data.documentDrafts).toHaveLength(1); @@ -529,7 +544,7 @@ describe("removeDocumentDraft", () => { mockPrisma.proposal.findFirst.mockResolvedValue(proposal); mockPrisma.proposal.update.mockResolvedValue(proposal); - await removeDocumentDraft("proposal-uuid", COMPANY_UUID, "dd-1"); + await removeDocumentDraft("proposal-uuid", COMPANY_UUID, "dd-1", adminAuth); const updateCall = mockPrisma.proposal.update.mock.calls[0][0]; expect(updateCall.data.documentDrafts).toBe("DbNull"); // Prisma.JsonNull @@ -539,7 +554,7 @@ describe("removeDocumentDraft", () => { mockPrisma.proposal.findFirst.mockResolvedValue(null); await expect( - removeDocumentDraft("proposal-uuid", COMPANY_UUID, "dd-1") + removeDocumentDraft("proposal-uuid", COMPANY_UUID, "dd-1", adminAuth) ).rejects.toThrow("Proposal not found or not in draft status"); }); }); @@ -558,7 +573,7 @@ describe("removeTaskDraft", () => { mockPrisma.proposal.findFirst.mockResolvedValue(proposal); mockPrisma.proposal.update.mockResolvedValue(proposal); - await removeTaskDraft("proposal-uuid", COMPANY_UUID, "td-1"); + await removeTaskDraft("proposal-uuid", COMPANY_UUID, "td-1", adminAuth); const updateCall = mockPrisma.proposal.update.mock.calls[0][0]; expect(updateCall.data.taskDrafts).toHaveLength(1); @@ -570,7 +585,7 @@ describe("removeTaskDraft", () => { mockPrisma.proposal.findFirst.mockResolvedValue(proposal); mockPrisma.proposal.update.mockResolvedValue(proposal); - await removeTaskDraft("proposal-uuid", COMPANY_UUID, "td-1"); + await removeTaskDraft("proposal-uuid", COMPANY_UUID, "td-1", adminAuth); const updateCall = mockPrisma.proposal.update.mock.calls[0][0]; expect(updateCall.data.taskDrafts).toBe("DbNull"); @@ -586,7 +601,7 @@ describe("removeTaskDraft", () => { mockPrisma.proposal.findFirst.mockResolvedValue(proposal); mockPrisma.proposal.update.mockResolvedValue(proposal); - await removeTaskDraft("proposal-uuid", COMPANY_UUID, "td-1"); + await removeTaskDraft("proposal-uuid", COMPANY_UUID, "td-1", adminAuth); const updateCall = mockPrisma.proposal.update.mock.calls[0][0]; const remaining = updateCall.data.taskDrafts; @@ -601,7 +616,7 @@ describe("removeTaskDraft", () => { mockPrisma.proposal.findFirst.mockResolvedValue(null); await expect( - removeTaskDraft("proposal-uuid", COMPANY_UUID, "td-1") + removeTaskDraft("proposal-uuid", COMPANY_UUID, "td-1", adminAuth) ).rejects.toThrow("Proposal not found or not in draft status"); }); }); @@ -615,7 +630,7 @@ describe("validateProposal", () => { mockPrisma.proposal.findFirst.mockResolvedValue(null); await expect( - validateProposal(COMPANY_UUID, "nonexistent") + validateProposal(COMPANY_UUID, "nonexistent", adminAuth) ).rejects.toThrow("Proposal not found"); }); @@ -628,7 +643,7 @@ describe("validateProposal", () => { }); mockPrisma.proposal.findFirst.mockResolvedValue(proposal); - const result = await validateProposal(COMPANY_UUID, proposal.uuid); + const result = await validateProposal(COMPANY_UUID, proposal.uuid, adminAuth); const e1 = result.issues.find((i) => i.id === "E1"); expect(e1).toBeDefined(); expect(e1!.level).toBe("error"); @@ -644,7 +659,7 @@ describe("validateProposal", () => { }); mockPrisma.proposal.findFirst.mockResolvedValue(proposal); - const result = await validateProposal(COMPANY_UUID, proposal.uuid); + const result = await validateProposal(COMPANY_UUID, proposal.uuid, adminAuth); const e1 = result.issues.find((i) => i.id === "E1"); expect(e1).toBeUndefined(); }); @@ -658,7 +673,7 @@ describe("validateProposal", () => { }); mockPrisma.proposal.findFirst.mockResolvedValue(proposal); - const result = await validateProposal(COMPANY_UUID, proposal.uuid); + const result = await validateProposal(COMPANY_UUID, proposal.uuid, adminAuth); const e2 = result.issues.find((i) => i.id === "E2"); expect(e2).toBeDefined(); expect(e2!.level).toBe("error"); @@ -673,7 +688,7 @@ describe("validateProposal", () => { }); mockPrisma.proposal.findFirst.mockResolvedValue(proposal); - const result = await validateProposal(COMPANY_UUID, proposal.uuid); + const result = await validateProposal(COMPANY_UUID, proposal.uuid, adminAuth); expect(result.issues.some((i) => i.id === "E2")).toBe(true); }); @@ -686,7 +701,7 @@ describe("validateProposal", () => { }); mockPrisma.proposal.findFirst.mockResolvedValue(proposal); - const result = await validateProposal(COMPANY_UUID, proposal.uuid); + const result = await validateProposal(COMPANY_UUID, proposal.uuid, adminAuth); const e3 = result.issues.find((i) => i.id === "E3"); expect(e3).toBeDefined(); expect(e3!.level).toBe("error"); @@ -701,7 +716,7 @@ describe("validateProposal", () => { }); mockPrisma.proposal.findFirst.mockResolvedValue(proposal); - const result = await validateProposal(COMPANY_UUID, proposal.uuid); + const result = await validateProposal(COMPANY_UUID, proposal.uuid, adminAuth); const e4 = result.issues.find((i) => i.id === "E4"); expect(e4).toBeDefined(); expect(e4!.level).toBe("error"); @@ -720,7 +735,7 @@ describe("validateProposal", () => { { uuid: "idea-1", title: "My Idea", elaborationStatus: "pending" }, ]); - const result = await validateProposal(COMPANY_UUID, proposal.uuid); + const result = await validateProposal(COMPANY_UUID, proposal.uuid, adminAuth); const e5 = result.issues.find((i) => i.id === "E5"); expect(e5).toBeDefined(); expect(e5!.level).toBe("error"); @@ -740,7 +755,7 @@ describe("validateProposal", () => { { uuid: "idea-1", title: "My Idea", elaborationStatus: "resolved" }, ]); - const result = await validateProposal(COMPANY_UUID, proposal.uuid); + const result = await validateProposal(COMPANY_UUID, proposal.uuid, adminAuth); const e5 = result.issues.find((i) => i.id === "E5"); expect(e5).toBeUndefined(); }); @@ -755,7 +770,7 @@ describe("validateProposal", () => { }); mockPrisma.proposal.findFirst.mockResolvedValue(proposal); - const result = await validateProposal(COMPANY_UUID, proposal.uuid); + const result = await validateProposal(COMPANY_UUID, proposal.uuid, adminAuth); expect(mockPrisma.idea.findMany).not.toHaveBeenCalled(); const e5 = result.issues.find((i) => i.id === "E5"); expect(e5).toBeUndefined(); @@ -777,7 +792,7 @@ describe("validateProposal", () => { }); mockPrisma.proposal.findFirst.mockResolvedValue(proposal); - const result = await validateProposal(COMPANY_UUID, proposal.uuid); + const result = await validateProposal(COMPANY_UUID, proposal.uuid, adminAuth); const eac = result.issues.find((i) => i.id === "E-AC"); expect(eac).toBeDefined(); expect(eac!.level).toBe("error"); @@ -798,7 +813,7 @@ describe("validateProposal", () => { }); mockPrisma.proposal.findFirst.mockResolvedValue(proposal); - const result = await validateProposal(COMPANY_UUID, proposal.uuid); + const result = await validateProposal(COMPANY_UUID, proposal.uuid, adminAuth); const eac = result.issues.find((i) => i.id === "E-AC"); expect(eac).toBeUndefined(); }); @@ -817,7 +832,7 @@ describe("validateProposal", () => { }); mockPrisma.proposal.findFirst.mockResolvedValue(proposal); - const result = await validateProposal(COMPANY_UUID, proposal.uuid); + const result = await validateProposal(COMPANY_UUID, proposal.uuid, adminAuth); const eac = result.issues.find((i) => i.id === "E-AC"); expect(eac).toBeDefined(); expect(eac!.level).toBe("error"); @@ -832,7 +847,7 @@ describe("validateProposal", () => { }); mockPrisma.proposal.findFirst.mockResolvedValue(proposal); - const result = await validateProposal(COMPANY_UUID, proposal.uuid); + const result = await validateProposal(COMPANY_UUID, proposal.uuid, adminAuth); const w1 = result.issues.find((i) => i.id === "W1"); expect(w1).toBeDefined(); expect(w1!.level).toBe("warning"); @@ -850,7 +865,7 @@ describe("validateProposal", () => { }); mockPrisma.proposal.findFirst.mockResolvedValue(proposal); - const result = await validateProposal(COMPANY_UUID, proposal.uuid); + const result = await validateProposal(COMPANY_UUID, proposal.uuid, adminAuth); const w1 = result.issues.find((i) => i.id === "W1"); expect(w1).toBeUndefined(); }); @@ -864,7 +879,7 @@ describe("validateProposal", () => { }); mockPrisma.proposal.findFirst.mockResolvedValue(proposal); - const result = await validateProposal(COMPANY_UUID, proposal.uuid); + const result = await validateProposal(COMPANY_UUID, proposal.uuid, adminAuth); const w2 = result.issues.find((i) => i.id === "W2"); expect(w2).toBeDefined(); expect(w2!.level).toBe("warning"); @@ -879,7 +894,7 @@ describe("validateProposal", () => { }); mockPrisma.proposal.findFirst.mockResolvedValue(proposal); - const result = await validateProposal(COMPANY_UUID, proposal.uuid); + const result = await validateProposal(COMPANY_UUID, proposal.uuid, adminAuth); const w2 = result.issues.find((i) => i.id === "W2"); expect(w2).toBeDefined(); }); @@ -896,7 +911,7 @@ describe("validateProposal", () => { }); mockPrisma.proposal.findFirst.mockResolvedValue(proposal); - const result = await validateProposal(COMPANY_UUID, proposal.uuid); + const result = await validateProposal(COMPANY_UUID, proposal.uuid, adminAuth); const w4 = result.issues.find((i) => i.id === "W4"); expect(w4).toBeDefined(); expect(w4!.level).toBe("warning"); @@ -914,7 +929,7 @@ describe("validateProposal", () => { }); mockPrisma.proposal.findFirst.mockResolvedValue(proposal); - const result = await validateProposal(COMPANY_UUID, proposal.uuid); + const result = await validateProposal(COMPANY_UUID, proposal.uuid, adminAuth); const w4 = result.issues.find((i) => i.id === "W4"); expect(w4).toBeUndefined(); }); @@ -928,7 +943,7 @@ describe("validateProposal", () => { }); mockPrisma.proposal.findFirst.mockResolvedValue(proposal); - const result = await validateProposal(COMPANY_UUID, proposal.uuid); + const result = await validateProposal(COMPANY_UUID, proposal.uuid, adminAuth); const w4 = result.issues.find((i) => i.id === "W4"); expect(w4).toBeUndefined(); }); @@ -942,7 +957,7 @@ describe("validateProposal", () => { }); mockPrisma.proposal.findFirst.mockResolvedValue(proposal); - const result = await validateProposal(COMPANY_UUID, proposal.uuid); + const result = await validateProposal(COMPANY_UUID, proposal.uuid, adminAuth); const w5 = result.issues.find((i) => i.id === "W5"); expect(w5).toBeDefined(); expect(w5!.level).toBe("warning"); @@ -957,7 +972,7 @@ describe("validateProposal", () => { }); mockPrisma.proposal.findFirst.mockResolvedValue(proposal); - const result = await validateProposal(COMPANY_UUID, proposal.uuid); + const result = await validateProposal(COMPANY_UUID, proposal.uuid, adminAuth); const w5 = result.issues.find((i) => i.id === "W5"); expect(w5).toBeDefined(); }); @@ -971,7 +986,7 @@ describe("validateProposal", () => { }); mockPrisma.proposal.findFirst.mockResolvedValue(proposal); - const result = await validateProposal(COMPANY_UUID, proposal.uuid); + const result = await validateProposal(COMPANY_UUID, proposal.uuid, adminAuth); const i1 = result.issues.find((i) => i.id === "I1"); expect(i1).toBeDefined(); expect(i1!.level).toBe("info"); @@ -986,7 +1001,7 @@ describe("validateProposal", () => { }); mockPrisma.proposal.findFirst.mockResolvedValue(proposal); - const result = await validateProposal(COMPANY_UUID, proposal.uuid); + const result = await validateProposal(COMPANY_UUID, proposal.uuid, adminAuth); const i2 = result.issues.find((i) => i.id === "I2"); expect(i2).toBeDefined(); expect(i2!.level).toBe("info"); @@ -1003,7 +1018,7 @@ describe("validateProposal", () => { }); mockPrisma.proposal.findFirst.mockResolvedValue(proposal); - const result = await validateProposal(COMPANY_UUID, proposal.uuid); + const result = await validateProposal(COMPANY_UUID, proposal.uuid, adminAuth); // Has W1, W2, W5, I1, I2 but no errors expect(result.valid).toBe(true); expect(result.issues.length).toBeGreaterThan(0); @@ -1019,7 +1034,7 @@ describe("validateProposal", () => { }); mockPrisma.proposal.findFirst.mockResolvedValue(proposal); - const result = await validateProposal(COMPANY_UUID, proposal.uuid); + const result = await validateProposal(COMPANY_UUID, proposal.uuid, adminAuth); expect(result.valid).toBe(false); expect(result.issues.some((i) => i.level === "error")).toBe(true); }); @@ -1036,7 +1051,7 @@ describe("validateProposal", () => { }); mockPrisma.proposal.findFirst.mockResolvedValue(proposal); - const result = await validateProposal(COMPANY_UUID, proposal.uuid); + const result = await validateProposal(COMPANY_UUID, proposal.uuid, adminAuth); const e2Issues = result.issues.filter((i) => i.id === "E2"); expect(e2Issues).toHaveLength(2); }); @@ -1051,7 +1066,7 @@ describe("submitProposal", () => { mockPrisma.proposal.findFirst.mockResolvedValue(null); await expect( - submitProposal("nonexistent", COMPANY_UUID) + submitProposal("nonexistent", COMPANY_UUID, adminAuth) ).rejects.toThrow("Proposal not found"); }); @@ -1060,7 +1075,7 @@ describe("submitProposal", () => { mockPrisma.proposal.findFirst.mockResolvedValue(proposal); await expect( - submitProposal(proposal.uuid, COMPANY_UUID) + submitProposal(proposal.uuid, COMPANY_UUID, adminAuth) ).rejects.toThrow("Only draft proposals can be submitted for review"); }); @@ -1075,7 +1090,7 @@ describe("submitProposal", () => { mockPrisma.proposal.findFirst.mockResolvedValue(proposal); await expect( - submitProposal(proposal.uuid, COMPANY_UUID) + submitProposal(proposal.uuid, COMPANY_UUID, adminAuth) ).rejects.toThrow("Proposal validation failed"); }); @@ -1096,7 +1111,7 @@ describe("submitProposal", () => { mockPrisma.proposal.update.mockResolvedValue(updatedProposal); mockPrisma.idea.updateMany.mockResolvedValue({ count: 1 }); - const result = await submitProposal(proposal.uuid, COMPANY_UUID); + const result = await submitProposal(proposal.uuid, COMPANY_UUID, adminAuth); expect(mockPrisma.proposal.update).toHaveBeenCalledWith( expect.objectContaining({ @@ -1123,7 +1138,7 @@ describe("submitProposal", () => { ]); mockPrisma.proposal.update.mockResolvedValue(dbProposal({ ...proposal, status: "pending" })); - await submitProposal(proposal.uuid, COMPANY_UUID); + await submitProposal(proposal.uuid, COMPANY_UUID, adminAuth); // Should NOT call idea.updateMany — idea status is no longer changed on proposal submit expect(mockPrisma.idea.updateMany).not.toHaveBeenCalled(); @@ -1139,7 +1154,7 @@ describe("approveProposal", () => { mockPrisma.proposal.findFirst.mockResolvedValue(null); await expect( - approveProposal("nonexistent", COMPANY_UUID, "reviewer-uuid") + approveProposal("nonexistent", COMPANY_UUID, "reviewer-uuid", undefined, adminAuth) ).rejects.toThrow("Proposal not found"); }); @@ -1164,7 +1179,7 @@ describe("approveProposal", () => { }; mockPrisma.$transaction.mockImplementation(async (cb: (tx: unknown) => Promise) => cb(txMock)); - const result = await approveProposal(proposal.uuid, COMPANY_UUID, "reviewer-uuid", "Looks good"); + const result = await approveProposal(proposal.uuid, COMPANY_UUID, "reviewer-uuid", "Looks good", adminAuth); expect(txMock.document.createManyAndReturn).toHaveBeenCalledOnce(); expect(result.status).toBe("approved"); @@ -1199,7 +1214,7 @@ describe("approveProposal", () => { }; mockPrisma.$transaction.mockImplementation(async (cb: (tx: unknown) => Promise) => cb(txMock)); - await approveProposal(proposal.uuid, COMPANY_UUID, "reviewer-uuid"); + await approveProposal(proposal.uuid, COMPANY_UUID, "reviewer-uuid", undefined, adminAuth); expect(txMock.task.createManyAndReturn).toHaveBeenCalledOnce(); expect(txMock.taskDependency.createMany).toHaveBeenCalledWith({ @@ -1235,7 +1250,7 @@ describe("approveProposal", () => { }; mockPrisma.$transaction.mockImplementation(async (cb: (tx: unknown) => Promise) => cb(txMock)); - await approveProposal(proposal.uuid, COMPANY_UUID, "reviewer-uuid"); + await approveProposal(proposal.uuid, COMPANY_UUID, "reviewer-uuid", undefined, adminAuth); expect(txMock.acceptanceCriterion.createMany).toHaveBeenCalledWith({ data: [ @@ -1273,7 +1288,7 @@ describe("approveProposal", () => { }); await expect( - approveProposal(proposal.uuid, COMPANY_UUID, "reviewer-uuid") + approveProposal(proposal.uuid, COMPANY_UUID, "reviewer-uuid", undefined, adminAuth) ).rejects.toThrow("no non-empty description"); }); @@ -1299,7 +1314,7 @@ describe("approveProposal", () => { return cb(tx); }); - await approveProposal(proposal.uuid, COMPANY_UUID, "reviewer-uuid"); + await approveProposal(proposal.uuid, COMPANY_UUID, "reviewer-uuid", undefined, adminAuth); // Ideas should NOT be auto-completed — derived status is computed from task progress expect(mockPrisma.idea.updateMany).not.toHaveBeenCalled(); @@ -1320,7 +1335,7 @@ describe("rejectProposal", () => { }); mockPrisma.proposal.update.mockResolvedValue(updated); - const result = await rejectProposal("proposal-uuid", "reviewer-uuid", "Needs work"); + const result = await rejectProposal("proposal-uuid", "reviewer-uuid", "Needs work", adminAuth); expect(mockPrisma.proposal.update).toHaveBeenCalledWith({ where: { uuid: "proposal-uuid" }, @@ -1352,7 +1367,7 @@ describe("closeProposal", () => { }); mockPrisma.proposal.update.mockResolvedValue(updated); - const result = await closeProposal("proposal-uuid", "admin-uuid", "No longer needed"); + const result = await closeProposal("proposal-uuid", "admin-uuid", "No longer needed", adminAuth); expect(mockPrisma.proposal.update).toHaveBeenCalledWith({ where: { uuid: "proposal-uuid" }, @@ -1397,7 +1412,7 @@ describe("revokeProposal", () => { }; mockPrisma.$transaction.mockImplementation(async (cb: (tx: unknown) => Promise) => cb(txMock)); - const result = await revokeProposal(proposal.uuid, COMPANY_UUID, ACTOR_UUID, "Revoking due to scope change"); + const result = await revokeProposal(proposal.uuid, COMPANY_UUID, ACTOR_UUID, "Revoking due to scope change", adminAuth); expect(result.proposalUuid).toBe(proposal.uuid); expect(result.closedTasks).toEqual([ @@ -1435,7 +1450,7 @@ describe("revokeProposal", () => { mockPrisma.proposal.findFirst.mockResolvedValue(null); await expect( - revokeProposal("nonexistent", COMPANY_UUID, ACTOR_UUID, "Revoke") + revokeProposal("nonexistent", COMPANY_UUID, ACTOR_UUID, "Revoke", adminAuth) ).rejects.toThrow("Proposal not found"); }); @@ -1444,14 +1459,14 @@ describe("revokeProposal", () => { mockPrisma.proposal.findFirst.mockResolvedValue(draftProposal); await expect( - revokeProposal(draftProposal.uuid, COMPANY_UUID, ACTOR_UUID, "Revoke") + revokeProposal(draftProposal.uuid, COMPANY_UUID, ACTOR_UUID, "Revoke", adminAuth) ).rejects.toThrow("Only approved proposals can be revoked"); const pendingProposal = dbProposal({ status: "pending" }); mockPrisma.proposal.findFirst.mockResolvedValue(pendingProposal); await expect( - revokeProposal(pendingProposal.uuid, COMPANY_UUID, ACTOR_UUID, "Revoke") + revokeProposal(pendingProposal.uuid, COMPANY_UUID, ACTOR_UUID, "Revoke", adminAuth) ).rejects.toThrow("Only approved proposals can be revoked"); }); @@ -1477,7 +1492,7 @@ describe("revokeProposal", () => { }; mockPrisma.$transaction.mockImplementation(async (cb: (tx: unknown) => Promise) => cb(txMock)); - const result = await revokeProposal(proposal.uuid, COMPANY_UUID, ACTOR_UUID, "Revoke note"); + const result = await revokeProposal(proposal.uuid, COMPANY_UUID, ACTOR_UUID, "Revoke note", adminAuth); // Verify return value shape expect(result.closedTasks).toHaveLength(1); @@ -1504,7 +1519,7 @@ describe("revokeProposal", () => { }; mockPrisma.$transaction.mockImplementation(async (cb: (tx: unknown) => Promise) => cb(txMock)); - const result = await revokeProposal(proposal.uuid, COMPANY_UUID, ACTOR_UUID, "Revoke empty"); + const result = await revokeProposal(proposal.uuid, COMPANY_UUID, ACTOR_UUID, "Revoke empty", adminAuth); expect(result.proposalUuid).toBe(proposal.uuid); expect(result.closedTasks).toEqual([]); @@ -1540,7 +1555,7 @@ describe("deleteProposal", () => { mockPrisma.proposal.findFirst.mockResolvedValue(proposal); mockPrisma.proposal.delete.mockResolvedValue(proposal); - await deleteProposal("proposal-uuid", COMPANY_UUID); + await deleteProposal("proposal-uuid", COMPANY_UUID, adminAuth); expect(mockPrisma.proposal.findFirst).toHaveBeenCalledWith({ where: { uuid: "proposal-uuid", companyUuid: COMPANY_UUID }, @@ -1559,7 +1574,7 @@ describe("deleteProposal", () => { mockPrisma.proposal.findFirst.mockResolvedValue(proposal); mockPrisma.proposal.delete.mockResolvedValue(proposal); - await deleteProposal("proposal-uuid", COMPANY_UUID); + await deleteProposal("proposal-uuid", COMPANY_UUID, adminAuth); expect(mockPrisma.proposal.delete).toHaveBeenCalledWith({ where: { uuid: "proposal-uuid" }, @@ -1570,7 +1585,7 @@ describe("deleteProposal", () => { it("should throw when proposal not found", async () => { mockPrisma.proposal.findFirst.mockResolvedValue(null); - await expect(deleteProposal("nonexistent", COMPANY_UUID)).rejects.toThrow("Proposal not found"); + await expect(deleteProposal("nonexistent", COMPANY_UUID, adminAuth)).rejects.toThrow("Proposal not found"); }); }); @@ -1591,6 +1606,7 @@ describe("listProposals", () => { projectUuid: PROJECT_UUID, skip: 0, take: 20, + auth: adminAuth, }); expect(result.proposals).toHaveLength(2); @@ -1616,6 +1632,7 @@ describe("listProposals", () => { skip: 0, take: 20, status: "pending", + auth: adminAuth, }); expect(mockPrisma.proposal.findMany).toHaveBeenCalledWith( @@ -1637,7 +1654,7 @@ describe("getProposal", () => { }); mockPrisma.proposal.findFirst.mockResolvedValue(proposal); - const result = await getProposal(COMPANY_UUID, "proposal-uuid"); + const result = await getProposal(COMPANY_UUID, "proposal-uuid", adminAuth); expect(result).not.toBeNull(); expect(result!.uuid).toBe(proposal.uuid); @@ -1651,7 +1668,7 @@ describe("getProposal", () => { it("should return null when proposal not found", async () => { mockPrisma.proposal.findFirst.mockResolvedValue(null); - const result = await getProposal(COMPANY_UUID, "nonexistent"); + const result = await getProposal(COMPANY_UUID, "nonexistent", adminAuth); expect(result).toBeNull(); }); }); @@ -1686,6 +1703,12 @@ describe("getProposalByUuid", () => { // ==================================================================== describe("updateProposalContent", () => { + beforeEach(() => { + // updateProposalContent resolves the proposal's project (findFirst) before + // updating, to gate by visibility. Provide a project for the access check. + mockPrisma.proposal.findFirst.mockResolvedValue(dbProposal({ projectUuid: PROJECT_UUID })); + }); + it("should update title and description", async () => { const updated = dbProposal({ title: "Updated Title", @@ -1697,7 +1720,7 @@ describe("updateProposalContent", () => { const result = await updateProposalContent("proposal-uuid", COMPANY_UUID, { title: "Updated Title", description: "Updated Description", - }); + }, adminAuth); expect(result.title).toBe("Updated Title"); expect(result.description).toBe("Updated Description"); @@ -1721,7 +1744,7 @@ describe("updateProposalContent", () => { const result = await updateProposalContent("proposal-uuid", COMPANY_UUID, { documentDrafts: newDrafts, - }); + }, adminAuth); expect(result.documentDrafts).toEqual(newDrafts); expect(mockPrisma.proposal.update).toHaveBeenCalledWith( @@ -1743,7 +1766,7 @@ describe("updateProposalContent", () => { const result = await updateProposalContent("proposal-uuid", COMPANY_UUID, { taskDrafts: newTasks, - }); + }, adminAuth); expect(result.taskDrafts).toEqual(newTasks); }); @@ -1759,7 +1782,7 @@ describe("updateProposalContent", () => { const result = await updateProposalContent("proposal-uuid", COMPANY_UUID, { documentDrafts: null, taskDrafts: null, - }); + }, adminAuth); expect(result.documentDrafts).toBeNull(); expect(result.taskDrafts).toBeNull(); @@ -1782,7 +1805,7 @@ describe("updateProposalContent", () => { await updateProposalContent("proposal-uuid", COMPANY_UUID, { title: "New Title", - }); + }, adminAuth); expect(mockPrisma.proposal.update).toHaveBeenCalledWith({ where: { uuid: "proposal-uuid", companyUuid: COMPANY_UUID }, @@ -1949,7 +1972,7 @@ describe("approveProposal - edge cases", () => { }; mockPrisma.$transaction.mockImplementation(async (callback) => callback(txMock)); - await approveProposal("proposal-uuid", COMPANY_UUID, "reviewer-uuid", "Approved"); + await approveProposal("proposal-uuid", COMPANY_UUID, "reviewer-uuid", "Approved", adminAuth); // No dependencies, so taskDependency.createMany should not be called expect(txMock.taskDependency.createMany).not.toHaveBeenCalled(); @@ -1976,7 +1999,7 @@ describe("approveProposal - edge cases", () => { }; mockPrisma.$transaction.mockImplementation(async (callback) => callback(txMock)); - await approveProposal("proposal-uuid", COMPANY_UUID, "reviewer-uuid", "Approved"); + await approveProposal("proposal-uuid", COMPANY_UUID, "reviewer-uuid", "Approved", adminAuth); // No AC items, so acceptanceCriterion.createMany should not be called expect(txMock.acceptanceCriterion.createMany).not.toHaveBeenCalled(); @@ -2002,7 +2025,7 @@ describe("getProjectProposals", () => { { proposalUuid: "p2", _count: 1 }, ]); - const result = await getProjectProposals(COMPANY_UUID, PROJECT_UUID); + const result = await getProjectProposals(COMPANY_UUID, PROJECT_UUID, adminAuth); expect(result).toHaveLength(2); expect(result[0]).toEqual({ uuid: "p1", title: "Proposal 1", sequenceNumber: 1, taskCount: 3 }); @@ -2022,7 +2045,7 @@ describe("getProjectProposals", () => { ]); mockPrisma.task.groupBy.mockResolvedValue([]); - const result = await getProjectProposals(COMPANY_UUID, PROJECT_UUID); + const result = await getProjectProposals(COMPANY_UUID, PROJECT_UUID, adminAuth); expect(result).toHaveLength(1); expect(result[0].taskCount).toBe(0); @@ -2032,7 +2055,7 @@ describe("getProjectProposals", () => { mockPrisma.proposal.findMany.mockResolvedValue([]); mockPrisma.task.groupBy.mockResolvedValue([]); - const result = await getProjectProposals(COMPANY_UUID, PROJECT_UUID); + const result = await getProjectProposals(COMPANY_UUID, PROJECT_UUID, adminAuth); expect(result).toHaveLength(0); }); @@ -2079,7 +2102,7 @@ describe("Idea reuse - submitProposal with proposal_created Idea", () => { mockPrisma.proposal.findFirst.mockResolvedValue(proposal); mockPrisma.proposal.update.mockResolvedValue({ ...proposal, status: "pending" }); - const result = await submitProposal("proposal-reuse", COMPANY_UUID); + const result = await submitProposal("proposal-reuse", COMPANY_UUID, adminAuth); expect(result.status).toBe("pending"); // Idea status is no longer changed on proposal submit — derived status handles lifecycle @@ -2128,7 +2151,7 @@ describe("Idea reuse - approveProposal with completed Idea", () => { return callback(txMock); }); - await approveProposal("proposal-reuse-2", COMPANY_UUID, "reviewer-uuid", "Approved"); + await approveProposal("proposal-reuse-2", COMPANY_UUID, "reviewer-uuid", "Approved", adminAuth); // Ideas should NOT be auto-completed — derived status computed from task progress expect(mockPrisma.idea.updateMany).not.toHaveBeenCalled(); @@ -2233,19 +2256,19 @@ describe("getProposalSection", () => { it("returns null when the proposal does not exist", async () => { mockPrisma.proposal.findFirst.mockResolvedValue(null); - const result = await getProposalSection(COMPANY_UUID, "missing", "basic"); + const result = await getProposalSection(COMPANY_UUID, "missing", "basic", adminAuth); expect(result).toBeNull(); }); it("issues a single DB read (reuses getProposal, no second query)", async () => { mockPrisma.proposal.findFirst.mockResolvedValue(richProposalRow()); - await getProposalSection(COMPANY_UUID, "prop-section", "basic"); + await getProposalSection(COMPANY_UUID, "prop-section", "basic", adminAuth); expect(mockPrisma.proposal.findFirst).toHaveBeenCalledOnce(); }); it("section='basic' returns metadata + lightweight indexes, no heavy bodies", async () => { mockPrisma.proposal.findFirst.mockResolvedValue(richProposalRow()); - const result = await getProposalSection(COMPANY_UUID, "prop-section", "basic"); + const result = await getProposalSection(COMPANY_UUID, "prop-section", "basic", adminAuth); expect(result).not.toBeNull(); if (result?.section !== "basic") throw new Error("expected basic section"); @@ -2271,13 +2294,13 @@ describe("getProposalSection", () => { it("defaults to the basic view when called with 'basic' (the omitted-param default)", async () => { mockPrisma.proposal.findFirst.mockResolvedValue(richProposalRow()); - const result = await getProposalSection(COMPANY_UUID, "prop-section", "basic"); + const result = await getProposalSection(COMPANY_UUID, "prop-section", "basic", adminAuth); expect(result?.section).toBe("basic"); }); it("section='documents' returns full document drafts and omits full task drafts", async () => { mockPrisma.proposal.findFirst.mockResolvedValue(richProposalRow()); - const result = await getProposalSection(COMPANY_UUID, "prop-section", "documents"); + const result = await getProposalSection(COMPANY_UUID, "prop-section", "documents", adminAuth); if (result?.section !== "documents") throw new Error("expected documents section"); expect(result.documentDrafts).toHaveLength(2); expect(result.documentDrafts?.[0].content).toBe("P".repeat(300)); @@ -2286,7 +2309,7 @@ describe("getProposalSection", () => { it("section='tasks' returns full task drafts and omits full document drafts", async () => { mockPrisma.proposal.findFirst.mockResolvedValue(richProposalRow()); - const result = await getProposalSection(COMPANY_UUID, "prop-section", "tasks"); + const result = await getProposalSection(COMPANY_UUID, "prop-section", "tasks", adminAuth); if (result?.section !== "tasks") throw new Error("expected tasks section"); expect(result.taskDrafts).toHaveLength(2); expect(result.taskDrafts?.[0].description).toContain("Implement service"); @@ -2295,7 +2318,7 @@ describe("getProposalSection", () => { it("section='full' returns the complete payload with both draft arrays", async () => { mockPrisma.proposal.findFirst.mockResolvedValue(richProposalRow()); - const result = await getProposalSection(COMPANY_UUID, "prop-section", "full"); + const result = await getProposalSection(COMPANY_UUID, "prop-section", "full", adminAuth); if (result?.section !== "full") throw new Error("expected full section"); expect(result.documentDrafts).toHaveLength(2); expect(result.taskDrafts).toHaveLength(2); @@ -2304,9 +2327,9 @@ describe("getProposalSection", () => { it("the basic view serializes smaller than the full view for the same proposal", async () => { mockPrisma.proposal.findFirst.mockResolvedValue(richProposalRow()); - const basic = await getProposalSection(COMPANY_UUID, "prop-section", "basic"); + const basic = await getProposalSection(COMPANY_UUID, "prop-section", "basic", adminAuth); mockPrisma.proposal.findFirst.mockResolvedValue(richProposalRow()); - const full = await getProposalSection(COMPANY_UUID, "prop-section", "full"); + const full = await getProposalSection(COMPANY_UUID, "prop-section", "full", adminAuth); expect(JSON.stringify(basic).length).toBeLessThan(JSON.stringify(full).length); }); @@ -2314,7 +2337,7 @@ describe("getProposalSection", () => { mockPrisma.proposal.findFirst.mockResolvedValue( dbProposal({ uuid: "prop-empty", documentDrafts: null, taskDrafts: null }) ); - const result = await getProposalSection(COMPANY_UUID, "prop-empty", "basic"); + const result = await getProposalSection(COMPANY_UUID, "prop-empty", "basic", adminAuth); if (result?.section !== "basic") throw new Error("expected basic section"); expect(result.documentDraftCount).toBe(0); expect(result.taskDraftCount).toBe(0); @@ -2322,3 +2345,54 @@ describe("getProposalSection", () => { expect(result.taskDraftIndex).toEqual([]); }); }); + +// ==================================================================== +// Project-visibility access gating +// ==================================================================== + +describe("access gating", () => { + /** Make canAccessProject(userAuth, ...) return false: private project the user + * neither owns nor is a member of. */ + function denyAccess() { + mockPrisma.project.findFirst.mockResolvedValue({ + visibility: "private", + ownerType: "user", + ownerUuid: "other-owner", + }); + mockPrisma.projectMember.findUnique.mockResolvedValue(null); + } + + it("listProposals returns empty for a non-member (no proposal query)", async () => { + denyAccess(); + + const result = await listProposals({ + companyUuid: COMPANY_UUID, + projectUuid: PROJECT_UUID, + skip: 0, + take: 20, + auth: userAuth, + }); + + expect(result).toEqual({ proposals: [], total: 0 }); + expect(mockPrisma.proposal.findMany).not.toHaveBeenCalled(); + }); + + it("getProposal returns null for a non-member", async () => { + mockPrisma.proposal.findFirst.mockResolvedValue(dbProposal({ projectUuid: PROJECT_UUID })); + denyAccess(); + + const result = await getProposal(COMPANY_UUID, "proposal-uuid", userAuth); + + expect(result).toBeNull(); + }); + + it("deleteProposal rejects a non-member (no delete)", async () => { + mockPrisma.proposal.findFirst.mockResolvedValue(dbProposal({ projectUuid: PROJECT_UUID })); + denyAccess(); + + await expect( + deleteProposal("proposal-uuid", COMPANY_UUID, userAuth) + ).rejects.toThrow("Proposal not found"); + expect(mockPrisma.proposal.delete).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/__tests__/search.service.test.ts b/src/services/__tests__/search.service.test.ts index f42bef85..ec4b8ac7 100644 --- a/src/services/__tests__/search.service.test.ts +++ b/src/services/__tests__/search.service.test.ts @@ -23,10 +23,16 @@ const mockPrisma = vi.hoisted(() => ({ findMany: vi.fn(), count: vi.fn(), }, + projectMember: { + findMany: vi.fn(), + }, projectGroup: { findMany: vi.fn(), count: vi.fn(), }, + projectGroupMember: { + findMany: vi.fn(), + }, })); vi.mock("@/lib/prisma", () => ({ prisma: mockPrisma })); @@ -35,11 +41,21 @@ vi.mock("@/lib/prisma", () => ({ prisma: mockPrisma })); import { search } from "@/services/search.service"; +// ===== Auth fixtures ===== +// Super-admin: getAccessibleProjectUuids returns the ALL sentinel without +// touching prisma, leaving the scope-resolved project filter unchanged so all +// existing query-shape assertions stay valid. +const superAdminAuth = { type: "super_admin" as const, email: "admin@chorus.local" }; + // ===== Test Suite ===== describe("search.service", () => { beforeEach(() => { vi.clearAllMocks(); + // getAccessibleProjectUuids (non-super-admin path) also consults group + // ownership/membership; default these to empty so existing tests are unaffected. + mockPrisma.projectGroup.findMany.mockResolvedValue([]); + mockPrisma.projectGroupMember.findMany.mockResolvedValue([]); }); describe("global search", () => { @@ -126,6 +142,7 @@ describe("search.service", () => { mockPrisma.projectGroup.count.mockResolvedValue(1); const result = await search({ + auth: superAdminAuth, query: "test", companyUuid, scope: "global", @@ -171,6 +188,7 @@ describe("search.service", () => { mockPrisma.idea.count.mockResolvedValue(1); const result = await search({ + auth: superAdminAuth, query: "test", companyUuid, scope: "global", @@ -230,6 +248,7 @@ describe("search.service", () => { mockPrisma.projectGroup.count.mockResolvedValue(0); const result = await search({ + auth: superAdminAuth, query: "test", companyUuid, scope: "project", @@ -294,6 +313,7 @@ describe("search.service", () => { mockPrisma.projectGroup.count.mockResolvedValue(0); const result = await search({ + auth: superAdminAuth, query: "test", companyUuid, scope: "group", @@ -377,6 +397,7 @@ describe("search.service", () => { mockPrisma.projectGroup.count.mockResolvedValue(0); const result = await search({ + auth: superAdminAuth, query: "test", companyUuid, scope: "global", @@ -417,6 +438,7 @@ describe("search.service", () => { mockPrisma.projectGroup.count.mockResolvedValue(0); const result = await search({ + auth: superAdminAuth, query, companyUuid: "company-1", scope: "global", @@ -484,6 +506,7 @@ describe("search.service", () => { mockPrisma.projectGroup.count.mockResolvedValue(0); const result = await search({ + auth: superAdminAuth, query: "test", companyUuid: "company-1", scope: "global", @@ -529,6 +552,7 @@ describe("search.service", () => { mockPrisma.projectGroup.count.mockResolvedValue(0); const result = await search({ + auth: superAdminAuth, query: "test", companyUuid, scope: "global", @@ -563,6 +587,7 @@ describe("search.service", () => { mockPrisma.projectGroup.count.mockResolvedValue(0); await search({ + auth: superAdminAuth, query: "test", companyUuid, scope: "global", @@ -641,6 +666,7 @@ describe("search.service", () => { mockPrisma.projectGroup.count.mockResolvedValue(0); const result = await search({ + auth: superAdminAuth, query: "test", companyUuid, scope: "global", @@ -689,6 +715,7 @@ describe("search.service", () => { mockPrisma.projectGroup.count.mockResolvedValue(1); const result = await search({ + auth: superAdminAuth, query: "test", companyUuid, scope: "global", @@ -703,12 +730,91 @@ describe("search.service", () => { }); }); + describe("project visibility gating", () => { + it("restricts a regular user's global search to their accessible projects", async () => { + const companyUuid = "company-1"; + const userAuth = { + type: "user" as const, + companyUuid, + actorUuid: "user-1", + }; + + // getAccessibleProjectUuids: one accessible (shared/owned) project, no + // extra memberships. + mockPrisma.project.findMany.mockResolvedValue([{ uuid: "project-accessible" }]); + mockPrisma.projectMember.findMany.mockResolvedValue([]); + + mockPrisma.task.findMany.mockResolvedValue([]); + mockPrisma.task.count.mockResolvedValue(0); + mockPrisma.idea.findMany.mockResolvedValue([]); + mockPrisma.idea.count.mockResolvedValue(0); + mockPrisma.proposal.findMany.mockResolvedValue([]); + mockPrisma.proposal.count.mockResolvedValue(0); + mockPrisma.document.findMany.mockResolvedValue([]); + mockPrisma.document.count.mockResolvedValue(0); + mockPrisma.project.count.mockResolvedValue(0); + mockPrisma.projectGroup.findMany.mockResolvedValue([]); + mockPrisma.projectGroup.count.mockResolvedValue(0); + + await search({ + auth: userAuth, + query: "test", + companyUuid, + scope: "global", + entityTypes: ["task"], + }); + + // The task search is constrained to the accessible project set even + // though the global scope itself imposes no project filter. + expect(mockPrisma.task.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + projectUuid: { in: ["project-accessible"] }, + }), + }) + ); + }); + + it("returns no entity hits when the user has no accessible projects", async () => { + const companyUuid = "company-1"; + const userAuth = { + type: "user" as const, + companyUuid, + actorUuid: "user-1", + }; + + // No shared/owned projects and no memberships => empty accessible set. + mockPrisma.project.findMany.mockResolvedValue([]); + mockPrisma.projectMember.findMany.mockResolvedValue([]); + + mockPrisma.task.findMany.mockResolvedValue([]); + mockPrisma.task.count.mockResolvedValue(0); + + const result = await search({ + auth: userAuth, + query: "test", + companyUuid, + scope: "global", + entityTypes: ["task"], + }); + + expect(result.results).toHaveLength(0); + // Task search runs with an empty accessible set (in: []), matching nothing. + expect(mockPrisma.task.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ projectUuid: { in: [] } }), + }) + ); + }); + }); + describe("error handling", () => { it("should throw error if scopeUuid missing for project scope", async () => { const companyUuid = "company-1"; await expect( search({ + auth: superAdminAuth, query: "test", companyUuid, scope: "project", @@ -722,6 +828,7 @@ describe("search.service", () => { await expect( search({ + auth: superAdminAuth, query: "test", companyUuid, scope: "group", diff --git a/src/services/__tests__/session.service.test.ts b/src/services/__tests__/session.service.test.ts index 162cccf2..434705a8 100644 --- a/src/services/__tests__/session.service.test.ts +++ b/src/services/__tests__/session.service.test.ts @@ -255,7 +255,8 @@ describe("sessionCheckinToTask", () => { taskUuid, assigneeType: "agent", assigneeUuid: agentUuid, - }) + }), + expect.anything() ); }); diff --git a/src/services/__tests__/task.service.pure.test.ts b/src/services/__tests__/task.service.pure.test.ts index 64044ef4..1f1175fe 100644 --- a/src/services/__tests__/task.service.pure.test.ts +++ b/src/services/__tests__/task.service.pure.test.ts @@ -52,6 +52,8 @@ import { addTaskDependency, TASK_STATUS_TRANSITIONS, } from "@/services/task.service"; +import type { SuperAdminAuthContext } from "@/types/auth"; +const adminAuth: SuperAdminAuthContext = { type: "super_admin", email: "root@chorus.local" }; // ===== isValidTaskStatusTransition ===== @@ -300,7 +302,7 @@ describe("wouldCreateCycle (via addTaskDependency)", () => { // addTaskDependency(companyUuid, taskUuid=A, dependsOnUuid=C) // wouldCreateCycle checks: from C, can we reach A via existing edges? // C has no outgoing edges, so no cycle - const result = await addTaskDependency(companyUuid, A, C); + const result = await addTaskDependency(companyUuid, A, C, adminAuth); expect(result.taskUuid).toBe(A); expect(result.dependsOnUuid).toBe(C); }); @@ -320,7 +322,7 @@ describe("wouldCreateCycle (via addTaskDependency)", () => { // addTaskDependency(companyUuid, taskUuid=B, dependsOnUuid=A) // wouldCreateCycle checks: from A, can we reach B? A -> B via existing edge, yes! - await expect(addTaskDependency(companyUuid, B, A)).rejects.toThrow( + await expect(addTaskDependency(companyUuid, B, A, adminAuth)).rejects.toThrow( "Adding this dependency would create a cycle" ); }); @@ -347,7 +349,7 @@ describe("wouldCreateCycle (via addTaskDependency)", () => { // addTaskDependency(companyUuid, taskUuid=D, dependsOnUuid=A) // wouldCreateCycle(startUuid=A, targetUuid=D): from A, follow edges: // A -> B -> D (found!), cycle detected - await expect(addTaskDependency(companyUuid, D, A)).rejects.toThrow( + await expect(addTaskDependency(companyUuid, D, A, adminAuth)).rejects.toThrow( "Adding this dependency would create a cycle" ); }); @@ -356,7 +358,7 @@ describe("wouldCreateCycle (via addTaskDependency)", () => { const A = "aaaa0000-0000-0000-0000-000000000001"; // addTaskDependency checks self-dependency before prisma calls - await expect(addTaskDependency(companyUuid, A, A)).rejects.toThrow( + await expect(addTaskDependency(companyUuid, A, A, adminAuth)).rejects.toThrow( "A task cannot depend on itself" ); }); @@ -388,7 +390,7 @@ describe("wouldCreateCycle (via addTaskDependency)", () => { // wouldCreateCycle(startUuid=D, targetUuid=A): from D, can we reach A? // D has no outgoing edges, so no cycle - const result = await addTaskDependency(companyUuid, A, D); + const result = await addTaskDependency(companyUuid, A, D, adminAuth); expect(result.taskUuid).toBe(A); }); @@ -408,9 +410,9 @@ describe("wouldCreateCycle (via addTaskDependency)", () => { { taskUuid: C, dependsOnUuid: D }, ]); - // addTaskDependency(companyUuid, D, A): wouldCreateCycle(A, D) + // addTaskDependency(companyUuid, D, A, adminAuth): wouldCreateCycle(A, D) // A -> B -> C -> D (found!), cycle - await expect(addTaskDependency(companyUuid, D, A)).rejects.toThrow( + await expect(addTaskDependency(companyUuid, D, A, adminAuth)).rejects.toThrow( "Adding this dependency would create a cycle" ); }); diff --git a/src/services/__tests__/task.service.test.ts b/src/services/__tests__/task.service.test.ts index eb7bae92..c52d3142 100644 --- a/src/services/__tests__/task.service.test.ts +++ b/src/services/__tests__/task.service.test.ts @@ -50,6 +50,12 @@ const mockPrisma = vi.hoisted(() => { sessionTaskCheckin: { findMany: vi.fn(), }, + project: { + findFirst: vi.fn(), + }, + projectMember: { + findUnique: vi.fn(), + }, $transaction: vi.fn(async (fn: (tx: unknown) => Promise) => fn(txProxy)), }; }); @@ -100,8 +106,14 @@ import { checkAcceptanceCriteriaGate, createAcceptanceCriteria, replaceAcceptanceCriteria, + resetAcceptanceCriterion, + getAcceptanceStatus, + addTaskDependency, + removeTaskDependency, + getTaskDependencies, } from "@/services/task.service"; import { AlreadyClaimedError, NotClaimedError } from "@/lib/errors"; +import type { AuthContext, SuperAdminAuthContext } from "@/types/auth"; // ===== Helpers ===== @@ -109,6 +121,19 @@ const COMPANY_UUID = authContexts.user.companyUuid; const PROJECT_UUID = "00000000-0000-0000-0000-000000000010"; const TASK_UUID = "00000000-0000-0000-0000-000000000099"; +const adminAuth: SuperAdminAuthContext = { type: "super_admin", email: "root@chorus.local" }; +const userAuth: AuthContext = { type: "user", companyUuid: COMPANY_UUID, actorUuid: "user-1" }; + +/** Configure prisma mocks so canAccessProject(userAuth, ...) returns false. */ +function denyAccess() { + mockPrisma.project.findFirst.mockResolvedValue({ + visibility: "private", + ownerType: "user", + ownerUuid: "other-owner", + }); + mockPrisma.projectMember.findUnique.mockResolvedValue(null); +} + function rawTask(overrides: Record = {}) { return makeTask({ uuid: TASK_UUID, @@ -133,6 +158,9 @@ function rawTaskWithRelations(overrides: Record = {}) { beforeEach(() => { vi.clearAllMocks(); resetFixtureCounter(); + // Default project-visibility gate lookups (overridable per test). + mockPrisma.task.findUnique.mockResolvedValue({ projectUuid: PROJECT_UUID, status: "assigned" }); + mockPrisma.task.findFirst.mockResolvedValue({ projectUuid: PROJECT_UUID }); }); // ---------- listTasks ---------- @@ -157,6 +185,7 @@ describe("listTasks", () => { projectUuid: PROJECT_UUID, skip: 0, take: 10, + auth: adminAuth, }); expect(result.total).toBe(5); @@ -175,6 +204,7 @@ describe("listTasks", () => { skip: 0, take: 10, status: "in_progress", + auth: adminAuth, }); const whereArg = mockPrisma.task.findMany.mock.calls[0][0].where; @@ -191,6 +221,7 @@ describe("listTasks", () => { skip: 0, take: 10, priority: "high", + auth: adminAuth, }); const whereArg = mockPrisma.task.findMany.mock.calls[0][0].where; @@ -206,6 +237,7 @@ describe("listTasks", () => { projectUuid: PROJECT_UUID, skip: 0, take: 10, + auth: adminAuth, }); const whereArg = mockPrisma.task.findMany.mock.calls[0][0].where; @@ -226,6 +258,7 @@ describe("listTasks", () => { projectUuid: PROJECT_UUID, skip: 0, take: 10, + auth: adminAuth, }); expect(mockCommentService.batchCommentCounts).toHaveBeenCalledWith( @@ -246,6 +279,7 @@ describe("listTasks", () => { skip: 0, take: 10, proposalUuids: ["proposal-1", "proposal-2"], + auth: adminAuth, }); const whereArg = mockPrisma.task.findMany.mock.calls[0][0].where; @@ -261,6 +295,7 @@ describe("listTasks", () => { projectUuid: PROJECT_UUID, skip: 0, take: 10, + auth: adminAuth, }); const whereArg = mockPrisma.task.findMany.mock.calls[0][0].where; @@ -277,6 +312,7 @@ describe("listTasks", () => { skip: 0, take: 10, proposalUuids: [], + auth: adminAuth, }); const whereArg = mockPrisma.task.findMany.mock.calls[0][0].where; @@ -292,7 +328,7 @@ describe("getTask", () => { mockPrisma.task.findFirst.mockResolvedValue(task); mockPrisma.comment.count.mockResolvedValue(2); - const result = await getTask(COMPANY_UUID, TASK_UUID); + const result = await getTask(COMPANY_UUID, TASK_UUID, adminAuth); expect(result).not.toBeNull(); expect(result!.uuid).toBe(TASK_UUID); @@ -305,14 +341,14 @@ describe("getTask", () => { it("returns null when task not found", async () => { mockPrisma.task.findFirst.mockResolvedValue(null); - const result = await getTask(COMPANY_UUID, "nonexistent"); + const result = await getTask(COMPANY_UUID, "nonexistent", adminAuth); expect(result).toBeNull(); }); it("scopes query by companyUuid", async () => { mockPrisma.task.findFirst.mockResolvedValue(null); - await getTask(COMPANY_UUID, TASK_UUID); + await getTask(COMPANY_UUID, TASK_UUID, adminAuth); expect(mockPrisma.task.findFirst).toHaveBeenCalledWith( expect.objectContaining({ @@ -347,7 +383,7 @@ describe("getTask", () => { mockPrisma.task.findFirst.mockResolvedValue(taskWithDeps); mockPrisma.comment.count.mockResolvedValue(0); - const result = await getTask(COMPANY_UUID, TASK_UUID); + const result = await getTask(COMPANY_UUID, TASK_UUID, adminAuth); expect(result!.dependsOn).toEqual([ { uuid: "dep1", title: "Dep Task", status: "done" }, @@ -374,7 +410,7 @@ describe("getTask", () => { mockPrisma.task.findFirst.mockResolvedValue(task); mockPrisma.comment.count.mockResolvedValue(0); - const result = await getTask(COMPANY_UUID, TASK_UUID); + const result = await getTask(COMPANY_UUID, TASK_UUID, adminAuth); expect(result!.acceptanceCriteriaItems).toHaveLength(1); expect(result!.acceptanceCriteriaItems[0].status).toBe("passed"); @@ -395,7 +431,7 @@ describe("createTask", () => { projectUuid: PROJECT_UUID, title: "New Task", createdByUuid: authContexts.user.actorUuid, - }); + }, adminAuth); expect(result.uuid).toBe(TASK_UUID); expect(result.status).toBe("open"); @@ -417,7 +453,7 @@ describe("createTask", () => { title: "High Priority", priority: "high", createdByUuid: authContexts.user.actorUuid, - }); + }, adminAuth); const createData = mockPrisma.task.create.mock.calls[0][0].data; expect(createData.priority).toBe("high"); @@ -431,7 +467,7 @@ describe("createTask", () => { projectUuid: PROJECT_UUID, title: "Task", createdByUuid: authContexts.user.actorUuid, - }); + }, adminAuth); expect(mockEventBus.emitChange).toHaveBeenCalledWith( expect.objectContaining({ @@ -455,7 +491,7 @@ describe("createTask", () => { acceptanceCriteria: "- [ ] criterion", proposalUuid: "prop-uuid", createdByUuid: authContexts.user.actorUuid, - }); + }, adminAuth); const createData = mockPrisma.task.create.mock.calls[0][0].data; expect(createData.description).toBe("Some desc"); @@ -480,7 +516,7 @@ describe("claimTask", () => { companyUuid: COMPANY_UUID, assigneeType: "agent", assigneeUuid: "a1", - }); + }, adminAuth); expect(result.status).toBe("assigned"); expect(mockPrisma.task.update).toHaveBeenCalledWith( @@ -508,7 +544,7 @@ describe("claimTask", () => { assigneeType: "agent", assigneeUuid: "a2", assignedByUuid: "user-123", - }); + }, adminAuth); expect(result.status).toBe("assigned"); expect(mockPrisma.task.update).toHaveBeenCalledWith( @@ -531,7 +567,7 @@ describe("claimTask", () => { companyUuid: COMPANY_UUID, assigneeType: "agent", assigneeUuid: "a1", - }), + }, adminAuth), ).rejects.toThrow(AlreadyClaimedError); }); @@ -545,7 +581,7 @@ describe("claimTask", () => { companyUuid: COMPANY_UUID, assigneeType: "agent", assigneeUuid: "a1", - }), + }, adminAuth), ).rejects.toThrow("DB connection lost"); }); @@ -561,7 +597,7 @@ describe("claimTask", () => { companyUuid: COMPANY_UUID, assigneeType: "agent", assigneeUuid: "a1", - }); + }, adminAuth); expect(mockEventBus.emitChange).toHaveBeenCalledWith( expect.objectContaining({ @@ -584,7 +620,7 @@ describe("claimTask", () => { assigneeType: "agent", assigneeUuid: "a1", assignedByUuid: "user-123", - }); + }, adminAuth); const updateData = mockPrisma.task.update.mock.calls[0][0].data; expect(updateData.assignedByUuid).toBe("user-123"); @@ -601,7 +637,7 @@ describe("releaseTask", () => { }; mockPrisma.task.update.mockResolvedValue(released); - const result = await releaseTask(TASK_UUID); + const result = await releaseTask(TASK_UUID, adminAuth); expect(result.status).toBe("open"); expect(mockPrisma.task.update).toHaveBeenCalledWith( @@ -621,14 +657,14 @@ describe("releaseTask", () => { it("throws NotClaimedError when task is not assigned (Prisma P2025)", async () => { mockPrisma.task.update.mockRejectedValue({ code: "P2025" }); - await expect(releaseTask(TASK_UUID)).rejects.toThrow(NotClaimedError); + await expect(releaseTask(TASK_UUID, adminAuth)).rejects.toThrow(NotClaimedError); }); it("re-throws non-P2025 errors", async () => { const dbError = new Error("Timeout"); mockPrisma.task.update.mockRejectedValue(dbError); - await expect(releaseTask(TASK_UUID)).rejects.toThrow("Timeout"); + await expect(releaseTask(TASK_UUID, adminAuth)).rejects.toThrow("Timeout"); }); it("emits change event on successful release", async () => { @@ -638,7 +674,7 @@ describe("releaseTask", () => { }; mockPrisma.task.update.mockResolvedValue(released); - await releaseTask(TASK_UUID); + await releaseTask(TASK_UUID, adminAuth); expect(mockEventBus.emitChange).toHaveBeenCalledWith( expect.objectContaining({ @@ -656,7 +692,7 @@ describe("deleteTask", () => { const task = rawTask(); mockPrisma.task.delete.mockResolvedValue(task); - const result = await deleteTask(TASK_UUID); + const result = await deleteTask(TASK_UUID, adminAuth); expect(result.uuid).toBe(TASK_UUID); expect(mockPrisma.task.delete).toHaveBeenCalledWith({ where: { uuid: TASK_UUID } }); @@ -666,7 +702,7 @@ describe("deleteTask", () => { const task = rawTask(); mockPrisma.task.delete.mockResolvedValue(task); - await deleteTask(TASK_UUID); + await deleteTask(TASK_UUID, adminAuth); expect(mockEventBus.emitChange).toHaveBeenCalledWith( expect.objectContaining({ @@ -699,7 +735,7 @@ describe("markAcceptanceCriteria", () => { COMPANY_UUID, TASK_UUID, [{ uuid: criterionUuid, status: "passed", evidence: "Looks good" }], - { type: "user", actorUuid: authContexts.user.actorUuid }, + { type: "user", actorUuid: authContexts.user.actorUuid }, adminAuth, ); expect(result.items).toHaveLength(1); @@ -724,7 +760,7 @@ describe("markAcceptanceCriteria", () => { COMPANY_UUID, TASK_UUID, [{ uuid: criterionUuid, status: "passed" }], - { type: "user", actorUuid: "u1" }, + { type: "user", actorUuid: "u1" }, adminAuth, ), ).rejects.toThrow("Task not found"); }); @@ -738,7 +774,7 @@ describe("markAcceptanceCriteria", () => { COMPANY_UUID, TASK_UUID, [{ uuid: "wrong-crit", status: "passed" }], - { type: "user", actorUuid: "u1" }, + { type: "user", actorUuid: "u1" }, adminAuth, ), ).rejects.toThrow(/does not belong to task/); }); @@ -759,7 +795,7 @@ describe("markAcceptanceCriteria", () => { COMPANY_UUID, TASK_UUID, [{ uuid: criterionUuid, status: "passed" }], - { type: "user", actorUuid: "u1" }, + { type: "user", actorUuid: "u1" }, adminAuth, ); expect(mockEventBus.emitChange).toHaveBeenCalledWith( @@ -784,7 +820,7 @@ describe("replaceAcceptanceCriteria", () => { await replaceAcceptanceCriteria(COMPANY_UUID, TASK_UUID, [ { description: " new crit ", required: false }, { description: " " }, // blank dropped by normalization - ]); + ], adminAuth); // Ran inside a transaction (single atomic unit). expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1); @@ -804,7 +840,7 @@ describe("replaceAcceptanceCriteria", () => { mockPrisma.task.findFirst.mockResolvedValue(null); await expect( - replaceAcceptanceCriteria(COMPANY_UUID, TASK_UUID, [{ description: "x" }]), + replaceAcceptanceCriteria(COMPANY_UUID, TASK_UUID, [{ description: "x" }], adminAuth), ).rejects.toThrow("Task not found"); expect(mockPrisma.$transaction).not.toHaveBeenCalled(); expect(mockPrisma.acceptanceCriterion.deleteMany).not.toHaveBeenCalled(); @@ -814,7 +850,7 @@ describe("replaceAcceptanceCriteria", () => { mockPrisma.task.findFirst.mockResolvedValue(rawTask()); await expect( - replaceAcceptanceCriteria(COMPANY_UUID, TASK_UUID, [{ description: " " }]), + replaceAcceptanceCriteria(COMPANY_UUID, TASK_UUID, [{ description: " " }], adminAuth), ).rejects.toThrow("acceptance criterion"); expect(mockPrisma.$transaction).not.toHaveBeenCalled(); expect(mockPrisma.acceptanceCriterion.deleteMany).not.toHaveBeenCalled(); @@ -843,7 +879,7 @@ describe("reportCriteriaSelfCheck", () => { COMPANY_UUID, TASK_UUID, [{ uuid: criterionUuid, devStatus: "passed", devEvidence: "Tests pass" }], - { type: "agent", actorUuid: authContexts.agent.actorUuid }, + { type: "agent", actorUuid: authContexts.agent.actorUuid }, adminAuth, ); expect(result.items).toHaveLength(1); @@ -868,7 +904,7 @@ describe("reportCriteriaSelfCheck", () => { COMPANY_UUID, TASK_UUID, [{ uuid: "c1", devStatus: "passed" }], - { type: "agent", actorUuid: "a1" }, + { type: "agent", actorUuid: "a1" }, adminAuth, ), ).rejects.toThrow("Task not found"); }); @@ -882,7 +918,7 @@ describe("reportCriteriaSelfCheck", () => { COMPANY_UUID, TASK_UUID, [{ uuid: "wrong-crit", devStatus: "failed" }], - { type: "agent", actorUuid: "a1" }, + { type: "agent", actorUuid: "a1" }, adminAuth, ), ).rejects.toThrow(/does not belong to task/); }); @@ -903,7 +939,7 @@ describe("reportCriteriaSelfCheck", () => { COMPANY_UUID, TASK_UUID, [{ uuid: "c1", devStatus: "passed" }], - { type: "agent", actorUuid: "a1" }, + { type: "agent", actorUuid: "a1" }, adminAuth, ); const updateData = mockPrisma.acceptanceCriterion.update.mock.calls[0][0].data; @@ -1020,7 +1056,7 @@ describe("addTaskDependency", () => { (await import("@/services/task.service")).addTaskDependency( COMPANY_UUID, taskUuid1, - taskUuid1, + taskUuid1, adminAuth, ), ).rejects.toThrow("A task cannot depend on itself"); }); @@ -1034,7 +1070,7 @@ describe("addTaskDependency", () => { (await import("@/services/task.service")).addTaskDependency( COMPANY_UUID, "nonexistent", - taskUuid2, + taskUuid2, adminAuth, ), ).rejects.toThrow("Task not found"); }); @@ -1048,7 +1084,7 @@ describe("addTaskDependency", () => { (await import("@/services/task.service")).addTaskDependency( COMPANY_UUID, taskUuid1, - "nonexistent", + "nonexistent", adminAuth, ), ).rejects.toThrow("Dependency task not found"); }); @@ -1062,7 +1098,7 @@ describe("addTaskDependency", () => { (await import("@/services/task.service")).addTaskDependency( COMPANY_UUID, taskUuid1, - taskUuid2, + taskUuid2, adminAuth, ), ).rejects.toThrow("Tasks must belong to the same project"); }); @@ -1083,7 +1119,7 @@ describe("addTaskDependency", () => { (await import("@/services/task.service")).addTaskDependency( COMPANY_UUID, taskUuid3, - taskUuid1, + taskUuid1, adminAuth, ), ).rejects.toThrow("Adding this dependency would create a cycle"); }); @@ -1102,7 +1138,7 @@ describe("addTaskDependency", () => { const result = await (await import("@/services/task.service")).addTaskDependency( COMPANY_UUID, taskUuid1, - taskUuid2, + taskUuid2, adminAuth, ); expect(result.taskUuid).toBe(taskUuid1); @@ -1123,7 +1159,7 @@ describe("removeTaskDependency", () => { (await import("@/services/task.service")).removeTaskDependency( COMPANY_UUID, "nonexistent", - "dep-uuid", + "dep-uuid", adminAuth, ), ).rejects.toThrow("Task not found"); }); @@ -1135,7 +1171,7 @@ describe("removeTaskDependency", () => { await (await import("@/services/task.service")).removeTaskDependency( COMPANY_UUID, "t1", - "dep-uuid", + "dep-uuid", adminAuth, ); expect(mockPrisma.taskDependency.deleteMany).toHaveBeenCalledWith({ @@ -1213,7 +1249,7 @@ describe("getTaskDependencies", () => { const result = await (await import("@/services/task.service")).getTaskDependencies( COMPANY_UUID, - "t1", + "t1", adminAuth, ); expect(result.dependsOn).toHaveLength(1); @@ -1228,7 +1264,7 @@ describe("getTaskDependencies", () => { await expect( (await import("@/services/task.service")).getTaskDependencies( COMPANY_UUID, - "nonexistent", + "nonexistent", adminAuth, ), ).rejects.toThrow("Task not found"); }); @@ -1250,6 +1286,7 @@ describe("getUnblockedTasks", () => { const result = await (await import("@/services/task.service")).getUnblockedTasks({ companyUuid: COMPANY_UUID, projectUuid: PROJECT_UUID, + auth: adminAuth, }); expect(result.tasks).toHaveLength(1); @@ -1263,6 +1300,7 @@ describe("getUnblockedTasks", () => { await (await import("@/services/task.service")).getUnblockedTasks({ companyUuid: COMPANY_UUID, projectUuid: PROJECT_UUID, + auth: adminAuth, }); const whereArg = mockPrisma.task.findMany.mock.calls[0][0].where; @@ -1277,6 +1315,7 @@ describe("getUnblockedTasks", () => { companyUuid: COMPANY_UUID, projectUuid: PROJECT_UUID, proposalUuids: ["prop-1", "prop-2"], + auth: adminAuth, }); const whereArg = mockPrisma.task.findMany.mock.calls[0][0].where; @@ -1290,6 +1329,7 @@ describe("getUnblockedTasks", () => { await (await import("@/services/task.service")).getUnblockedTasks({ companyUuid: COMPANY_UUID, projectUuid: PROJECT_UUID, + auth: adminAuth, }); const whereArg = mockPrisma.task.findMany.mock.calls[0][0].where; @@ -1380,7 +1420,7 @@ describe("getProjectTaskDependencies", () => { const result = await (await import("@/services/task.service")).getProjectTaskDependencies( COMPANY_UUID, - PROJECT_UUID, + PROJECT_UUID, adminAuth, ); expect(result.nodes).toHaveLength(2); @@ -1396,7 +1436,7 @@ describe("getProjectTaskDependencies", () => { const result = await (await import("@/services/task.service")).getProjectTaskDependencies( COMPANY_UUID, - PROJECT_UUID, + PROJECT_UUID, adminAuth, ); expect(result.nodes).toEqual([]); @@ -1418,7 +1458,7 @@ describe("resetAcceptanceCriterion", () => { await (await import("@/services/task.service")).resetAcceptanceCriterion( COMPANY_UUID, TASK_UUID, - "c1", + "c1", adminAuth, ); expect(mockPrisma.acceptanceCriterion.update).toHaveBeenCalledWith({ @@ -1441,7 +1481,7 @@ describe("resetAcceptanceCriterion", () => { (await import("@/services/task.service")).resetAcceptanceCriterion( COMPANY_UUID, "nonexistent", - "c1", + "c1", adminAuth, ), ).rejects.toThrow("Task not found"); }); @@ -1454,7 +1494,7 @@ describe("resetAcceptanceCriterion", () => { (await import("@/services/task.service")).resetAcceptanceCriterion( COMPANY_UUID, TASK_UUID, - "wrong-crit", + "wrong-crit", adminAuth, ), ).rejects.toThrow("Criterion not found for this task"); }); @@ -1473,7 +1513,7 @@ describe("getAcceptanceStatus", () => { const result = await (await import("@/services/task.service")).getAcceptanceStatus( COMPANY_UUID, - TASK_UUID, + TASK_UUID, adminAuth, ); expect(result.items).toHaveLength(2); @@ -1487,7 +1527,7 @@ describe("getAcceptanceStatus", () => { await expect( (await import("@/services/task.service")).getAcceptanceStatus( COMPANY_UUID, - "nonexistent", + "nonexistent", adminAuth, ), ).rejects.toThrow("Task not found"); }); @@ -1545,7 +1585,6 @@ describe("createAcceptanceCriteria", () => { describe("updateTask", () => { it("should update task fields", async () => { - mockPrisma.task.findUnique.mockResolvedValue(null); const updated = { ...rawTask({ title: "Updated Title", status: "in_progress" }), project: { uuid: PROJECT_UUID, name: "Test Project" }, @@ -1555,7 +1594,7 @@ describe("updateTask", () => { const result = await updateTask(TASK_UUID, { title: "Updated Title", status: "in_progress", - }); + }, adminAuth); expect(result.title).toBe("Updated Title"); expect(result.status).toBe("in_progress"); @@ -1572,7 +1611,7 @@ describe("updateTask", () => { mockPrisma.task.update.mockResolvedValue(updated); mockPrisma.acceptanceCriterion.updateMany.mockResolvedValue({ count: 2 }); - await updateTask(TASK_UUID, { status: "in_progress" }); + await updateTask(TASK_UUID, { status: "in_progress" }, adminAuth); expect(mockPrisma.acceptanceCriterion.updateMany).toHaveBeenCalledWith({ where: { taskUuid: TASK_UUID }, @@ -1592,7 +1631,7 @@ describe("updateTask", () => { mockPrisma.task.findUnique.mockResolvedValue({ status: "to_verify" }); mockPrisma.task.update.mockResolvedValue(updated); - await updateTask(TASK_UUID, { status: "done" }); + await updateTask(TASK_UUID, { status: "done" }, adminAuth); expect(mockPrisma.acceptanceCriterion.updateMany).not.toHaveBeenCalled(); }); @@ -1615,6 +1654,7 @@ describe("updateTask", () => { await updateTask( TASK_UUID, { description: newDesc }, + adminAuth, { actorType: "agent", actorUuid: "agent1" }, ); @@ -1627,3 +1667,117 @@ describe("updateTask", () => { expect(mockActivityService.createActivity).toHaveBeenCalled(); }); }); + +// ---------- access gating (project visibility) ---------- + +describe("access gating", () => { + it("listTasks returns empty for a non-member of a private project", async () => { + denyAccess(); + + const result = await listTasks({ + companyUuid: COMPANY_UUID, + projectUuid: PROJECT_UUID, + skip: 0, + take: 10, + auth: userAuth, + }); + + expect(result).toEqual({ tasks: [], total: 0 }); + expect(mockPrisma.task.findMany).not.toHaveBeenCalled(); + }); + + it("getTask returns null for a non-member of a private project", async () => { + mockPrisma.task.findFirst.mockResolvedValue(rawTaskWithRelations()); + denyAccess(); + + const result = await getTask(COMPANY_UUID, TASK_UUID, userAuth); + + expect(result).toBeNull(); + }); + + it("claimTask rejects a non-member of a private project", async () => { + mockPrisma.task.findFirst.mockResolvedValue({ projectUuid: PROJECT_UUID }); + denyAccess(); + + await expect( + claimTask( + { + taskUuid: TASK_UUID, + companyUuid: COMPANY_UUID, + assigneeType: "agent", + assigneeUuid: "a1", + }, + userAuth, + ), + ).rejects.toThrow(AlreadyClaimedError); + expect(mockPrisma.task.update).not.toHaveBeenCalled(); + }); +}); + +// ---------- Visibility write-gate rejections (non-member of a private project) ---------- +describe("write gates reject a non-member of a private project", () => { + // Each gated mutation first resolves the task (which carries projectUuid), + // then calls canAccessProject(access, projectUuid). Configure the task lookup + // to succeed and the project lookup to be private/owned-by-someone-else with + // no membership row, so the gate denies. + function denyForExistingTask() { + mockPrisma.task.findFirst.mockResolvedValue(rawTask()); + mockPrisma.task.findUnique.mockResolvedValue(rawTask()); + mockPrisma.project.findFirst.mockResolvedValue({ + visibility: "private", + ownerType: "user", + ownerUuid: "other-owner", + }); + mockPrisma.projectMember.findUnique.mockResolvedValue(null); + } + + beforeEach(() => denyForExistingTask()); + + it("markAcceptanceCriteria denies non-member", async () => { + await expect( + markAcceptanceCriteria(COMPANY_UUID, TASK_UUID, [{ uuid: "ac-1", status: "passed" }], { type: "user", actorUuid: "user-1" }, userAuth), + ).rejects.toThrow("Task not found"); + }); + + it("reportCriteriaSelfCheck denies non-member", async () => { + await expect( + reportCriteriaSelfCheck(COMPANY_UUID, TASK_UUID, [{ uuid: "ac-1", devStatus: "passed" }], { type: "user", actorUuid: "user-1" }, userAuth), + ).rejects.toThrow("Task not found"); + }); + + it("replaceAcceptanceCriteria denies non-member", async () => { + await expect( + replaceAcceptanceCriteria(COMPANY_UUID, TASK_UUID, [{ description: "x", required: true }], userAuth), + ).rejects.toThrow("Task not found"); + }); + + it("resetAcceptanceCriterion denies non-member", async () => { + await expect( + resetAcceptanceCriterion(COMPANY_UUID, TASK_UUID, "ac-1", userAuth), + ).rejects.toThrow("Task not found"); + }); + + it("getAcceptanceStatus denies non-member", async () => { + await expect( + getAcceptanceStatus(COMPANY_UUID, TASK_UUID, userAuth), + ).rejects.toThrow("Task not found"); + }); + + it("addTaskDependency denies non-member", async () => { + await expect( + addTaskDependency(COMPANY_UUID, TASK_UUID, "dep-uuid", userAuth), + ).rejects.toThrow(); + }); + + it("removeTaskDependency denies non-member", async () => { + await expect( + removeTaskDependency(COMPANY_UUID, TASK_UUID, "dep-uuid", userAuth), + ).rejects.toThrow(); + }); + + it("getTaskDependencies denies non-member", async () => { + await expect( + getTaskDependencies(COMPANY_UUID, TASK_UUID, userAuth), + ).rejects.toThrow(); + }); +}); diff --git a/src/services/activity.service.ts b/src/services/activity.service.ts index 7ba6779c..5910010b 100644 --- a/src/services/activity.service.ts +++ b/src/services/activity.service.ts @@ -5,6 +5,7 @@ import { prisma } from "@/lib/prisma"; import { eventBus } from "@/lib/event-bus"; import { getActorName } from "@/lib/uuid-resolver"; +import { type AnyAuth, canAccessProject } from "@/lib/authz/project-access"; export type TargetType = "idea" | "task" | "proposal" | "document"; @@ -15,6 +16,8 @@ export interface ActivityListParams { take: number; targetType?: TargetType; targetUuid?: string; + /** Auth context used to restrict results to projects the actor can access. */ + auth: AnyAuth; } export interface ActivityCreateParams { @@ -52,7 +55,13 @@ export async function listActivities({ take, targetType, targetUuid, + auth, }: ActivityListParams) { + // Visibility gate: a non-member of this project sees no activity. + if (!(await canAccessProject(auth, projectUuid))) { + return { activities: [], total: 0 }; + } + const where = { projectUuid, companyUuid, diff --git a/src/services/checkin.service.ts b/src/services/checkin.service.ts index ae730e35..6ad91b10 100644 --- a/src/services/checkin.service.ts +++ b/src/services/checkin.service.ts @@ -140,6 +140,7 @@ async function buildNotificationSummary(auth: AuthContext): Promise ({ diff --git a/src/services/comment.service.ts b/src/services/comment.service.ts index c4ee9a0e..abad0821 100644 --- a/src/services/comment.service.ts +++ b/src/services/comment.service.ts @@ -12,6 +12,7 @@ import * as mentionService from "@/services/mention.service"; import * as activityService from "@/services/activity.service"; import { eventBus, type RealtimeEvent } from "@/lib/event-bus"; import logger from "@/lib/logger"; +import { type AnyAuth, canAccessProject } from "@/lib/authz/project-access"; export interface CommentListParams { companyUuid: string; @@ -19,6 +20,8 @@ export interface CommentListParams { targetUuid: string; skip: number; take: number; + /** Auth context used to gate access by the target entity's project. */ + auth: AnyAuth; } export interface CommentCreateParams { @@ -28,6 +31,8 @@ export interface CommentCreateParams { content: string; authorType: "user" | "agent"; authorUuid: string; + /** Auth context used to gate access by the target entity's project. */ + auth: AnyAuth; } // Comment response format (using UUIDs) @@ -52,6 +57,7 @@ export async function listComments({ targetUuid, skip, take, + auth, }: CommentListParams): Promise<{ comments: CommentResponse[]; total: number }> { // Validate target exists const exists = await validateTargetExists(targetType, targetUuid, companyUuid); @@ -59,6 +65,16 @@ export async function listComments({ return { comments: [], total: 0 }; } + // Visibility gate: a non-member of the target's project must not read its + // comments. Resolve the target's project and reject when inaccessible — + // mirror the not-found style (empty list) used above for a missing target. + // An unresolved project ("" ) denies non-super-admins via canAccessProject + // while super admins (who bypass the project filter) still pass. + const projectUuid = await resolveProjectUuid(targetType, targetUuid, companyUuid); + if (!(await canAccessProject(auth, projectUuid ?? ""))) { + return { comments: [], total: 0 }; + } + const where = { companyUuid, targetType, targetUuid }; const [rawComments, total] = await Promise.all([ @@ -112,6 +128,7 @@ export async function createComment({ content, authorType, authorUuid, + auth, }: CommentCreateParams): Promise { // Validate target exists const exists = await validateTargetExists(targetType, targetUuid, companyUuid); @@ -119,6 +136,16 @@ export async function createComment({ throw new Error(`Target ${targetType} with UUID ${targetUuid} not found`); } + // Visibility gate: a non-member of the target's project must not post a + // comment. Resolve the target's project and reject when inaccessible — + // reuse the same not-found error style used for a missing target above. + // An unresolved project ("") denies non-super-admins via canAccessProject + // while super admins (who bypass the project filter) still pass. + const targetProjectUuid = await resolveProjectUuid(targetType, targetUuid, companyUuid); + if (!(await canAccessProject(auth, targetProjectUuid ?? ""))) { + throw new Error(`Target ${targetType} with UUID ${targetUuid} not found`); + } + const comment = await prisma.comment.create({ data: { companyUuid, diff --git a/src/services/document.service.ts b/src/services/document.service.ts index f9ceb1fd..2ab1d400 100644 --- a/src/services/document.service.ts +++ b/src/services/document.service.ts @@ -7,6 +7,10 @@ import { formatCreatedBy } from "@/lib/uuid-resolver"; import { eventBus } from "@/lib/event-bus"; import * as activityService from "@/services/activity.service"; import logger from "@/lib/logger"; +import { + type AnyAuth, + canAccessProject, +} from "@/lib/authz/project-access"; const docLogger = logger.child({ module: "document.service" }); @@ -18,6 +22,8 @@ export interface DocumentListParams { skip: number; take: number; type?: string; + /** Auth context used to restrict results to projects the actor can access. */ + auth: AnyAuth; } export interface DocumentCreateParams { @@ -101,7 +107,13 @@ export async function listDocuments({ skip, take, type, + auth, }: DocumentListParams): Promise<{ documents: DocumentResponse[]; total: number }> { + // Visibility gate: a non-member of this project sees nothing. + if (!(await canAccessProject(auth, projectUuid))) { + return { documents: [], total: 0 }; + } + const where = { projectUuid, companyUuid, @@ -172,7 +184,8 @@ export async function listDocumentsByProposalUuids( // Get Document details export async function getDocument( companyUuid: string, - uuid: string + uuid: string, + auth: AnyAuth ): Promise { const doc = await prisma.document.findFirst({ where: { uuid, companyUuid }, @@ -182,6 +195,8 @@ export async function getDocument( }); if (!doc) return null; + // Visibility gate: hide documents in projects the actor cannot access. + if (!(await canAccessProject(auth, doc.projectUuid))) return null; return formatDocumentResponse(doc, true); } @@ -203,8 +218,14 @@ export async function getDocumentByUuidUnscoped(uuid: string) { // Create Document export async function createDocument( - params: DocumentCreateParams + params: DocumentCreateParams, + auth: AnyAuth ): Promise { + // Visibility gate: cannot create a document in an inaccessible project. + if (!(await canAccessProject(auth, params.projectUuid))) { + throw new Error("Project not found"); + } + const doc = await prisma.document.create({ data: { companyUuid: params.companyUuid, @@ -341,8 +362,14 @@ async function emitReportSideEffects( // Update Document export async function updateDocument( uuid: string, - { title, content, incrementVersion }: DocumentUpdateParams + { title, content, incrementVersion }: DocumentUpdateParams, + auth: AnyAuth ): Promise { + // Visibility gate: resolve the document's project and reject non-members. + const target = await prisma.document.findUnique({ where: { uuid }, select: { projectUuid: true } }); + if (!target) throw new Error("Document not found"); + if (!(await canAccessProject(auth, target.projectUuid))) throw new Error("Document not found"); + const data: { title?: string; content?: string | null; version?: { increment: number } } = {}; if (title !== undefined) { @@ -367,7 +394,11 @@ export async function updateDocument( } // Delete Document -export async function deleteDocument(uuid: string) { +export async function deleteDocument(uuid: string, auth: AnyAuth) { + // Visibility gate: resolve the document's project and reject non-members. + const target = await prisma.document.findUnique({ where: { uuid }, select: { projectUuid: true } }); + if (!target) throw new Error("Document not found"); + if (!(await canAccessProject(auth, target.projectUuid))) throw new Error("Document not found"); return prisma.document.delete({ where: { uuid } }); } diff --git a/src/services/idea-tracker.service.ts b/src/services/idea-tracker.service.ts index ef99a222..e61ba4e1 100644 --- a/src/services/idea-tracker.service.ts +++ b/src/services/idea-tracker.service.ts @@ -9,6 +9,26 @@ import { prisma } from "@/lib/prisma"; import type { AuthContext } from "@/types/auth"; import { computeDerivedStatus, type DerivedIdeaStatus } from "@/services/idea.service"; +import { ALL_PROJECTS, getAccessibleProjectUuids } from "@/lib/authz/project-access"; + +// Intersect the caller-supplied project filter with the actor's accessible +// project set. Returns the project UUIDs to scope by, or null when the actor +// can access every project (super-admin path; no project filter needed). An +// empty array means "no accessible projects" — callers short-circuit to {}. +async function resolveAccessibleProjectUuids( + auth: AuthContext, + requested?: string[], +): Promise { + const accessible = await getAccessibleProjectUuids(auth); + if (accessible === ALL_PROJECTS) { + return requested && requested.length > 0 ? requested : null; + } + if (requested && requested.length > 0) { + const accessibleSet = new Set(accessible); + return requested.filter((u) => accessibleSet.has(u)); + } + return accessible; +} // ===== Idea tracker types ===== @@ -96,10 +116,12 @@ export async function buildIdeaTracker( options: BuildIdeaTrackerOptions = {}, ): Promise> { const maxIdeas = options.maxIdeas ?? Number.POSITIVE_INFINITY; + // Visibility gate: scope to the actor's accessible projects (intersected with + // any requested filter). null => actor sees all projects (no filter needed). + const scoped = await resolveAccessibleProjectUuids(auth, options.projectUuids); + if (scoped !== null && scoped.length === 0) return {}; const projectFilter = - options.projectUuids && options.projectUuids.length > 0 - ? { projectUuid: { in: options.projectUuids } } - : {}; + scoped !== null ? { projectUuid: { in: scoped } } : {}; // Q1: Ideas assigned to the agent OR to the agent's owner. // Exclude legacy "closed" (terminal) — elaborated/completed/etc. still flow @@ -261,10 +283,12 @@ export async function buildTaskTracker( auth: AuthContext, options: BuildTaskTrackerOptions = {}, ): Promise> { + // Visibility gate: scope to the actor's accessible projects (intersected with + // any requested filter). null => actor sees all projects (no filter needed). + const scoped = await resolveAccessibleProjectUuids(auth, options.projectUuids); + if (scoped !== null && scoped.length === 0) return {}; const projectFilter = - options.projectUuids && options.projectUuids.length > 0 - ? { projectUuid: { in: options.projectUuids } } - : {}; + scoped !== null ? { projectUuid: { in: scoped } } : {}; const rawTasks = await prisma.task.findMany({ where: { diff --git a/src/services/idea.service.ts b/src/services/idea.service.ts index f77434c1..2ddda66a 100644 --- a/src/services/idea.service.ts +++ b/src/services/idea.service.ts @@ -12,6 +12,10 @@ import * as activityService from "@/services/activity.service"; import * as documentService from "@/services/document.service"; import * as proposalService from "@/services/proposal.service"; import logger from "@/lib/logger"; +import { + type AnyAuth, + canAccessProject, +} from "@/lib/authz/project-access"; // ===== Derived Status ===== @@ -28,6 +32,8 @@ export interface IdeaListParams { assignedToMe?: boolean; // Filter for ideas assigned to current user actorUuid?: string; // Current user/agent UUID for assignedToMe filter actorType?: string; // "user" | "agent" for assignedToMe filter + /** Auth context used to restrict results to projects the actor can access. */ + auth: AnyAuth; } export interface IdeaCreateParams { @@ -182,7 +188,13 @@ export async function listIdeas({ assignedToMe, actorUuid, actorType, + auth, }: IdeaListParams): Promise<{ ideas: IdeaResponse[]; total: number }> { + // Visibility gate: a non-member of this project sees nothing. + if (!(await canAccessProject(auth, projectUuid))) { + return { ideas: [], total: 0 }; + } + const where: { projectUuid: string; companyUuid: string; @@ -321,7 +333,8 @@ async function getReportCountsForIdeas( // no reports is one extra cheap proposal lookup. export async function getIdea( companyUuid: string, - uuid: string + uuid: string, + auth: AnyAuth ): Promise { const idea = await prisma.idea.findFirst({ where: { uuid, companyUuid }, @@ -331,6 +344,8 @@ export async function getIdea( }); if (!idea) return null; + // Visibility gate: hide ideas in projects the actor cannot access. + if (!(await canAccessProject(auth, idea.projectUuid))) return null; const response = await formatIdeaResponse(idea); // Step 1: idea-rooted report-bearing proposals (approved or closed — @@ -367,7 +382,12 @@ export async function getIdeaByUuid(companyUuid: string, uuid: string) { } // Create Idea -export async function createIdea(params: IdeaCreateParams): Promise { +export async function createIdea(params: IdeaCreateParams, auth: AnyAuth): Promise { + // Visibility gate: cannot create an idea in a project the actor cannot access. + if (!(await canAccessProject(auth, params.projectUuid))) { + throw new Error("Project not found"); + } + const idea = await prisma.idea.create({ data: { companyUuid: params.companyUuid, @@ -406,8 +426,14 @@ export async function updateIdea( uuid: string, companyUuid: string, data: { title?: string; content?: string | null; status?: string }, + auth: AnyAuth, actorContext?: { actorType: string; actorUuid: string } ): Promise { + // Visibility gate: resolve the idea's project and reject non-members. + const target = await prisma.idea.findFirst({ where: { uuid, companyUuid }, select: { projectUuid: true } }); + if (!target) throw new Error("Idea not found"); + if (!(await canAccessProject(auth, target.projectUuid))) throw new Error("Idea not found"); + // If content is being updated and we have actor context, capture old content for mention diffing let oldContent: string | null = null; if (data.content !== undefined && actorContext) { @@ -449,11 +475,12 @@ export async function claimIdea({ assigneeType, assigneeUuid, assignedByUuid, -}: IdeaClaimParams): Promise { +}: IdeaClaimParams, auth: AnyAuth): Promise { const existing = await prisma.idea.findFirst({ where: { uuid: ideaUuid, companyUuid }, }); if (!existing) throw new AlreadyClaimedError("Idea"); + if (!(await canAccessProject(auth, existing.projectUuid))) throw new AlreadyClaimedError("Idea"); if (existing.assigneeUuid) { throw new AlreadyClaimedError("Idea"); } @@ -488,11 +515,12 @@ export async function assignIdea({ assigneeType, assigneeUuid, assignedByUuid, -}: IdeaClaimParams): Promise { +}: IdeaClaimParams, auth: AnyAuth): Promise { const existing = await prisma.idea.findFirst({ where: { uuid: ideaUuid, companyUuid }, }); if (!existing) throw new Error("Idea not found"); + if (!(await canAccessProject(auth, existing.projectUuid))) throw new Error("Idea not found"); const normalizedAssignStatus = normalizeIdeaStatus(existing.status); if (normalizedAssignStatus === "elaborated") { throw new Error("Cannot assign an elaborated Idea"); @@ -521,9 +549,10 @@ export async function assignIdea({ } // Release Idea (clears assignee, resets to open; any non-terminal status) -export async function releaseIdea(uuid: string): Promise { +export async function releaseIdea(uuid: string, auth: AnyAuth): Promise { const existing = await prisma.idea.findUnique({ where: { uuid } }); if (!existing) throw new Error("Idea not found"); + if (!(await canAccessProject(auth, existing.projectUuid))) throw new Error("Idea not found"); const normalizedReleaseStatus = normalizeIdeaStatus(existing.status); if (normalizedReleaseStatus === "elaborated") { throw new Error("Cannot release an elaborated Idea"); @@ -602,7 +631,11 @@ async function processNewIdeaMentions( } // Delete Idea -export async function deleteIdea(uuid: string) { +export async function deleteIdea(uuid: string, auth: AnyAuth) { + // Visibility gate: resolve project before deleting; reject non-members. + const existing = await prisma.idea.findUnique({ where: { uuid }, select: { projectUuid: true } }); + if (!existing) throw new Error("Idea not found"); + if (!(await canAccessProject(auth, existing.projectUuid))) throw new Error("Idea not found"); const idea = await prisma.idea.delete({ where: { uuid } }); eventBus.emitChange({ companyUuid: idea.companyUuid, projectUuid: idea.projectUuid, entityType: "idea", entityUuid: idea.uuid, action: "deleted" }); return idea; @@ -632,7 +665,8 @@ export async function moveIdea( ideaUuid: string, targetProjectUuid: string, actorUuid: string, - actorType: string = "user" + actorType: string = "user", + auth: AnyAuth ): Promise { // Validate idea exists and belongs to same company const idea = await prisma.idea.findFirst({ @@ -640,6 +674,10 @@ export async function moveIdea( include: { project: { select: { uuid: true, name: true } } }, }); if (!idea) throw new ApiError("NOT_FOUND", "Idea not found", 404); + // Visibility gate: both source idea's project and target project must be accessible. + if (!(await canAccessProject(auth, idea.projectUuid))) { + throw new ApiError("NOT_FOUND", "Idea not found", 404); + } // Validate target project exists and belongs to same company const targetProject = await prisma.project.findFirst({ @@ -647,6 +685,9 @@ export async function moveIdea( select: { uuid: true, name: true }, }); if (!targetProject) throw new ApiError("NOT_FOUND", "Target project not found", 404); + if (!(await canAccessProject(auth, targetProjectUuid))) { + throw new ApiError("NOT_FOUND", "Target project not found", 404); + } if (idea.projectUuid === targetProjectUuid) { throw new ApiError("BAD_REQUEST", "Idea is already in the target project", 400); @@ -794,7 +835,8 @@ export async function moveIdea( export async function moveIdeaPreview( companyUuid: string, ideaUuid: string, - targetProjectUuid: string + targetProjectUuid: string, + auth: AnyAuth ): Promise { // Validate idea exists and belongs to same company const idea = await prisma.idea.findFirst({ @@ -802,6 +844,9 @@ export async function moveIdeaPreview( select: { uuid: true, projectUuid: true }, }); if (!idea) throw new ApiError("NOT_FOUND", "Idea not found", 404); + if (!(await canAccessProject(auth, idea.projectUuid))) { + throw new ApiError("NOT_FOUND", "Idea not found", 404); + } // Validate target project exists and belongs to same company const targetProject = await prisma.project.findFirst({ @@ -809,6 +854,9 @@ export async function moveIdeaPreview( select: { uuid: true }, }); if (!targetProject) throw new ApiError("NOT_FOUND", "Target project not found", 404); + if (!(await canAccessProject(auth, targetProjectUuid))) { + throw new ApiError("NOT_FOUND", "Target project not found", 404); + } if (idea.projectUuid === targetProjectUuid) { throw new ApiError("BAD_REQUEST", "Idea is already in the target project", 400); @@ -938,8 +986,9 @@ export function computeDerivedStatus(ctx: DerivedStatusContext): DerivedStatusRe export async function getIdeaWithDerivedStatus( companyUuid: string, ideaUuid: string, + auth: AnyAuth, ): Promise<(IdeaResponse & DerivedStatusResult) | null> { - const idea = await getIdea(companyUuid, ideaUuid); + const idea = await getIdea(companyUuid, ideaUuid, auth); if (!idea) return null; const proposals = await prisma.proposal.findMany({ @@ -994,7 +1043,11 @@ export interface IdeaWithDerivedStatus { export async function getIdeasWithDerivedStatus( companyUuid: string, projectUuid: string, + auth: AnyAuth, ): Promise { + // Visibility gate: a non-member of this project sees no ideas. + if (!(await canAccessProject(auth, projectUuid))) return []; + // Query 1: All ideas in the project const ideas = await prisma.idea.findMany({ where: { companyUuid, projectUuid }, @@ -1134,8 +1187,9 @@ const TRACKER_STATUSES: DerivedIdeaStatus[] = [ export async function getTrackerGroups( companyUuid: string, projectUuid: string, + auth: AnyAuth, ): Promise { - const ideas = await getIdeasWithDerivedStatus(companyUuid, projectUuid); + const ideas = await getIdeasWithDerivedStatus(companyUuid, projectUuid, auth); const groups: Record = {}; const counts: Record = {}; diff --git a/src/services/mention.service.ts b/src/services/mention.service.ts index 329b55fe..fc6da087 100644 --- a/src/services/mention.service.ts +++ b/src/services/mention.service.ts @@ -48,6 +48,13 @@ export interface SearchMentionablesParams { actorUuid: string; ownerUuid?: string; limit?: number; + /** + * When true AND the query is empty, also surface recent company users + * (in addition to the actor's own agents). Used by the member-add picker, + * which needs to list humans even before the user types. Defaults to false, + * which preserves the @mention empty-query behavior (agents only). + */ + includeUsersOnEmpty?: boolean; } // ===== Service Methods ===== @@ -211,7 +218,7 @@ const DEFAULT_EMPTY_QUERY_LIMIT = 5; * - Agent caller: all company users + same-owner agents (agents with same ownerUuid) */ export async function searchMentionables(params: SearchMentionablesParams): Promise { - const { companyUuid, query, actorType, actorUuid, ownerUuid, limit = 10 } = params; + const { companyUuid, query, actorType, actorUuid, ownerUuid, limit = 10, includeUsersOnEmpty = false } = params; const effectiveLimit = Math.min(limit, 50); const results: Mentionable[] = []; @@ -253,7 +260,34 @@ export async function searchMentionables(params: SearchMentionablesParams): Prom } } - return results; + // For the member-add picker we also surface recent company users on an empty + // query, so the human can pick a teammate before typing. The default + // @mention behavior (flag off) stays agents-only. + if (includeUsersOnEmpty) { + const users = await prisma.user.findMany({ + where: { companyUuid }, + select: { + uuid: true, + name: true, + email: true, + avatarUrl: true, + }, + orderBy: { createdAt: "desc" }, + take: Math.min(DEFAULT_EMPTY_QUERY_LIMIT, effectiveLimit), + }); + + for (const user of users) { + results.push({ + type: "user", + uuid: user.uuid, + name: user.name ?? user.email ?? "Unknown", + email: user.email, + avatarUrl: user.avatarUrl, + }); + } + } + + return results.slice(0, effectiveLimit); } // Search users (all company users are mentionable) const users = await prisma.user.findMany({ diff --git a/src/services/notification.service.ts b/src/services/notification.service.ts index 05620621..3d072d65 100644 --- a/src/services/notification.service.ts +++ b/src/services/notification.service.ts @@ -4,6 +4,11 @@ import { prisma } from "@/lib/prisma"; import { eventBus } from "@/lib/event-bus"; +import { + type AnyAuth, + ALL_PROJECTS, + getAccessibleProjectUuids, +} from "@/lib/authz/project-access"; // ===== Type Definitions ===== @@ -32,6 +37,8 @@ export interface NotificationListParams { archived?: boolean; skip?: number; take?: number; + /** Auth context used to restrict results to projects the actor can access. */ + auth: AnyAuth; } export interface NotificationResponse { @@ -243,10 +250,20 @@ export async function createBatch( export async function list( params: NotificationListParams ): Promise<{ notifications: NotificationResponse[]; total: number; unreadCount: number }> { - const { companyUuid, recipientType, recipientUuid, projectUuid, readFilter, archived } = params; + const { companyUuid, recipientType, recipientUuid, projectUuid, readFilter, archived, auth } = params; const skip = params.skip ?? 0; const take = params.take ?? 20; + // Visibility gate: restrict project-scoped notifications to the accessible + // set, but ALWAYS preserve non-project notifications (denormalized + // projectUuid is "" for those) so the recipient never loses them. Super + // admins (ALL_PROJECTS) skip the filter entirely. + const accessible = await getAccessibleProjectUuids(auth); + const visibilityFilter = + accessible === ALL_PROJECTS + ? undefined + : { OR: [{ projectUuid: { in: accessible } }, { projectUuid: "" }] }; + const where = { companyUuid, recipientType, @@ -256,6 +273,7 @@ export async function list( ...(readFilter === "read" && { readAt: { not: null } }), ...(archived === false && { archivedAt: null }), ...(archived === true && { archivedAt: { not: null } }), + ...(visibilityFilter ?? {}), }; const [rawNotifications, total, unreadCount] = await Promise.all([ diff --git a/src/services/project-group.service.ts b/src/services/project-group.service.ts index 6da89aa6..1124515e 100644 --- a/src/services/project-group.service.ts +++ b/src/services/project-group.service.ts @@ -1,5 +1,15 @@ import { prisma } from "@/lib/prisma"; import { eventBus } from "@/lib/event-bus"; +import { getActorName } from "@/lib/uuid-resolver"; +import { + type AnyAuth, + getAccessibleProjectUuids, + getAccessibleGroupUuids, + canAccessGroup, + canManageOrClaimableGroup, + applyProjectFilter, + ALL_PROJECTS, +} from "@/lib/authz/project-access"; // ============================================================ // Interfaces @@ -9,6 +19,13 @@ export interface ProjectGroupCreateParams { companyUuid: string; name: string; description?: string | null; + /** Visibility for the new group (default "private"). */ + visibility?: "shared" | "private"; + /** Owner of the group (the acting human or agent). */ + ownerType?: "user" | "agent" | null; + ownerUuid?: string | null; + /** Initial members (besides the owner, who is always added). */ + memberUuids?: { memberType: "user" | "agent"; memberUuid: string }[]; } export interface ProjectGroupUpdateParams { @@ -23,6 +40,9 @@ export interface ProjectGroupResponse { name: string; description: string | null; projectCount: number; + visibility: "shared" | "private"; + ownerType: "user" | "agent" | null; + ownerUuid: string | null; createdAt: string; updatedAt: string; } @@ -40,6 +60,11 @@ export interface GroupDashboardResponse { uuid: string; name: string; description: string | null; + visibility: "shared" | "private"; + ownerType: "user" | "agent" | null; + ownerUuid: string | null; + /** Whether the requesting actor owns (can manage) this group. */ + isOwner: boolean; }; stats: { projectCount: number; @@ -76,14 +101,47 @@ export interface GroupDashboardResponse { export async function createProjectGroup( params: ProjectGroupCreateParams ): Promise { + const { + companyUuid, + name, + description, + visibility = "private", + ownerType = null, + ownerUuid = null, + memberUuids = [], + } = params; + const group = await prisma.projectGroup.create({ data: { - companyUuid: params.companyUuid, - name: params.name, - description: params.description ?? "", + companyUuid, + name, + description: description ?? "", + visibility, + ownerType, + ownerUuid, }, }); + // Seed members: the owner (if any) plus any explicitly provided members, + // de-duplicated on (memberType, memberUuid). Mirrors createProject. + const seed = new Map(); + if (ownerType && ownerUuid) { + seed.set(`${ownerType}:${ownerUuid}`, { memberType: ownerType, memberUuid: ownerUuid }); + } + for (const m of memberUuids) { + seed.set(`${m.memberType}:${m.memberUuid}`, m); + } + if (seed.size > 0) { + await prisma.projectGroupMember.createMany({ + data: [...seed.values()].map((m) => ({ + companyUuid, + projectGroupUuid: group.uuid, + memberType: m.memberType, + memberUuid: m.memberUuid, + })), + }); + } + eventBus.emitChange({ companyUuid: params.companyUuid, projectUuid: "", @@ -97,6 +155,9 @@ export async function createProjectGroup( name: group.name, description: group.description, projectCount: 0, + visibility: group.visibility as "shared" | "private", + ownerType: (group.ownerType as "user" | "agent" | null) ?? null, + ownerUuid: group.ownerUuid ?? null, createdAt: group.createdAt.toISOString(), updatedAt: group.updatedAt.toISOString(), }; @@ -127,6 +188,9 @@ export async function updateProjectGroup( name: updated.name, description: updated.description, projectCount, + visibility: updated.visibility as "shared" | "private", + ownerType: (updated.ownerType as "user" | "agent" | null) ?? null, + ownerUuid: updated.ownerUuid ?? null, createdAt: updated.createdAt.toISOString(), updatedAt: updated.updatedAt.toISOString(), }; @@ -172,15 +236,20 @@ export async function deleteProjectGroup( export async function getProjectGroup( companyUuid: string, - groupUuid: string + groupUuid: string, + auth: AnyAuth ): Promise { + // Inaccessible group => looks like it does not exist. + if (!(await canAccessGroup(auth, groupUuid))) return null; + const group = await prisma.projectGroup.findFirst({ where: { uuid: groupUuid, companyUuid }, }); if (!group) return null; + const accessible = await getAccessibleProjectUuids(auth); const projects = await prisma.project.findMany({ - where: { groupUuid, companyUuid }, + where: applyProjectFilter({ groupUuid, companyUuid }, accessible, "uuid"), select: { uuid: true, name: true, description: true }, orderBy: { updatedAt: "desc" }, }); @@ -190,6 +259,9 @@ export async function getProjectGroup( name: group.name, description: group.description, projectCount: projects.length, + visibility: group.visibility as "shared" | "private", + ownerType: (group.ownerType as "user" | "agent" | null) ?? null, + ownerUuid: group.ownerUuid ?? null, projects, createdAt: group.createdAt.toISOString(), updatedAt: group.updatedAt.toISOString(), @@ -197,20 +269,38 @@ export async function getProjectGroup( } export async function listProjectGroups( - companyUuid: string + companyUuid: string, + auth: AnyAuth ): Promise<{ groups: ProjectGroupResponse[]; total: number; ungroupedCount: number }> { - const groups = await prisma.projectGroup.findMany({ + const allGroups = await prisma.projectGroup.findMany({ where: { companyUuid }, orderBy: { createdAt: "asc" }, }); - // Batch count projects per group + // Visibility gate: keep only groups the actor may see — shared groups, groups + // they OWN, or groups they are a member of (super_admin => ALL). A freshly + // created empty group is still returned to its creator because they are its + // owner; another user's PRIVATE group is filtered out. The accessible-GROUP + // set (not the accessible-project set) is authoritative here. + const accessibleGroups = await getAccessibleGroupUuids(auth); + const groups = + accessibleGroups === ALL_PROJECTS + ? allGroups + : allGroups.filter((g) => accessibleGroups.includes(g.uuid)); + + const accessible = await getAccessibleProjectUuids(auth); + + // Batch count ACCESSIBLE projects per group. const groupUuids = groups.map((g) => g.uuid); const projectCounts = groupUuids.length > 0 ? await prisma.project.groupBy({ by: ["groupUuid"], - where: { companyUuid, groupUuid: { in: groupUuids } }, + where: applyProjectFilter( + { companyUuid, groupUuid: { in: groupUuids } }, + accessible, + "uuid" + ), _count: { _all: true }, }) : []; @@ -219,21 +309,27 @@ export async function listProjectGroups( projectCounts.map((pc) => [pc.groupUuid, pc._count._all]) ); + // The list is already visibility-filtered above (accessibleGroups). The + // per-group projectCount still reflects only the projects the actor can + // access within each visible group. const result: ProjectGroupResponse[] = groups.map((g) => ({ uuid: g.uuid, name: g.name, description: g.description, projectCount: countMap.get(g.uuid) ?? 0, + visibility: g.visibility as "shared" | "private", + ownerType: (g.ownerType as "user" | "agent" | null) ?? null, + ownerUuid: g.ownerUuid ?? null, createdAt: g.createdAt.toISOString(), updatedAt: g.updatedAt.toISOString(), })); - // Count ungrouped projects + // Count ungrouped projects the actor can access. const ungroupedCount = await prisma.project.count({ - where: { companyUuid, groupUuid: null }, + where: applyProjectFilter({ companyUuid, groupUuid: null }, accessible, "uuid"), }); - return { groups: result, total: groups.length, ungroupedCount }; + return { groups: result, total: result.length, ungroupedCount }; } // ============================================================ @@ -285,16 +381,37 @@ export async function moveProjectToGroup( export async function getGroupDashboard( companyUuid: string, - groupUuid: string + groupUuid: string, + auth: AnyAuth ): Promise { + // Inaccessible group => looks like it does not exist. + if (!(await canAccessGroup(auth, groupUuid))) return null; + const group = await prisma.projectGroup.findFirst({ where: { uuid: groupUuid, companyUuid }, }); if (!group) return null; - // Get all projects in this group + // The actor "owns" the group for UI purposes iff they manage it OR could claim + // it (null-owner legacy group they can access). Shows manage controls without + // mutating on read — the real claim happens server-side on a manage action. + const isOwner = await canManageOrClaimableGroup(auth, groupUuid); + const groupInfo = { + uuid: group.uuid, + name: group.name, + description: group.description, + visibility: group.visibility as "shared" | "private", + ownerType: (group.ownerType as "user" | "agent" | null) ?? null, + ownerUuid: group.ownerUuid ?? null, + isOwner, + }; + + // Get all ACCESSIBLE projects in this group. All downstream stats derive from + // this list, so filtering here cascades the visibility boundary through the + // entire dashboard. + const accessible = await getAccessibleProjectUuids(auth); const projects = await prisma.project.findMany({ - where: { groupUuid, companyUuid }, + where: applyProjectFilter({ groupUuid, companyUuid }, accessible, "uuid"), select: { uuid: true, name: true }, }); @@ -302,7 +419,7 @@ export async function getGroupDashboard( if (projectUuids.length === 0) { return { - group: { uuid: group.uuid, name: group.name, description: group.description }, + group: groupInfo, stats: { projectCount: 0, totalTasks: 0, @@ -390,7 +507,7 @@ export async function getGroupDashboard( const projectNameMap = new Map(projects.map((p) => [p.uuid, p.name])); return { - group: { uuid: group.uuid, name: group.name, description: group.description }, + group: groupInfo, stats: { projectCount: projects.length, totalTasks, @@ -415,3 +532,176 @@ export async function getGroupDashboard( })), }; } + +// ============================================================ +// Visibility & Membership (mirrors project.service) +// ============================================================ + +export interface ProjectGroupMemberResponse { + uuid: string; + memberType: "user" | "agent"; + memberUuid: string; + /** Resolved display name for the member (null if unresolvable). */ + name: string | null; + role: string; + createdAt: string; +} + +// Set a group's visibility ("shared" | "private"). Scoped by companyUuid. +// Returns null if the group does not exist within the company. +export async function setGroupVisibility( + companyUuid: string, + groupUuid: string, + visibility: "shared" | "private", +) { + const group = await prisma.projectGroup.findFirst({ + where: { uuid: groupUuid, companyUuid }, + select: { uuid: true }, + }); + if (!group) return null; + + const updated = await prisma.projectGroup.update({ + where: { uuid: group.uuid }, + data: { visibility }, + select: { uuid: true, visibility: true }, + }); + + eventBus.emitChange({ + companyUuid, + projectUuid: "", + entityType: "project_group", + entityUuid: groupUuid, + action: "updated", + }); + + return updated; +} + +// List members of a group. Scoped by companyUuid. +export async function listGroupMembers( + companyUuid: string, + groupUuid: string, +): Promise { + const members = await prisma.projectGroupMember.findMany({ + where: { companyUuid, projectGroupUuid: groupUuid }, + orderBy: { createdAt: "asc" }, + select: { + uuid: true, + memberType: true, + memberUuid: true, + role: true, + createdAt: true, + }, + }); + return Promise.all( + members.map(async (m) => ({ + uuid: m.uuid, + memberType: m.memberType as "user" | "agent", + memberUuid: m.memberUuid, + name: await getActorName(m.memberType, m.memberUuid), + role: m.role, + createdAt: m.createdAt.toISOString(), + })), + ); +} + +// Add a member (user or agent) to a group. Idempotent on the unique key. +export async function addGroupMember( + companyUuid: string, + groupUuid: string, + memberType: "user" | "agent", + memberUuid: string, +): Promise { + const group = await prisma.projectGroup.findFirst({ + where: { uuid: groupUuid, companyUuid }, + select: { uuid: true }, + }); + if (!group) return null; + + const existing = await prisma.projectGroupMember.findUnique({ + where: { + projectGroupUuid_memberType_memberUuid: { + projectGroupUuid: groupUuid, + memberType, + memberUuid, + }, + }, + select: { uuid: true, memberType: true, memberUuid: true, role: true, createdAt: true }, + }); + + const member = + existing ?? + (await prisma.projectGroupMember.create({ + data: { companyUuid, projectGroupUuid: groupUuid, memberType, memberUuid }, + select: { uuid: true, memberType: true, memberUuid: true, role: true, createdAt: true }, + })); + + eventBus.emitChange({ + companyUuid, + projectUuid: "", + entityType: "project_group", + entityUuid: groupUuid, + action: "updated", + }); + + return { + uuid: member.uuid, + memberType: member.memberType as "user" | "agent", + memberUuid: member.memberUuid, + name: await getActorName(member.memberType, member.memberUuid), + role: member.role, + createdAt: member.createdAt.toISOString(), + }; +} + +// Remove a member from a group. Returns false if the group or member is not +// found. The owner cannot be removed (they retain access via ownership). +export async function removeGroupMember( + companyUuid: string, + groupUuid: string, + memberType: "user" | "agent", + memberUuid: string, +): Promise { + const group = await prisma.projectGroup.findFirst({ + where: { uuid: groupUuid, companyUuid }, + select: { uuid: true, ownerType: true, ownerUuid: true }, + }); + if (!group) return false; + + // Do not remove the owner's membership row. + if (group.ownerType === memberType && group.ownerUuid === memberUuid) { + return false; + } + + const existing = await prisma.projectGroupMember.findUnique({ + where: { + projectGroupUuid_memberType_memberUuid: { + projectGroupUuid: groupUuid, + memberType, + memberUuid, + }, + }, + select: { id: true }, + }); + if (!existing) return false; + + await prisma.projectGroupMember.delete({ + where: { + projectGroupUuid_memberType_memberUuid: { + projectGroupUuid: groupUuid, + memberType, + memberUuid, + }, + }, + }); + + eventBus.emitChange({ + companyUuid, + projectUuid: "", + entityType: "project_group", + entityUuid: groupUuid, + action: "updated", + }); + + return true; +} diff --git a/src/services/project.service.ts b/src/services/project.service.ts index 6bf84c8e..84dce523 100644 --- a/src/services/project.service.ts +++ b/src/services/project.service.ts @@ -4,11 +4,20 @@ import { prisma } from "@/lib/prisma"; import { eventBus } from "@/lib/event-bus"; +import { getActorName } from "@/lib/uuid-resolver"; +import { + type AnyAuth, + getAccessibleProjectUuids, + canAccessProject, + applyProjectFilter, +} from "@/lib/authz/project-access"; export interface ProjectListParams { companyUuid: string; skip: number; take: number; + /** Auth context used to restrict results to projects the actor can access. */ + auth: AnyAuth; } export interface ProjectCreateParams { @@ -16,6 +25,13 @@ export interface ProjectCreateParams { name: string; description?: string | null; groupUuid?: string | null; + /** Visibility for the new project (default "private"). */ + visibility?: "shared" | "private"; + /** Owner of the project (the acting human or agent). */ + ownerType?: "user" | "agent" | null; + ownerUuid?: string | null; + /** Initial members (besides the owner, who is always added). */ + memberUuids?: { memberType: "user" | "agent"; memberUuid: string }[]; } export interface ProjectUpdateParams { @@ -23,11 +39,15 @@ export interface ProjectUpdateParams { description?: string | null; } -// List projects query -export async function listProjects({ companyUuid, skip, take }: ProjectListParams) { +// List projects query — restricted to the projects the actor can access. +export async function listProjects({ companyUuid, skip, take, auth }: ProjectListParams) { + const accessible = await getAccessibleProjectUuids(auth); + // Filter the Project table by its own `uuid` column against the accessible set. + const where = applyProjectFilter({ companyUuid }, accessible, "uuid"); + const [projects, total] = await Promise.all([ prisma.project.findMany({ - where: { companyUuid }, + where, skip, take, orderBy: { updatedAt: "desc" }, @@ -36,6 +56,9 @@ export async function listProjects({ companyUuid, skip, take }: ProjectListParam name: true, description: true, groupUuid: true, + visibility: true, + ownerType: true, + ownerUuid: true, createdAt: true, updatedAt: true, _count: { @@ -48,14 +71,15 @@ export async function listProjects({ companyUuid, skip, take }: ProjectListParam }, }, }), - prisma.project.count({ where: { companyUuid } }), + prisma.project.count({ where }), ]); return { projects, total }; } -// Get project details -export async function getProject(companyUuid: string, uuid: string) { +// Get project details — null if the actor cannot access it. +export async function getProject(companyUuid: string, uuid: string, auth: AnyAuth) { + if (!(await canAccessProject(auth, uuid))) return null; return prisma.project.findFirst({ where: { uuid, companyUuid }, select: { @@ -63,6 +87,9 @@ export async function getProject(companyUuid: string, uuid: string) { name: true, description: true, groupUuid: true, + visibility: true, + ownerType: true, + ownerUuid: true, createdAt: true, updatedAt: true, _count: { @@ -78,8 +105,9 @@ export async function getProject(companyUuid: string, uuid: string) { }); } -// Verify if project exists -export async function projectExists(companyUuid: string, projectUuid: string): Promise { +// Verify if project exists AND is accessible to the actor. +export async function projectExists(companyUuid: string, projectUuid: string, auth: AnyAuth): Promise { + if (!(await canAccessProject(auth, projectUuid))) return false; const project = await prisma.project.findFirst({ where: { uuid: projectUuid, companyUuid }, select: { uuid: true }, @@ -87,8 +115,9 @@ export async function projectExists(companyUuid: string, projectUuid: string): P return !!project; } -// Get basic project info by UUID -export async function getProjectByUuid(companyUuid: string, uuid: string) { +// Get basic project info by UUID — null if inaccessible. +export async function getProjectByUuid(companyUuid: string, uuid: string, auth: AnyAuth) { + if (!(await canAccessProject(auth, uuid))) return null; return prisma.project.findFirst({ where: { uuid, companyUuid }, select: { uuid: true, name: true }, @@ -107,20 +136,76 @@ export async function getProjectUuidsByGroup(companyUuid: string, groupUuid: str return projects.map((p) => p.uuid); } -// Create project -export async function createProject({ companyUuid, name, description, groupUuid }: ProjectCreateParams) { +// Create project. Records owner + visibility (default private) and seeds a +// ProjectMember row for the owner so the owner is always a member. +export async function createProject({ + companyUuid, + name, + description, + groupUuid, + visibility, + ownerType = null, + ownerUuid = null, + memberUuids = [], +}: ProjectCreateParams) { + // Resolve the default visibility. When the caller does NOT pass visibility + // explicitly and the project is being created inside a group, inherit the + // group's visibility (so a project added to a shared group is shared by + // default). Otherwise default to "private". An explicit visibility always wins. + let effectiveVisibility: "shared" | "private" = visibility ?? "private"; + if (visibility === undefined && groupUuid) { + const group = await prisma.projectGroup.findFirst({ + where: { uuid: groupUuid, companyUuid }, + select: { visibility: true }, + }); + if (group?.visibility === "shared" || group?.visibility === "private") { + effectiveVisibility = group.visibility; + } + } + const project = await prisma.project.create({ - data: { companyUuid, name, description, groupUuid: groupUuid ?? null }, + data: { + companyUuid, + name, + description, + groupUuid: groupUuid ?? null, + visibility: effectiveVisibility, + ownerType, + ownerUuid, + }, select: { uuid: true, name: true, description: true, groupUuid: true, + visibility: true, + ownerType: true, + ownerUuid: true, createdAt: true, updatedAt: true, }, }); + // Seed members: the owner (if any) plus any explicitly provided members, + // de-duplicated on (memberType, memberUuid). + const seed = new Map(); + if (ownerType && ownerUuid) { + seed.set(`${ownerType}:${ownerUuid}`, { memberType: ownerType, memberUuid: ownerUuid }); + } + for (const m of memberUuids) { + seed.set(`${m.memberType}:${m.memberUuid}`, m); + } + if (seed.size > 0) { + await prisma.projectMember.createMany({ + data: [...seed.values()].map((m) => ({ + companyUuid, + projectUuid: project.uuid, + memberType: m.memberType, + memberUuid: m.memberUuid, + })), + }); + } + eventBus.emitChange({ companyUuid, projectUuid: project.uuid, @@ -175,13 +260,20 @@ export async function deleteProject(companyUuid: string, uuid: string) { return true; } -// Get company-level overview stats (for Projects list page) -export async function getCompanyOverviewStats(companyUuid: string) { +// Get company-level overview stats (for Projects list page) — counts restricted +// to projects the actor can access. +export async function getCompanyOverviewStats(companyUuid: string, auth: AnyAuth) { + const accessible = await getAccessibleProjectUuids(auth); + const projectWhere = applyProjectFilter({ companyUuid }, accessible, "uuid"); + // Child-entity counts filter by the accessible projectUuid set. + const childWhere = applyProjectFilter({ companyUuid }, accessible); + const proposalWhere = applyProjectFilter({ companyUuid, status: "pending" }, accessible); + const [projectCount, taskCount, openProposalCount, ideaCount] = await Promise.all([ - prisma.project.count({ where: { companyUuid } }), - prisma.task.count({ where: { companyUuid } }), - prisma.proposal.count({ where: { companyUuid, status: "pending" } }), - prisma.idea.count({ where: { companyUuid } }), + prisma.project.count({ where: projectWhere }), + prisma.task.count({ where: childWhere }), + prisma.proposal.count({ where: proposalWhere }), + prisma.idea.count({ where: childWhere }), ]); return { @@ -193,8 +285,8 @@ export async function getCompanyOverviewStats(companyUuid: string) { } // Get project list with task completion stats (for Projects list page) -export async function listProjectsWithStats({ companyUuid, skip, take }: ProjectListParams) { - const { projects, total } = await listProjects({ companyUuid, skip, take }); +export async function listProjectsWithStats({ companyUuid, skip, take, auth }: ProjectListParams) { + const { projects, total } = await listProjects({ companyUuid, skip, take, auth }); // Batch query completed task count for each project const projectUuids = projects.map((p) => p.uuid); @@ -214,8 +306,9 @@ export async function listProjectsWithStats({ companyUuid, skip, take }: Project }; } -// Get project statistics (for Dashboard) -export async function getProjectStats(companyUuid: string, projectUuid: string) { +// Get project statistics (for Dashboard) — null if the actor cannot access it. +export async function getProjectStats(companyUuid: string, projectUuid: string, auth: AnyAuth) { + if (!(await canAccessProject(auth, projectUuid))) return null; const [ideasStats, tasksStats, proposalsStats, documentsCount] = await Promise.all([ // Ideas stats prisma.idea.groupBy({ @@ -266,3 +359,164 @@ export async function getProjectStats(companyUuid: string, projectUuid: string) documents: { total: documentsCount }, }; } + +// ============================================================ +// Visibility & Membership +// ============================================================ + +export interface ProjectMemberResponse { + uuid: string; + memberType: "user" | "agent"; + memberUuid: string; + /** Resolved display name for the member (null if unresolvable). */ + name: string | null; + role: string; + createdAt: string; +} + +// Set a project's visibility ("shared" | "private"). Scoped by companyUuid. +// Returns null if the project does not exist within the company. +export async function setProjectVisibility( + companyUuid: string, + projectUuid: string, + visibility: "shared" | "private", +) { + const project = await prisma.project.findFirst({ + where: { uuid: projectUuid, companyUuid }, + select: { uuid: true }, + }); + if (!project) return null; + + const updated = await prisma.project.update({ + where: { uuid: project.uuid }, + data: { visibility }, + select: { uuid: true, visibility: true }, + }); + + eventBus.emitChange({ + companyUuid, + projectUuid, + entityType: "project", + entityUuid: projectUuid, + action: "updated", + }); + + return updated; +} + +// List members of a project. Scoped by companyUuid. +export async function listProjectMembers( + companyUuid: string, + projectUuid: string, +): Promise { + const members = await prisma.projectMember.findMany({ + where: { companyUuid, projectUuid }, + orderBy: { createdAt: "asc" }, + select: { + uuid: true, + memberType: true, + memberUuid: true, + role: true, + createdAt: true, + }, + }); + return Promise.all( + members.map(async (m) => ({ + uuid: m.uuid, + memberType: m.memberType as "user" | "agent", + memberUuid: m.memberUuid, + name: await getActorName(m.memberType, m.memberUuid), + role: m.role, + createdAt: m.createdAt.toISOString(), + })), + ); +} + +// Add a member (user or agent) to a project. Idempotent on the unique key. +export async function addProjectMember( + companyUuid: string, + projectUuid: string, + memberType: "user" | "agent", + memberUuid: string, +): Promise { + const project = await prisma.project.findFirst({ + where: { uuid: projectUuid, companyUuid }, + select: { uuid: true }, + }); + if (!project) return null; + + const existing = await prisma.projectMember.findUnique({ + where: { + projectUuid_memberType_memberUuid: { projectUuid, memberType, memberUuid }, + }, + select: { uuid: true, memberType: true, memberUuid: true, role: true, createdAt: true }, + }); + + const member = + existing ?? + (await prisma.projectMember.create({ + data: { companyUuid, projectUuid, memberType, memberUuid }, + select: { uuid: true, memberType: true, memberUuid: true, role: true, createdAt: true }, + })); + + eventBus.emitChange({ + companyUuid, + projectUuid, + entityType: "project", + entityUuid: projectUuid, + action: "updated", + }); + + return { + uuid: member.uuid, + memberType: member.memberType as "user" | "agent", + memberUuid: member.memberUuid, + name: await getActorName(member.memberType, member.memberUuid), + role: member.role, + createdAt: member.createdAt.toISOString(), + }; +} + +// Remove a member from a project. Returns false if the project or member is +// not found. The owner cannot be removed (they retain access via ownership). +export async function removeProjectMember( + companyUuid: string, + projectUuid: string, + memberType: "user" | "agent", + memberUuid: string, +): Promise { + const project = await prisma.project.findFirst({ + where: { uuid: projectUuid, companyUuid }, + select: { uuid: true, ownerType: true, ownerUuid: true }, + }); + if (!project) return false; + + // Do not remove the owner's membership row. + if (project.ownerType === memberType && project.ownerUuid === memberUuid) { + return false; + } + + const existing = await prisma.projectMember.findUnique({ + where: { + projectUuid_memberType_memberUuid: { projectUuid, memberType, memberUuid }, + }, + select: { id: true }, + }); + if (!existing) return false; + + await prisma.projectMember.delete({ + where: { + projectUuid_memberType_memberUuid: { projectUuid, memberType, memberUuid }, + }, + }); + + eventBus.emitChange({ + companyUuid, + projectUuid, + entityType: "project", + entityUuid: projectUuid, + action: "updated", + }); + + return true; +} diff --git a/src/services/proposal.service.ts b/src/services/proposal.service.ts index aa8d0d90..18ada49c 100644 --- a/src/services/proposal.service.ts +++ b/src/services/proposal.service.ts @@ -15,6 +15,10 @@ import { hasNonEmptyAcceptanceCriteria, normalizeAcceptanceCriteria, } from "@/lib/acceptance-criteria"; +import { + type AnyAuth, + canAccessProject, +} from "@/lib/authz/project-access"; // ===== UUID Helper Functions ===== @@ -42,6 +46,8 @@ export interface ProposalListParams { skip: number; take: number; status?: string; + /** Auth context used to restrict results to projects the actor can access. */ + auth: AnyAuth; } // Document draft type (with UUID for tracking and modification) @@ -233,7 +239,8 @@ export interface ValidationResult { // Validate Proposal completeness before submission export async function validateProposal( companyUuid: string, - proposalUuid: string + proposalUuid: string, + auth: AnyAuth ): Promise { const proposal = await prisma.proposal.findFirst({ where: { uuid: proposalUuid, companyUuid }, @@ -242,6 +249,9 @@ export async function validateProposal( if (!proposal) { throw new Error("Proposal not found"); } + if (!(await canAccessProject(auth, proposal.projectUuid))) { + throw new Error("Proposal not found"); + } const issues: ValidationIssue[] = []; const documentDrafts = (proposal.documentDrafts as unknown as DocumentDraft[]) || []; @@ -519,7 +529,13 @@ export async function listProposals({ skip, take, status, + auth, }: ProposalListParams): Promise<{ proposals: ProposalResponse[]; total: number }> { + // Visibility gate: a non-member of this project sees nothing. + if (!(await canAccessProject(auth, projectUuid))) { + return { proposals: [], total: 0 }; + } + const where = { projectUuid, companyUuid, @@ -562,7 +578,8 @@ export async function listProposals({ // Get Proposal details export async function getProposal( companyUuid: string, - uuid: string + uuid: string, + auth: AnyAuth ): Promise { const proposal = await prisma.proposal.findFirst({ where: { uuid, companyUuid }, @@ -572,6 +589,8 @@ export async function getProposal( }); if (!proposal) return null; + // Visibility gate: hide proposals in projects the actor cannot access. + if (!(await canAccessProject(auth, proposal.projectUuid))) return null; return formatProposalResponse(proposal); } @@ -607,9 +626,10 @@ export function toTaskDraftIndex(draft: TaskDraft): TaskDraftIndexEntry { export async function getProposalSection( companyUuid: string, uuid: string, - section: ProposalSection + section: ProposalSection, + auth: AnyAuth ): Promise { - const proposal = await getProposal(companyUuid, uuid); + const proposal = await getProposal(companyUuid, uuid, auth); if (!proposal) return null; // Split metadata from the heavy draft arrays. @@ -644,8 +664,14 @@ export async function getProposalByUuid(companyUuid: string, uuid: string) { // Create Proposal (container) export async function createProposal( - params: ProposalCreateParams + params: ProposalCreateParams, + auth: AnyAuth ): Promise { + // Visibility gate: cannot create a proposal in an inaccessible project. + if (!(await canAccessProject(auth, params.projectUuid))) { + throw new Error("Project not found"); + } + // Ensure all drafts have UUIDs (frontend may still pass drafts at creation time) const documentDraftsWithUuids = params.documentDrafts?.map(ensureDocumentDraftUuid); const taskDraftsWithUuids = params.taskDrafts?.map(ensureTaskDraftUuid); @@ -697,8 +723,14 @@ export async function updateProposalContent( description?: string | null; documentDrafts?: DocumentDraft[] | null; taskDrafts?: TaskDraft[] | null; - } + }, + auth: AnyAuth ): Promise { + // Visibility gate: resolve the proposal's project and reject non-members. + const target = await prisma.proposal.findFirst({ where: { uuid: proposalUuid, companyUuid }, select: { projectUuid: true } }); + if (!target) throw new Error("Proposal not found"); + if (!(await canAccessProject(auth, target.projectUuid))) throw new Error("Proposal not found"); + // Build update data with proper JSON null handling const updateData: Prisma.ProposalUpdateInput = {}; @@ -746,7 +778,8 @@ export async function approveProposal( proposalUuid: string, companyUuid: string, reviewedByUuid: string, - reviewNote?: string | null + reviewNote: string | null | undefined, + auth: AnyAuth ): Promise { const proposal = await prisma.proposal.findFirst({ where: { uuid: proposalUuid, companyUuid }, @@ -755,6 +788,9 @@ export async function approveProposal( if (!proposal) { throw new Error("Proposal not found"); } + if (!(await canAccessProject(auth, proposal.projectUuid))) { + throw new Error("Proposal not found"); + } // Start transaction const { updatedProposal, materializedTasks, materializedDocuments } = await prisma.$transaction(async (tx) => { @@ -906,7 +942,8 @@ export async function revokeProposal( proposalUuid: string, companyUuid: string, revokedByUuid: string, - reviewNote?: string + reviewNote: string | undefined, + auth: AnyAuth ): Promise { // 1. Validate proposal exists, belongs to company, status === 'approved' const proposal = await prisma.proposal.findFirst({ @@ -916,6 +953,9 @@ export async function revokeProposal( if (!proposal) { throw new Error("Proposal not found"); } + if (!(await canAccessProject(auth, proposal.projectUuid))) { + throw new Error("Proposal not found"); + } if (proposal.status !== "approved") { throw new Error("Only approved proposals can be revoked"); @@ -993,8 +1033,14 @@ export async function revokeProposal( export async function rejectProposal( proposalUuid: string, reviewedByUuid: string, - reviewNote: string + reviewNote: string, + auth: AnyAuth ): Promise { + // Visibility gate: resolve the proposal's project and reject non-members. + const target = await prisma.proposal.findUnique({ where: { uuid: proposalUuid }, select: { projectUuid: true } }); + if (!target) throw new Error("Proposal not found"); + if (!(await canAccessProject(auth, target.projectUuid))) throw new Error("Proposal not found"); + const proposal = await prisma.proposal.update({ where: { uuid: proposalUuid }, data: { @@ -1017,8 +1063,14 @@ export async function rejectProposal( export async function closeProposal( proposalUuid: string, closedByUuid: string, - reviewNote: string + reviewNote: string, + auth: AnyAuth ): Promise { + // Visibility gate: resolve the proposal's project and reject non-members. + const target = await prisma.proposal.findUnique({ where: { uuid: proposalUuid }, select: { projectUuid: true } }); + if (!target) throw new Error("Proposal not found"); + if (!(await canAccessProject(auth, target.projectUuid))) throw new Error("Proposal not found"); + const proposal = await prisma.proposal.update({ where: { uuid: proposalUuid }, data: { @@ -1040,7 +1092,8 @@ export async function closeProposal( // Delete Proposal (only draft or closed) export async function deleteProposal( proposalUuid: string, - companyUuid: string + companyUuid: string, + auth: AnyAuth ): Promise { const proposal = await prisma.proposal.findFirst({ where: { uuid: proposalUuid, companyUuid }, @@ -1049,6 +1102,9 @@ export async function deleteProposal( if (!proposal) { throw new Error("Proposal not found"); } + if (!(await canAccessProject(auth, proposal.projectUuid))) { + throw new Error("Proposal not found"); + } await prisma.proposal.delete({ where: { uuid: proposalUuid } }); @@ -1060,7 +1116,8 @@ export async function deleteProposal( // Submit Proposal for review (draft -> pending) export async function submitProposal( proposalUuid: string, - companyUuid: string + companyUuid: string, + auth: AnyAuth ): Promise { const proposal = await prisma.proposal.findFirst({ where: { uuid: proposalUuid, companyUuid }, @@ -1069,13 +1126,16 @@ export async function submitProposal( if (!proposal) { throw new Error("Proposal not found"); } + if (!(await canAccessProject(auth, proposal.projectUuid))) { + throw new Error("Proposal not found"); + } if (proposal.status !== "draft") { throw new Error("Only draft proposals can be submitted for review"); } // Run full validation (includes elaboration gate E5 and all other checks) - const validation = await validateProposal(companyUuid, proposalUuid); + const validation = await validateProposal(companyUuid, proposalUuid, auth); if (!validation.valid) { const lines = validation.issues.map( (i) => `[${i.level}] ${i.message}` @@ -1102,7 +1162,8 @@ export async function submitProposal( export async function addDocumentDraft( proposalUuid: string, companyUuid: string, - draft: Omit & { uuid?: string } + draft: Omit & { uuid?: string }, + auth: AnyAuth ): Promise { const proposal = await prisma.proposal.findFirst({ where: { uuid: proposalUuid, companyUuid, status: "draft" }, @@ -1111,6 +1172,9 @@ export async function addDocumentDraft( if (!proposal) { throw new Error("Proposal not found or not in draft status"); } + if (!(await canAccessProject(auth, proposal.projectUuid))) { + throw new Error("Proposal not found or not in draft status"); + } const existingDrafts = (proposal.documentDrafts as unknown as DocumentDraft[]) || []; const newDraft = ensureDocumentDraftUuid(draft); @@ -1134,7 +1198,8 @@ export async function addDocumentDraft( export async function addTaskDraft( proposalUuid: string, companyUuid: string, - draft: Omit & { uuid?: string } + draft: Omit & { uuid?: string }, + auth: AnyAuth ): Promise { const proposal = await prisma.proposal.findFirst({ where: { uuid: proposalUuid, companyUuid, status: "draft" }, @@ -1143,6 +1208,9 @@ export async function addTaskDraft( if (!proposal) { throw new Error("Proposal not found or not in draft status"); } + if (!(await canAccessProject(auth, proposal.projectUuid))) { + throw new Error("Proposal not found or not in draft status"); + } // Acceptance criteria are mandatory on creation: a task draft must carry at // least one criterion with a non-blank description. @@ -1176,7 +1244,8 @@ export async function updateDocumentDraft( proposalUuid: string, companyUuid: string, draftUuid: string, - updates: Partial> + updates: Partial>, + auth: AnyAuth ): Promise { const proposal = await prisma.proposal.findFirst({ where: { uuid: proposalUuid, companyUuid, status: "draft" }, @@ -1185,6 +1254,9 @@ export async function updateDocumentDraft( if (!proposal) { throw new Error("Proposal not found or not in draft status"); } + if (!(await canAccessProject(auth, proposal.projectUuid))) { + throw new Error("Proposal not found or not in draft status"); + } const existingDrafts = (proposal.documentDrafts as unknown as DocumentDraft[]) || []; const draftIndex = existingDrafts.findIndex(d => d.uuid === draftUuid); @@ -1214,7 +1286,8 @@ export async function updateTaskDraft( proposalUuid: string, companyUuid: string, draftUuid: string, - updates: Partial> + updates: Partial>, + auth: AnyAuth ): Promise { const proposal = await prisma.proposal.findFirst({ where: { uuid: proposalUuid, companyUuid, status: "draft" }, @@ -1223,6 +1296,9 @@ export async function updateTaskDraft( if (!proposal) { throw new Error("Proposal not found or not in draft status"); } + if (!(await canAccessProject(auth, proposal.projectUuid))) { + throw new Error("Proposal not found or not in draft status"); + } const existingDrafts = (proposal.taskDrafts as unknown as TaskDraft[]) || []; const draftIndex = existingDrafts.findIndex(d => d.uuid === draftUuid); @@ -1262,7 +1338,8 @@ export async function updateTaskDraft( export async function removeDocumentDraft( proposalUuid: string, companyUuid: string, - draftUuid: string + draftUuid: string, + auth: AnyAuth ): Promise { const proposal = await prisma.proposal.findFirst({ where: { uuid: proposalUuid, companyUuid, status: "draft" }, @@ -1271,6 +1348,9 @@ export async function removeDocumentDraft( if (!proposal) { throw new Error("Proposal not found or not in draft status"); } + if (!(await canAccessProject(auth, proposal.projectUuid))) { + throw new Error("Proposal not found or not in draft status"); + } const existingDrafts = (proposal.documentDrafts as unknown as DocumentDraft[]) || []; const updatedDrafts = existingDrafts.filter(d => d.uuid !== draftUuid); @@ -1295,7 +1375,8 @@ export async function removeDocumentDraft( export async function removeTaskDraft( proposalUuid: string, companyUuid: string, - draftUuid: string + draftUuid: string, + auth: AnyAuth ): Promise { const proposal = await prisma.proposal.findFirst({ where: { uuid: proposalUuid, companyUuid, status: "draft" }, @@ -1304,6 +1385,9 @@ export async function removeTaskDraft( if (!proposal) { throw new Error("Proposal not found or not in draft status"); } + if (!(await canAccessProject(auth, proposal.projectUuid))) { + throw new Error("Proposal not found or not in draft status"); + } const existingDrafts = (proposal.taskDrafts as unknown as TaskDraft[]) || []; const updatedDrafts = existingDrafts @@ -1333,7 +1417,11 @@ export async function removeTaskDraft( export async function getProjectProposals( companyUuid: string, projectUuid: string, + auth: AnyAuth, ): Promise> { + // Visibility gate: a non-member of this project sees no proposals. + if (!(await canAccessProject(auth, projectUuid))) return []; + const proposals = await prisma.proposal.findMany({ where: { companyUuid, projectUuid, status: "approved" }, select: { diff --git a/src/services/search.service.ts b/src/services/search.service.ts index d738a097..07d06630 100644 --- a/src/services/search.service.ts +++ b/src/services/search.service.ts @@ -3,6 +3,11 @@ // UUID-Based Architecture: All operations use UUIDs import { prisma } from "@/lib/prisma"; +import { + type AnyAuth, + ALL_PROJECTS, + getAccessibleProjectUuids, +} from "@/lib/authz/project-access"; // ===== Type Definitions ===== @@ -16,6 +21,8 @@ export interface SearchParams { scopeUuid?: string; // project group UUID or project UUID entityTypes?: EntityType[]; limit?: number; + /** Auth context used to restrict results to projects the actor can access. */ + auth: AnyAuth; } export interface SearchResult { @@ -447,6 +454,19 @@ export async function search(params: SearchParams): Promise { groupUuid = scopeUuid; } + // Visibility gate: intersect the scope-resolved project set with the actor's + // accessible projects so entities/projects in inaccessible private projects + // never surface. Super admins (ALL_PROJECTS) bypass the intersection. + // project_group results are unaffected (groups are containers, not projects). + const accessible = await getAccessibleProjectUuids(params.auth); + if (accessible !== ALL_PROJECTS) { + const accessibleSet = new Set(accessible); + projectUuids = + projectUuids === null + ? accessible + : projectUuids.filter((u) => accessibleSet.has(u)); + } + // Execute searches in parallel const searchPromises: Promise<{ results: SearchResult[]; count: number }>[] = []; const typeOrder: EntityType[] = []; diff --git a/src/services/session.service.ts b/src/services/session.service.ts index 160820b0..7ec31823 100644 --- a/src/services/session.service.ts +++ b/src/services/session.service.ts @@ -280,7 +280,7 @@ export async function sessionCheckinToTask( companyUuid, assigneeType: "agent", assigneeUuid: session.agentUuid, - }); + }, { type: "agent", companyUuid, actorUuid: session.agentUuid }); } catch { // Claim may fail if task was concurrently claimed — safe to ignore } diff --git a/src/services/task.service.ts b/src/services/task.service.ts index df4bdb0a..65076401 100644 --- a/src/services/task.service.ts +++ b/src/services/task.service.ts @@ -16,6 +16,10 @@ import { normalizeAcceptanceCriteria, type AcceptanceCriteriaItemInput, } from "@/lib/acceptance-criteria"; +import { + type AnyAuth, + canAccessProject, +} from "@/lib/authz/project-access"; // ===== Type Definitions ===== @@ -27,6 +31,8 @@ export interface TaskListParams { status?: string; priority?: string; proposalUuids?: string[]; + /** Auth context used to restrict results to projects the actor can access. */ + auth: AnyAuth; } export interface TaskCreateParams { @@ -410,7 +416,13 @@ export async function listTasks({ status, priority, proposalUuids, + auth, }: TaskListParams): Promise<{ tasks: TaskResponse[]; total: number }> { + // Visibility gate: a non-member of this project sees nothing. + if (!(await canAccessProject(auth, projectUuid))) { + return { tasks: [], total: 0 }; + } + const where = { projectUuid, companyUuid, @@ -462,7 +474,8 @@ export async function listTasks({ // Get Task details export async function getTask( companyUuid: string, - uuid: string + uuid: string, + auth: AnyAuth ): Promise { const task = await prisma.task.findFirst({ where: { uuid, companyUuid }, @@ -473,6 +486,8 @@ export async function getTask( }); if (!task) return null; + // Visibility gate: hide tasks in projects the actor cannot access. + if (!(await canAccessProject(auth, task.projectUuid))) return null; const commentCount = await prisma.comment.count({ where: { companyUuid, targetType: "task", targetUuid: uuid }, @@ -489,7 +504,12 @@ export async function getTaskByUuid(companyUuid: string, uuid: string) { } // Create Task -export async function createTask(params: TaskCreateParams): Promise { +export async function createTask(params: TaskCreateParams, auth: AnyAuth): Promise { + // Visibility gate: cannot create a task in an inaccessible project. + if (!(await canAccessProject(auth, params.projectUuid))) { + throw new Error("Project not found"); + } + const task = await prisma.task.create({ data: { companyUuid: params.companyUuid, @@ -531,8 +551,14 @@ export async function createTask(params: TaskCreateParams): Promise { + // Visibility gate: resolve the task's project and reject non-members. + const target = await prisma.task.findUnique({ where: { uuid }, select: { projectUuid: true } }); + if (!target) throw new Error("Task not found"); + if (!(await canAccessProject(auth, target.projectUuid))) throw new Error("Task not found"); + // If description is being updated and we have actor context, capture old description for mention diffing let oldDescription: string | null = null; if (data.description !== undefined && actorContext) { @@ -600,7 +626,12 @@ export async function claimTask({ assigneeType, assigneeUuid, assignedByUuid, -}: TaskClaimParams): Promise { +}: TaskClaimParams, auth: AnyAuth): Promise { + // Visibility gate: resolve the task's project and reject non-members. + const target = await prisma.task.findFirst({ where: { uuid: taskUuid, companyUuid }, select: { projectUuid: true } }); + if (!target) throw new AlreadyClaimedError("Task"); + if (!(await canAccessProject(auth, target.projectUuid))) throw new AlreadyClaimedError("Task"); + try { const task = await prisma.task.update({ where: { uuid: taskUuid, status: { in: ["open", "assigned"] } }, @@ -628,7 +659,12 @@ export async function claimTask({ } // Release Task (atomic: only succeeds if status is "assigned") -export async function releaseTask(uuid: string): Promise { +export async function releaseTask(uuid: string, auth: AnyAuth): Promise { + // Visibility gate: resolve the task's project and reject non-members. + const target = await prisma.task.findUnique({ where: { uuid }, select: { projectUuid: true } }); + if (!target) throw new NotClaimedError("Task"); + if (!(await canAccessProject(auth, target.projectUuid))) throw new NotClaimedError("Task"); + try { const task = await prisma.task.update({ where: { uuid, status: "assigned" }, @@ -656,7 +692,11 @@ export async function releaseTask(uuid: string): Promise { } // Delete Task -export async function deleteTask(uuid: string) { +export async function deleteTask(uuid: string, auth: AnyAuth) { + // Visibility gate: resolve project before deleting; reject non-members. + const target = await prisma.task.findUnique({ where: { uuid }, select: { projectUuid: true } }); + if (!target) throw new Error("Task not found"); + if (!(await canAccessProject(auth, target.projectUuid))) throw new Error("Task not found"); const task = await prisma.task.delete({ where: { uuid } }); eventBus.emitChange({ companyUuid: task.companyUuid, projectUuid: task.projectUuid, entityType: "task", entityUuid: task.uuid, action: "deleted" }); return task; @@ -756,9 +796,11 @@ export async function replaceAcceptanceCriteria( companyUuid: string, taskUuid: string, items: AcceptanceCriteriaItemInput[], + access: AnyAuth, ): Promise { const task = await prisma.task.findFirst({ where: { uuid: taskUuid, companyUuid } }); if (!task) throw new Error("Task not found"); + if (!(await canAccessProject(access, task.projectUuid))) throw new Error("Task not found"); if (!hasNonEmptyAcceptanceCriteria(items)) { throw new Error(ACCEPTANCE_CRITERIA_REQUIRED_MESSAGE); @@ -790,10 +832,12 @@ export async function markAcceptanceCriteria( taskUuid: string, criteria: Array<{ uuid: string; status: "passed" | "failed"; evidence?: string }>, auth: { type: string; actorUuid: string }, + access: AnyAuth, ): Promise<{ items: AcceptanceCriterionResponse[]; status: string; summary: AcceptanceSummary }> { // Validate task belongs to company const task = await prisma.task.findFirst({ where: { uuid: taskUuid, companyUuid } }); if (!task) throw new Error("Task not found"); + if (!(await canAccessProject(access, task.projectUuid))) throw new Error("Task not found"); // Pre-validate all criterion UUIDs belong to this task const validUuids = new Set( @@ -821,7 +865,7 @@ export async function markAcceptanceCriteria( eventBus.emitChange({ companyUuid, projectUuid: task.projectUuid, entityType: "task", entityUuid: taskUuid, action: "updated" }); // Return updated state - return getAcceptanceStatus(companyUuid, taskUuid); + return getAcceptanceStatus(companyUuid, taskUuid, access); } // Dev agent reports self-check on acceptance criteria @@ -830,10 +874,12 @@ export async function reportCriteriaSelfCheck( taskUuid: string, criteria: Array<{ uuid: string; devStatus: "passed" | "failed"; devEvidence?: string }>, auth: { type: string; actorUuid: string }, + access: AnyAuth, ): Promise<{ items: AcceptanceCriterionResponse[]; status: string; summary: AcceptanceSummary }> { // Validate task belongs to company const task = await prisma.task.findFirst({ where: { uuid: taskUuid, companyUuid } }); if (!task) throw new Error("Task not found"); + if (!(await canAccessProject(access, task.projectUuid))) throw new Error("Task not found"); // Pre-validate all criterion UUIDs belong to this task const validUuids = new Set( @@ -861,7 +907,7 @@ export async function reportCriteriaSelfCheck( eventBus.emitChange({ companyUuid, projectUuid: task.projectUuid, entityType: "task", entityUuid: taskUuid, action: "updated" }); // Return updated state - return getAcceptanceStatus(companyUuid, taskUuid); + return getAcceptanceStatus(companyUuid, taskUuid, access); } // Reset a single acceptance criterion back to pending (admin/user undo) @@ -869,9 +915,11 @@ export async function resetAcceptanceCriterion( companyUuid: string, taskUuid: string, criterionUuid: string, + access: AnyAuth, ): Promise { const task = await prisma.task.findFirst({ where: { uuid: taskUuid, companyUuid } }); if (!task) throw new Error("Task not found"); + if (!(await canAccessProject(access, task.projectUuid))) throw new Error("Task not found"); // Validate criterion belongs to this task const criterion = await prisma.acceptanceCriterion.findFirst({ where: { uuid: criterionUuid, taskUuid } }); @@ -895,10 +943,12 @@ export async function resetAcceptanceCriterion( export async function getAcceptanceStatus( companyUuid: string, taskUuid: string, + access: AnyAuth, ): Promise<{ items: AcceptanceCriterionResponse[]; status: string; summary: AcceptanceSummary }> { // Validate task belongs to company const task = await prisma.task.findFirst({ where: { uuid: taskUuid, companyUuid } }); if (!task) throw new Error("Task not found"); + if (!(await canAccessProject(access, task.projectUuid))) throw new Error("Task not found"); const rows = await prisma.acceptanceCriterion.findMany({ where: { taskUuid }, @@ -1057,7 +1107,8 @@ async function wouldCreateCycle( export async function addTaskDependency( companyUuid: string, taskUuid: string, - dependsOnUuid: string + dependsOnUuid: string, + auth: AnyAuth ): Promise<{ taskUuid: string; dependsOnUuid: string; createdAt: Date }> { // Cannot depend on itself if (taskUuid === dependsOnUuid) { @@ -1072,6 +1123,8 @@ export async function addTaskDependency( if (!task) throw new Error("Task not found"); if (!dependsOnTask) throw new Error("Dependency task not found"); + // Visibility gate: reject non-members of the task's project. + if (!(await canAccessProject(auth, task.projectUuid))) throw new Error("Task not found"); if (task.projectUuid !== dependsOnTask.projectUuid) { throw new Error("Tasks must belong to the same project"); @@ -1095,11 +1148,13 @@ export async function addTaskDependency( export async function removeTaskDependency( companyUuid: string, taskUuid: string, - dependsOnUuid: string + dependsOnUuid: string, + auth: AnyAuth ): Promise { // Verify task belongs to this company const task = await prisma.task.findFirst({ where: { uuid: taskUuid, companyUuid } }); if (!task) throw new Error("Task not found"); + if (!(await canAccessProject(auth, task.projectUuid))) throw new Error("Task not found"); await prisma.taskDependency.deleteMany({ where: { taskUuid, dependsOnUuid }, @@ -1109,7 +1164,8 @@ export async function removeTaskDependency( // Get task dependencies export async function getTaskDependencies( companyUuid: string, - taskUuid: string + taskUuid: string, + auth: AnyAuth ): Promise<{ dependsOn: TaskDependencyInfo[]; dependedBy: TaskDependencyInfo[] }> { const task = await prisma.task.findFirst({ where: { uuid: taskUuid, companyUuid }, @@ -1117,6 +1173,7 @@ export async function getTaskDependencies( }); if (!task) throw new Error("Task not found"); + if (!(await canAccessProject(auth, task.projectUuid))) throw new Error("Task not found"); return { dependsOn: task.dependsOn.map((d) => ({ @@ -1137,11 +1194,18 @@ export async function getUnblockedTasks({ companyUuid, projectUuid, proposalUuids, + auth, }: { companyUuid: string; projectUuid: string; proposalUuids?: string[]; + auth: AnyAuth; }): Promise<{ tasks: TaskResponse[]; total: number }> { + // Visibility gate: a non-member of this project sees nothing. + if (!(await canAccessProject(auth, projectUuid))) { + return { tasks: [], total: 0 }; + } + const where = { projectUuid, companyUuid, @@ -1289,11 +1353,17 @@ export async function checkDependenciesResolved( // Get all task dependencies within a project (for DAG visualization) export async function getProjectTaskDependencies( companyUuid: string, - projectUuid: string + projectUuid: string, + auth: AnyAuth ): Promise<{ nodes: Array<{ uuid: string; title: string; status: string; priority: string; proposalUuid: string | null }>; edges: Array<{ from: string; to: string }>; }> { + // Visibility gate: a non-member of this project sees an empty graph. + if (!(await canAccessProject(auth, projectUuid))) { + return { nodes: [], edges: [] }; + } + const [tasks, dependencies] = await Promise.all([ prisma.task.findMany({ where: { companyUuid, projectUuid },