diff --git a/docs/features/org-onboarding/plan.md b/docs/features/org-onboarding/plan.md new file mode 100644 index 00000000..d6a9278b --- /dev/null +++ b/docs/features/org-onboarding/plan.md @@ -0,0 +1,173 @@ +# Org Onboarding — API Implementation Plan + +**Goal:** Let a SuperAdmin create a new organization and invite its first Org Manager without developer intervention, then let that Org Manager add further Org Managers / Project Managers from the Users page (fluent-web #489). + +**Companion plan (web side):** `fluent-web/docs/features/org-onboarding/plan.md` +**Downstream plan:** `fluent-web/docs/features/org-manager-users-page/plan.md` (#489) + +**Tech Stack:** Hono + `@hono/zod-openapi`, Drizzle, Vitest + +--- + +## Current state (audited 2026-09-16) + +- `organizations` table: `id`, `name` (unique, ≤100), timestamps. No domain module — only `src/domains/organizations/users/org-users.route.ts` (`DELETE /organizations/{orgId}/users/{userId}`). +- No list / create / read org endpoints anywhere. The only org-creating code path is the zero-org "solo workflow" inside `POST /projects` (`projects.route.ts:157-218`), which provisions `"'s Organization"` and grants the caller Org Member + Org Manager. +- `requireSuperAdmin` middleware exists (`src/middlewares/role-auth.ts:112`) — global grant (`orgId=null, projectId=null`) holding `role:assign:org_manager`. `requirePermission(perm, () => ({}))` also resolves to global scope only (`isGrantApplicable` line 17). +- `POST /users/invite` already: + - detects existing Fluent account vs. new user (`users.route.ts:247-284`), + - for a new user: creates auth identity + user row + Org Member anchor + role grant, sends a magic-link email to `/accept-invitation` (201), + - for an existing user: adds anchor + role grant to the org, sends a login-link email (200), + - authorizes via `requirePermission(USER_CREATE, orgFromBody)` + `canAssignRole(caller, roleName, orgId, projectId)`. + A SuperAdmin's global grant satisfies both for `roleName: 'Org Manager', projectId: null` (`authorize.test.ts:148-150` already asserts `canAssignRole(superAdmin, ORG_MANAGER, ORG, null) === true`). **No invite changes are needed for the SuperAdmin flow.** +- `GET /users` returns _all_ users for SuperAdmin — not per-org — so the web org-detail page needs an org-scoped member list. +- RBAC gaps that block #489 (Org Manager self-service): Org Manager lacks `ROLE_ASSIGN_ORG_MANAGER`; `PATCH /users/:id` ignores `role`; no org-level role-change endpoint; Project Manager only exists as a project-pinned grant. +- Dev seeds (`src/db/seeds/dev-users.ts`) contain no SuperAdmin and no Org Manager. + +## Sequencing + +``` +API-1 Org endpoints + seeds ──► WEB-1 SuperAdmin Organizations pages + Invite Org Manager +API-2 #489 prerequisites ──► WEB #489 Users page org roles +``` + +API-1 and API-2 are independent of each other. API-1 unblocks the whole onboarding flow and should go first. + +--- + +## Ticket API-1: Organizations endpoints + SuperAdmin/Org Manager dev seeds + +### Task 1: Permissions + +**Files:** `src/lib/permissions.ts`, `src/db/seeds/rbac.ts`, `src/db/seeds/rbac.test.ts` (if present) / `src/lib/services/permissions/authorize.test.ts` + +- [ ] Add `ORG_VIEW: 'org:view'` and `ORG_CREATE: 'org:create'` under a new `// ── Organizations` block in `PERMISSIONS`. (`authorize.ts:14` already uses `org:create` as the canonical example of an org-scoped permission.) +- [ ] Add both to `PERMISSION_DEFINITIONS` in `rbac.ts`. SuperAdmin picks them up automatically via the `Object.values(PERMISSIONS)` spread. Do **not** grant them to Org Manager in this ticket. +- [ ] Test: a global SuperAdmin grant authorizes `ORG_CREATE` at scope `{}`; an org-scoped Org Manager grant with every org-manager permission does not. + +Deploy note: `seedRbac()` is idempotent; re-run `pnpm db:seed:rbac` (or the setup script) in each environment after deploy. + +### Task 2: Organizations domain module + +**Files (create):** + +- `src/domains/organizations/organizations.types.ts` +- `src/domains/organizations/organizations.repository.ts` +- `src/domains/organizations/organizations.service.ts` +- `src/domains/organizations/organizations.service.test.ts` +- `src/domains/organizations/organizations.route.ts` + +**Files (modify):** `src/app.ts` (register route import next to `org-users.route`) + +**Interfaces:** + +```ts +// organizations.types.ts +export const createOrganizationRequestSchema = z.object({ + name: z.string().trim().min(1).max(100), +}); +export const organizationResponseSchema = z.object({ + id: z.number().int(), + name: z.string(), + createdAt: z.string().datetime().nullable(), +}); +export const organizationSummarySchema = organizationResponseSchema.extend({ + orgManagerCount: z.number().int(), // distinct users with an Org Manager grant, projectId IS NULL +}); +``` + +``` +GET /organizations requireSuperAdmin (or requirePermission(ORG_VIEW, () => ({}))) + 200 organizationSummarySchema[] ordered by name +POST /organizations requirePermission(ORG_CREATE, () => ({})) + 201 organizationResponseSchema | 409 duplicate name | 422 validation +GET /organizations/{orgId} requirePermission(ORG_VIEW, () => ({})) + 200 organizationSummarySchema | 404 +``` + +Use `requirePermission(..., () => ({}))` rather than `requireSuperAdmin` so a future "Org Manager can read own org" only needs a scope-resolver change. Follow the `createRoute` + `server.openapi` shape and `jsonContent` / `createMessageObjectSchema` responses used in `org-users.route.ts`. + +**Steps:** + +- [ ] Failing service tests: `createOrganization` returns `CONFLICT` on duplicate name (mock repo → `handleConstraintError` path); `listOrganizations` returns summaries sorted by name with counts computed from `user_roles`; `getOrganization` returns `NOT_FOUND` for missing id. +- [ ] Repository: `findAllWithCounts()`, `findByIdWithCounts(id)`, `insert({ name })`. `orgManagerCount` via a single `LEFT JOIN user_roles JOIN roles` grouped query with `countDistinct(user_roles.userId)` filtered to `roles.name = 'Org Manager' AND user_roles.projectId IS NULL`. +- [ ] Service wraps repository with `Result`; route maps errors via `getHttpStatus`. +- [ ] Register in `src/app.ts`. + +Verify: `pnpm test src/domains/organizations`, `pnpm typecheck`, `pnpm lint`. Check `/reference` (OpenAPI) renders the new tag `Organizations`. + +### Task 3: Org-scoped member list + +**Files:** `src/domains/organizations/users/org-users.route.ts`, `src/domains/users/users.service.ts` (+ test) + +``` +GET /organizations/{orgId}/users authenticateUser, requirePermission(USER_VIEW, orgId-from-param) + 200 userResponseSchema[] (orgGrants filtered to orgId) | 404 org missing +``` + +- [ ] Add `getUsersInOrg(orgId)` to `users.service.ts`: `repo.findByOrganizations([orgId])` + `findRoleGrantsByUserIds(ids, [orgId])`, same shape as `getUsersForUser` output. Extract the shared mapping so both callers use it. +- [ ] Test: a user with grants in orgs 1 and 2 → only org-1 grants appear when listing org 1. +- [ ] Scope resolver reads `orgId` from the path param (same inline resolver as the DELETE route above it). Both SuperAdmin (global grant) and Org Manager (org-scoped `USER_VIEW`) pass; a Project Manager pinned to a project also has `USER_VIEW` but its grant is project-pinned, so `isGrantApplicable` rejects it at org scope — add that as a test case. + +### Task 4: Invite Org Manager — verification only + +No code change expected. Add a route-level or middleware-level test proving the contract the web relies on: + +- [ ] `requireUserAccess(USER_ACTIONS.CREATE)` with body `{ orgId, projectId: null, roleName: 'Org Manager' }` passes for a global SuperAdmin grant and fails (403) for a project-pinned Project Manager. +- [ ] Document in the route description of `POST /users/invite` that 201 = new Fluent account created (magic link sent), 200 = existing account added to org (login link sent). The web uses the status to word its toast. +- [ ] Check the existing-user email template (`sendExistingUserOrgInviteEmail`) reads correctly when the role is Org Manager and there is no project. Adjust copy if it assumes a project. + +### Task 5: Dev seeds + +**Files:** `src/db/seeds/dev-users.ts` + +- [ ] Add `super_admin` (global SuperAdmin grant, `orgId: null, projectId: null`) and `org_manager` (Org Member anchor + Org Manager grant in the dev org) seed users, following the existing PM pattern and password reconciliation logic. These are what QA and local dev use for both WEB-1 and #489. + +### Out of scope (note in ticket) + +- `PATCH /organizations/{orgId}` (rename) and `DELETE /organizations/{orgId}`. +- The zero-org "solo workflow" in `POST /projects` is **not** retired — it is the path for a solo user who registers, logs in and works without an admin, and will be refined in a later phase. API-1 must not change its behaviour. Having it call the new organizations service (one provisioning path) is a candidate for that later refinement. + +--- + +## Ticket API-2: Prerequisites for fluent-web #489 (Org Manager self-service on the Users page) + +Full task detail, including tests, lives in `fluent-web/docs/features/org-manager-users-page/plan.md` Phase A. Summary: + +### Task 1: Org Manager may assign Org Manager + +- [ ] `rbac.ts`: add `{ roleName: ROLES.ORG_MANAGER, permissionName: PERMISSIONS.ROLE_ASSIGN_ORG_MANAGER }`. +- [ ] `authorize.test.ts`: org-scoped Org Manager can `canAssignRole(…, ORG_MANAGER, ORG, null)`; still cannot assign `SuperAdmin`. +- [ ] Update the comment in `requireSuperAdmin` (`role-auth.ts:121-123`): the permission is no longer SuperAdmin-exclusive, but the check still holds because it also requires a _global_ grant. Add a test that an Org Manager with the new permission is still rejected by `requireSuperAdmin`. + +### Task 2: Org-level role change + +``` +PATCH /organizations/{orgId}/users/{userId} body { roleName: 'Org Manager' | 'Project Manager'* } +200 userResponseSchema | 400 role not allowed at org level | 403 self-change or !canAssignRole | 404 not a member +``` + +\* `Project Manager` only if decision D1 in the #489 plan lands on "org-level PM". + +- [ ] Service `updateOrgUserRole(callerId, orgId, userId, roleName)` in `src/domains/organizations/users/org-users.service.ts`: forbid `callerId === userId`; in a transaction delete `user_roles` rows `(userId, orgId, projectId IS NULL, role ≠ Org Member)` then insert the new grant; return `usersService.getUserById(userId)`. +- [ ] Route in `org-users.route.ts`, mirroring `PATCH /projects/{projectId}/users/{userId}`. +- [ ] Tests: self-change → 403; non-member → 404; anchor + project-scoped grants preserved; idempotent when unchanged. + +### Task 3 (conditional on D1): Org-level Project Manager grant + +- [ ] `canAssignRole` branch 4: `PROJECT_MANAGER` with `projectId === null` requires `ROLE_ASSIGN_PROJECT` at org scope. Translator/Observer remain project-only. +- [ ] Revisit the TEMP bypass in `projects.route.ts:90-104`. + +### Task 4: Optional cleanup + +- [ ] `PATCH /users/:id` handler (`users.route.ts:465-475`) strips `role` after the zod schema has already dropped it — dead code. Remove or add `role` to the schema deliberately (recommend remove; role changes go through the org/project endpoints). + +--- + +## Verification summary + +| Ticket | Command | +| ------ | ------------------------------------------------------------------------------------------------------------------- | +| API-1 | `pnpm test src/domains/organizations src/domains/users src/lib/services/permissions && pnpm typecheck && pnpm lint` | +| API-2 | `pnpm test src/domains/organizations src/lib/services/permissions src/middlewares && pnpm typecheck && pnpm lint` | +| both | re-run RBAC seed in the target environment after deploy | diff --git a/docs/features/org-onboarding/tickets/2026-09-16-org-manager-self-service-prerequisites.md b/docs/features/org-onboarding/tickets/2026-09-16-org-manager-self-service-prerequisites.md new file mode 100644 index 00000000..8d5f41f4 --- /dev/null +++ b/docs/features/org-onboarding/tickets/2026-09-16-org-manager-self-service-prerequisites.md @@ -0,0 +1,79 @@ +# Org Manager self-service prerequisites for fluent-web #489 + +> **Status: NOT STARTED** — awaiting go-ahead to implement, and Product decision D1 (see below). +> GitHub: [fluent-api#337](https://github.com/eten-tech-foundation/fluent-api/issues/337) + +**Parent feature:** [`org-onboarding`](../plan.md) — Ticket API-2. Task-level detail with tests also in `fluent-web/docs/features/org-manager-users-page/plan.md` Phase A. +**Repo:** `fluent-api`. +**Blocks:** fluent-web#489 (Users page org roles). +**Related:** #336 — independent, but ships the seeded `org_manager` account this ticket needs for QA. + +## Problem + +fluent-web#489 lets an Org Manager add/edit Org Managers and Project Managers from the Users page. Three API gaps make that fail or silently no-op: + +1. `canAssignRole()` requires `ROLE_ASSIGN_ORG_MANAGER` for target role `Org Manager`; only SuperAdmin holds it (`rbac.ts:29-41`). An Org Manager inviting an Org Manager gets **403**. +2. `PATCH /users/:id` ignores `role` — not in `updateUserRequestSchema`, and stripped again in `users.repository.update()`. The Edit User dialog "saves" a role that never changes. No org-level role-change endpoint exists; only `PATCH /projects/{projectId}/users/{userId}`. +3. `Project Manager` exists only as a project-pinned grant; `canAssignRole` returns `false` for PM with `projectId === null` (`authorize.ts:82-90`). "Org-scoped Project Manager" as written in #489 is not in the model. + +## Product decision D1 (open) + +How should "Project Manager" behave on the org-level Users page? + +- **(a) Org-level PM grant** — allow `Project Manager` with `projectId = null`. Matches #489's wording. `grant-utils.isProjectManager()` on the web already treats a null-project manager grant as managing every project in the org. New RBAC concept; touches `canAssignRole` and the TEMP bypass in `projects.route.ts:90-104`. +- **(b) Org Manager only** — the Users page offers only `Org Manager`; PMs stay per-project via Add Project User. Deviates from #489. + +Task 3 below exists only under (a). + +## Tasks + +### 1. Org Manager may assign the Org Manager role + +Files: `src/db/seeds/rbac.ts`, `src/lib/services/permissions/authorize.test.ts`, `src/middlewares/role-auth.ts` (+ test) + +- [ ] Add `{ roleName: ROLES.ORG_MANAGER, permissionName: PERMISSIONS.ROLE_ASSIGN_ORG_MANAGER }` to the Org Manager block. +- [ ] Tests: org-scoped Org Manager → `canAssignRole(…, ORG_MANAGER, ORG, null) === true`; same caller cannot assign `SuperAdmin`. +- [ ] `requireSuperAdmin` (`role-auth.ts:121-123`) comments call this permission SuperAdmin-exclusive; it no longer is. The check still holds because it also requires a _global_ grant. Fix the comment and add a test that an Org Manager holding the new permission is still rejected. + +### 2. Org-level role change endpoint + +Create: `src/domains/organizations/users/org-users.service.ts` (+ `.test.ts`), `org-users.types.ts`. Modify: `org-users.route.ts`. + +``` +PATCH /organizations/{orgId}/users/{userId} body { roleName: 'Org Manager' | 'Project Manager'* } +middleware: authenticateUser, requireUserAccess(USER_ACTIONS.UPDATE, 'userId') +200 userResponseSchema (orgGrants refreshed) +400 roleName not allowed at org level +403 caller === target (self-change blocked, same as project route) OR !canAssignRole(caller, roleName, orgId, null) +404 target is not a member of orgId +``` + +\* only under D1 (a). + +- [ ] Failing service tests: self-change → `FORBIDDEN`; non-member → `USER_NOT_FOUND`; replaces the existing org-level non-anchor grant, keeps the `Org Member` anchor and all project-scoped grants; idempotent when unchanged. +- [ ] Service `updateOrgUserRole(callerId, orgId, userId, roleName)`: `getRoleId`; in one transaction delete `user_roles` where `(userId, orgId, projectId IS NULL, role ≠ Org Member)`, insert the new grant; return `usersService.getUserById(userId)`. +- [ ] Route mirrors `updateProjectUserRoleRoute` in `project-users.route.ts:185-240`; body schema `z.object({ roleName: z.enum([...allowedOrgRoles]) })`. +- [ ] Unit tests for `canAssignRole` at scope `{ orgId, projectId: null }` for each allowed role. + +### 3. Org-level Project Manager grant — only under D1 (a) + +Files: `src/lib/services/permissions/authorize.ts` (+ test), `src/domains/projects/projects.route.ts` + +- [ ] `canAssignRole` branch 4: `PROJECT_MANAGER && projectId === null` → require `ROLE_ASSIGN_PROJECT` at `{ orgId, projectId: null }`. Translator/Observer remain project-only. +- [ ] Tests: Org Manager can assign org-level PM; project-pinned PM cannot (grant not applicable at org scope); Translator/Observer at org scope still `false`. +- [ ] Revisit the TEMP bypass comment in `projects.route.ts:90-104` — an org-level PM satisfies the normal `authorize()` path. Keep the bypass for legacy project-pinned PMs or remove in a follow-up; document the choice. + +Under D1 (b): skip, and restrict Task 2's enum to `Org Manager`. + +### 4. Optional cleanup + +- [ ] `PATCH /users/:id` handler (`users.route.ts:465-475`) deletes `updates.role` after zod already dropped it — dead code. Remove it; role changes go through org/project endpoints. + +## Verification + +``` +pnpm test src/domains/organizations src/lib/services/permissions src/middlewares +pnpm typecheck && pnpm lint +``` + +After deploy, re-run the RBAC seed in each environment so existing Org Manager rows pick up the permission. diff --git a/docs/features/org-onboarding/tickets/2026-09-16-organizations-endpoints.md b/docs/features/org-onboarding/tickets/2026-09-16-organizations-endpoints.md new file mode 100644 index 00000000..5d9d15f8 --- /dev/null +++ b/docs/features/org-onboarding/tickets/2026-09-16-organizations-endpoints.md @@ -0,0 +1,97 @@ +# Organizations endpoints for SuperAdmin org onboarding + dev seeds + +> **Status: IMPLEMENTED** — PR [fluent-api#339](https://github.com/eten-tech-foundation/fluent-api/pull/339). +> GitHub: [fluent-api#336](https://github.com/eten-tech-foundation/fluent-api/issues/336) + +**Parent feature:** [`org-onboarding`](../plan.md) — Ticket API-1. +**Repo:** `fluent-api`. +**Unblocks:** fluent-web#492 (SuperAdmin Organizations pages). +**Blocked by:** nothing. + +## Problem + +A SuperAdmin must be able to create a new organization and invite its first Org Manager without a developer. The API has no list / create / read org endpoints and no org-scoped member list (`GET /users` returns _all_ users for a SuperAdmin). The only org-creating code is the zero-org "solo workflow" inside `POST /projects`, which serves solo users and is out of scope here. + +`POST /users/invite` already covers the invite step: it detects new vs. existing Fluent accounts, grants Org Member anchor + role, and emails a magic link (201) or login link (200). `canAssignRole(superAdmin, 'Org Manager', orgId, null)` is already `true` (`authorize.test.ts:148-150`). No invite changes are expected beyond tests and docs. + +## Scope decisions + +- Endpoints use `requirePermission(perm, () => ({}))` (global scope) rather than `requireSuperAdmin`, so a future "Org Manager reads own org" is a scope-resolver change only. +- List/read summary carries `orgManagerCount` only. No `memberCount`: the web list shows Name / Org Managers / Created and the detail page lists members directly. +- The zero-org solo workflow in `POST /projects` is **not** retired and its behaviour must not change in this ticket. It is the solo-user path and will be refined in a later phase. + +## Tasks + +### 1. Permissions + +Files: `src/lib/permissions.ts`, `src/db/seeds/rbac.ts`, `src/lib/services/permissions/authorize.test.ts` + +- [ ] Add `ORG_VIEW: 'org:view'` and `ORG_CREATE: 'org:create'` under a new `// ── Organizations` block. +- [ ] Add both to `PERMISSION_DEFINITIONS`. SuperAdmin gets them via the existing all-permissions spread. Do not grant to Org Manager. +- [ ] Test: global SuperAdmin grant authorizes `ORG_CREATE` at scope `{}`; an org-scoped Org Manager grant does not. + +### 2. Organizations domain module + +Create: `src/domains/organizations/organizations.{types,repository,service,route}.ts`, `organizations.service.test.ts`. Modify: `src/app.ts`. + +```ts +export const createOrganizationRequestSchema = z.object({ + name: z.string().trim().min(1).max(100), +}); +export const organizationResponseSchema = z.object({ + id: z.number().int(), + name: z.string(), + createdAt: z.string().datetime().nullable(), +}); +export const organizationSummarySchema = organizationResponseSchema.extend({ + orgManagerCount: z.number().int(), // distinct users with Org Manager grant, projectId IS NULL +}); +``` + +``` +GET /organizations requirePermission(ORG_VIEW, () => ({})) 200 summary[] ordered by name +POST /organizations requirePermission(ORG_CREATE, () => ({})) 201 org | 409 duplicate name | 422 validation +GET /organizations/{orgId} requirePermission(ORG_VIEW, () => ({})) 200 summary | 404 +``` + +- [ ] Failing service tests: duplicate name → `CONFLICT` (via `handleConstraintError`); list sorted by name with `orgManagerCount`; missing id → `NOT_FOUND`. +- [ ] Repository: `findAllWithCounts()`, `findByIdWithCounts(id)`, `insert({ name })`. Count via `LEFT JOIN user_roles JOIN roles`, `countDistinct(user_roles.userId)` filtered to `roles.name = 'Org Manager' AND user_roles.projectId IS NULL`. +- [ ] Service returns `Result`; route maps errors with `getHttpStatus`. Follow the `createRoute` + `server.openapi` + `jsonContent` shape in `org-users.route.ts`. +- [ ] Register the route import in `src/app.ts` next to `org-users.route`. + +### 3. Org-scoped member list + +Files: `src/domains/organizations/users/org-users.route.ts`, `src/domains/users/users.service.ts` (+ test) + +``` +GET /organizations/{orgId}/users requirePermission(USER_VIEW, orgId from path) 200 userResponseSchema[] | 404 +``` + +- [ ] `getUsersInOrg(orgId)` in `users.service.ts`: `repo.findByOrganizations([orgId])` + `findRoleGrantsByUserIds(ids, [orgId])`; extract the mapping shared with `getUsersForUser`. +- [ ] Tests: grants in orgs 1 and 2 → only org-1 grants returned for org 1; project-pinned Project Manager is rejected at org scope (its `USER_VIEW` grant is not applicable); SuperAdmin and org-scoped Org Manager pass. + +### 4. Invite Org Manager — verification only + +- [ ] Test `requireUserAccess(USER_ACTIONS.CREATE)` with body `{ orgId, projectId: null, roleName: 'Org Manager' }`: passes for a global SuperAdmin grant, 403 for a project-pinned PM. +- [ ] Route description of `POST /users/invite`: document 201 = new account created (magic link), 200 = existing account added to org (login link). +- [ ] Check `sendExistingUserOrgInviteEmail` copy reads correctly with no project context; adjust if it assumes one. + +### 5. Dev seeds + +File: `src/db/seeds/dev-users.ts` + +- [ ] Add `super_admin` (global grant, `orgId: null, projectId: null`) and `org_manager` (Org Member anchor + Org Manager grant in the dev org), following the PM pattern and password reconciliation. + +## Verification + +``` +pnpm test src/domains/organizations src/domains/users src/lib/services/permissions +pnpm typecheck && pnpm lint +``` + +Check `/reference` renders the `Organizations` tag. After deploy, re-run `pnpm db:seed:rbac` (or the setup script) in each environment. + +## Out of scope + +- `PATCH` / `DELETE /organizations/{orgId}`. +- Any change to the solo workflow in `POST /projects`. diff --git a/src/app.ts b/src/app.ts index e67ebdff..23bff3be 100644 --- a/src/app.ts +++ b/src/app.ts @@ -22,6 +22,7 @@ import '@/domains/usfm/usfm.route'; import '@/domains/book-details/book-details.route'; import '@/domains/chapter-assignments/editor-state/user-chapter-assignment-editor-state.route'; import '@/domains/projects/users/project-users.route'; +import '@/domains/organizations/organizations.route'; import '@/domains/organizations/users/org-users.route'; import '@/domains/users/projects/user-projects.route'; import '@/domains/chapter-assignments/presence/chapter-assignments-presence.route'; diff --git a/src/db/env-configs/local.ts b/src/db/env-configs/local.ts index 5c14117e..28c33dae 100644 --- a/src/db/env-configs/local.ts +++ b/src/db/env-configs/local.ts @@ -7,7 +7,7 @@ * `environment:` block in compose.yaml, so the container already has it. * * Credentials are intentionally plain / local-only defaults. - * Three seed users are created so a developer can exercise all role flows + * Five seed users are created so a developer can exercise all role flows * immediately without manual setup. */ import type { EnvConfig } from './types'; @@ -19,6 +19,18 @@ export const config: EnvConfig = { // No databaseUrl here — compose.yaml injects DATABASE_URL into the container. seedUsers: [ + { + email: 'sa@fluent.local', + password: 'sa@123456', + username: 'superadmin', + role: 'super_admin', + }, + { + email: 'om@fluent.local', + password: 'om@123456', + username: 'orgmanager', + role: 'org_manager', + }, { email: 'pm@fluent.local', password: 'pm@123456', diff --git a/src/db/env-configs/types.ts b/src/db/env-configs/types.ts index e35ef1af..a0750ab8 100644 --- a/src/db/env-configs/types.ts +++ b/src/db/env-configs/types.ts @@ -7,7 +7,7 @@ export interface SeedUser { email: string; password: string; username: string; - role: 'project_manager' | 'project_translator' | 'org_member'; + role: 'project_manager' | 'project_translator' | 'org_member' | 'super_admin' | 'org_manager'; } /** diff --git a/src/db/seeds/dev-users.ts b/src/db/seeds/dev-users.ts index bfc33484..c0bd58d0 100644 --- a/src/db/seeds/dev-users.ts +++ b/src/db/seeds/dev-users.ts @@ -14,22 +14,34 @@ export type { SeedUser }; /** Default users used when the seed is run standalone (CLI) without arguments. */ const DEFAULT_SEED_USERS: SeedUser[] = [ + { + email: process.env.SEED_SUPERADMIN_EMAIL ?? 'sa@fluent.local', + password: process.env.SEED_SUPERADMIN_PASSWORD ?? 'sa@123456', + username: 'Super Admin', + role: 'super_admin', + }, + { + email: process.env.SEED_ORG_MANAGER_EMAIL ?? 'om@fluent.local', + password: process.env.SEED_ORG_MANAGER_PASSWORD ?? 'om@123456', + username: 'Org Manager Dev', + role: 'org_manager', + }, { email: process.env.SEED_MANAGER_EMAIL ?? 'pm@fluent.local', password: process.env.SEED_MANAGER_PASSWORD ?? 'pm@123456', - username: 'devpm', + username: 'Project Manager Dev', role: 'project_manager', }, { email: process.env.SEED_TRANSLATOR_EMAIL ?? 't@fluent.local', password: process.env.SEED_TRANSLATOR_PASSWORD ?? 't@123456', - username: 'translator', + username: 'Translator Dev', role: 'project_translator', }, { email: process.env.SEED_TRANSLATOR2_EMAIL ?? 't2@fluent.local', password: process.env.SEED_TRANSLATOR2_PASSWORD ?? 't@123456', - username: 'translator2', + username: 'Translator 2 Dev', role: 'project_translator', }, ]; @@ -76,6 +88,16 @@ export async function seedDevUsers( throw new Error(`Role "${ROLES.PROJECT_MANAGER}" not found. Run seedRoles first.`); } + const superAdminRoleId = roleMap.get(ROLES.SUPER_ADMIN); + if (!superAdminRoleId && seedUsers.some((u) => u.role === 'super_admin')) { + throw new Error(`Role "${ROLES.SUPER_ADMIN}" not found. Run seedRoles first.`); + } + + const orgManagerRoleId = roleMap.get(ROLES.ORG_MANAGER); + if (!orgManagerRoleId && seedUsers.some((u) => u.role === 'org_manager')) { + throw new Error(`Role "${ROLES.ORG_MANAGER}" not found. Run seedRoles first.`); + } + // Seed PM first so we have a real actor id to use as createdBy for translators. const pmUsers = seedUsers.filter((u) => u.role === 'project_manager'); const otherUsers = seedUsers.filter((u) => u.role !== 'project_manager'); @@ -219,6 +241,36 @@ export async function seedDevUsers( const grantedRoleIds = new Set(existingGrants.map((g) => g.roleId)); + // SuperAdmin holds a single global grant (orgId NULL, projectId NULL) and + // is not a member of any org — skip the Org Member anchor entirely. + if (seedUser.role === 'super_admin' && superAdminRoleId) { + const [existingGlobalGrant] = await tx + .select({ roleId: user_roles.roleId }) + .from(user_roles) + .where( + and( + eq(user_roles.userId, appUserId), + isNull(user_roles.orgId), + isNull(user_roles.projectId), + eq(user_roles.roleId, superAdminRoleId) + ) + ) + .limit(1); + + if (!existingGlobalGrant) { + await tx.insert(user_roles).values({ + userId: appUserId, + orgId: null, + projectId: null, + roleId: superAdminRoleId, + createdBy: grantedBy, + createdAt: new Date(), + updatedAt: new Date(), + }); + } + return; + } + // Insert Org Member anchor role if missing. if (!grantedRoleIds.has(orgMemberRoleId)) { await tx.insert(user_roles).values({ @@ -244,6 +296,20 @@ export async function seedDevUsers( }); } } + + // Insert Org Manager role (org-scoped, projectId NULL) if designated and missing. + if (seedUser.role === 'org_manager' && orgManagerRoleId) { + if (!grantedRoleIds.has(orgManagerRoleId)) { + await tx.insert(user_roles).values({ + userId: appUserId, + orgId: defaultOrg.id, + roleId: orgManagerRoleId, + createdBy: grantedBy, + createdAt: new Date(), + updatedAt: new Date(), + }); + } + } }); } diff --git a/src/db/seeds/rbac.ts b/src/db/seeds/rbac.ts index 1ed5b932..2c46ef76 100644 --- a/src/db/seeds/rbac.ts +++ b/src/db/seeds/rbac.ts @@ -13,6 +13,8 @@ const PERMISSION_DEFINITIONS = [ { name: PERMISSIONS.CONTENT_VIEW, description: 'View chapter assignment content' }, { name: PERMISSIONS.CONTENT_ASSIGN, description: 'Assign chapter assignments' }, { name: PERMISSIONS.CONTENT_UPDATE, description: 'Update chapter assignment content' }, + { name: PERMISSIONS.ORG_VIEW, description: 'View organizations' }, + { name: PERMISSIONS.ORG_CREATE, description: 'Create new organizations' }, { name: PERMISSIONS.MEMBERSHIP_REVOKE, description: 'Revoke user memberships' }, { name: PERMISSIONS.ROLE_ASSIGN_PROJECT, description: 'Assign project-level roles' }, { name: PERMISSIONS.ROLE_ASSIGN_ORG_MANAGER, description: 'Assign org manager role' }, diff --git a/src/domains/organizations/organizations.repository.ts b/src/domains/organizations/organizations.repository.ts new file mode 100644 index 00000000..1bbcded4 --- /dev/null +++ b/src/domains/organizations/organizations.repository.ts @@ -0,0 +1,79 @@ +import { and, eq, isNull, sql } from 'drizzle-orm'; + +import type { Result } from '@/lib/types'; + +import { db } from '@/db'; +import { organizations, roles, user_roles } from '@/db/schema'; +import { handleConstraintError } from '@/lib/db-errors'; +import { logger } from '@/lib/logger'; +import { ROLES } from '@/lib/roles'; +import { err, ErrorCode, ok } from '@/lib/types'; + +import type { + CreateOrganizationInput, + OrganizationRecord, + OrganizationSummaryRecord, +} from './organizations.types'; + +function withManagerCounts() { + const orgManagerCount = + sql`count(DISTINCT ${user_roles.userId}) FILTER (WHERE ${roles.name} = ${ROLES.ORG_MANAGER})::int`.as( + 'orgManagerCount' + ); + + return db + .select({ + id: organizations.id, + name: organizations.name, + createdAt: organizations.createdAt, + orgManagerCount, + }) + .from(organizations) + .leftJoin(user_roles, and(eq(user_roles.orgId, organizations.id), isNull(user_roles.projectId))) + .leftJoin(roles, eq(roles.id, user_roles.roleId)); +} + +export async function findAllWithCounts(): Promise> { + try { + const rows = await withManagerCounts() + .groupBy(organizations.id, organizations.name, organizations.createdAt) + .orderBy(organizations.name); + return ok(rows); + } catch (error) { + logger.error({ cause: error, message: 'Failed to list organizations' }); + return err(ErrorCode.INTERNAL_ERROR); + } +} + +export async function findByIdWithCounts( + id: number +): Promise> { + try { + const [row] = await withManagerCounts() + .where(eq(organizations.id, id)) + .groupBy(organizations.id, organizations.name, organizations.createdAt) + .limit(1); + return ok(row ?? null); + } catch (error) { + logger.error({ + cause: error, + message: 'Failed to find organization', + context: { id }, + }); + return err(ErrorCode.INTERNAL_ERROR); + } +} + +export async function insert(input: CreateOrganizationInput): Promise> { + try { + const [org] = await db.insert(organizations).values({ name: input.name }).returning({ + id: organizations.id, + name: organizations.name, + createdAt: organizations.createdAt, + }); + if (!org) return err(ErrorCode.INTERNAL_ERROR); + return ok(org); + } catch (error) { + return handleConstraintError(error); + } +} diff --git a/src/domains/organizations/organizations.route.ts b/src/domains/organizations/organizations.route.ts new file mode 100644 index 00000000..c494eb1d --- /dev/null +++ b/src/domains/organizations/organizations.route.ts @@ -0,0 +1,160 @@ +import { createRoute, z } from '@hono/zod-openapi'; +import * as HttpStatusCodes from 'stoker/http-status-codes'; +import * as HttpStatusPhrases from 'stoker/http-status-phrases'; +import { jsonContent } from 'stoker/openapi/helpers'; +import { createMessageObjectSchema } from 'stoker/openapi/schemas'; + +import { PERMISSIONS } from '@/lib/permissions'; +import { getHttpStatus } from '@/lib/types'; +import { authenticateUser, requirePermission } from '@/middlewares/role-auth'; +import { server } from '@/server/server'; + +import * as organizationsService from './organizations.service'; +import { + createOrganizationRequestSchema, + organizationResponseSchema, + organizationSummarySchema, +} from './organizations.types'; + +const orgIdParamSchema = z.object({ + orgId: z.coerce + .number() + .int() + .positive() + .openapi({ + param: { name: 'orgId', in: 'path', required: true }, + example: 1, + }), +}); + +const validationErrorSchema = z.object({ + success: z.boolean(), + error: z.object({ + issues: z.array(z.object({ code: z.string(), path: z.array(z.string()), message: z.string() })), + name: z.string(), + }), +}); + +// ─── GET /organizations ─────────────────────────────────────────────────────── + +const listOrganizationsRoute = createRoute({ + tags: ['Organizations'], + method: 'get', + path: '/organizations', + middleware: [authenticateUser, requirePermission(PERMISSIONS.ORG_VIEW, () => ({}))] as const, + responses: { + [HttpStatusCodes.OK]: jsonContent( + organizationSummarySchema.array().openapi('Organizations'), + 'The list of organizations, ordered by name' + ), + [HttpStatusCodes.UNAUTHORIZED]: jsonContent( + createMessageObjectSchema('Unauthorized'), + 'Authentication required' + ), + [HttpStatusCodes.FORBIDDEN]: jsonContent( + createMessageObjectSchema('Forbidden'), + 'Insufficient permissions' + ), + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent( + createMessageObjectSchema(HttpStatusPhrases.INTERNAL_SERVER_ERROR), + 'Internal server error' + ), + }, + summary: 'List organizations', + description: + 'Returns all organizations ordered by name, each with a count of distinct Org Managers. SuperAdmin only.', +}); + +server.openapi(listOrganizationsRoute, async (c) => { + const result = await organizationsService.listOrganizations(); + if (result.ok) return c.json(result.data, HttpStatusCodes.OK); + + return c.json({ message: result.error.message }, getHttpStatus(result.error) as never); +}); + +// ─── POST /organizations ────────────────────────────────────────────────────── + +const createOrganizationRoute = createRoute({ + tags: ['Organizations'], + method: 'post', + path: '/organizations', + middleware: [authenticateUser, requirePermission(PERMISSIONS.ORG_CREATE, () => ({}))] as const, + request: { + body: jsonContent(createOrganizationRequestSchema, 'The organization to create'), + }, + responses: { + [HttpStatusCodes.CREATED]: jsonContent(organizationResponseSchema, 'The created organization'), + [HttpStatusCodes.CONFLICT]: jsonContent( + createMessageObjectSchema('Conflict'), + 'An organization with this name already exists' + ), + [HttpStatusCodes.UNAUTHORIZED]: jsonContent( + createMessageObjectSchema('Unauthorized'), + 'Authentication required' + ), + [HttpStatusCodes.FORBIDDEN]: jsonContent( + createMessageObjectSchema('Forbidden'), + 'Insufficient permissions' + ), + [HttpStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent( + validationErrorSchema, + 'The validation error' + ), + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent( + createMessageObjectSchema(HttpStatusPhrases.INTERNAL_SERVER_ERROR), + 'Internal server error' + ), + }, + summary: 'Create an organization', + description: + 'Creates a new organization. Invite its first Org Manager afterwards via POST /users/invite. SuperAdmin only.', +}); + +server.openapi(createOrganizationRoute, async (c) => { + const body = c.req.valid('json'); + + const result = await organizationsService.createOrganization(body); + if (result.ok) return c.json(result.data, HttpStatusCodes.CREATED); + + return c.json({ message: result.error.message }, getHttpStatus(result.error) as never); +}); + +// ─── GET /organizations/{orgId} ─────────────────────────────────────────────── + +const getOrganizationRoute = createRoute({ + tags: ['Organizations'], + method: 'get', + path: '/organizations/{orgId}', + middleware: [authenticateUser, requirePermission(PERMISSIONS.ORG_VIEW, () => ({}))] as const, + request: { params: orgIdParamSchema }, + responses: { + [HttpStatusCodes.OK]: jsonContent(organizationSummarySchema, 'The organization'), + [HttpStatusCodes.NOT_FOUND]: jsonContent( + createMessageObjectSchema(HttpStatusPhrases.NOT_FOUND), + 'Organization not found' + ), + [HttpStatusCodes.UNAUTHORIZED]: jsonContent( + createMessageObjectSchema('Unauthorized'), + 'Authentication required' + ), + [HttpStatusCodes.FORBIDDEN]: jsonContent( + createMessageObjectSchema('Forbidden'), + 'Insufficient permissions' + ), + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent( + createMessageObjectSchema(HttpStatusPhrases.INTERNAL_SERVER_ERROR), + 'Internal server error' + ), + }, + summary: 'Get an organization', + description: 'Returns a single organization with its Org Manager count. SuperAdmin only.', +}); + +server.openapi(getOrganizationRoute, async (c) => { + const { orgId } = c.req.valid('param'); + + const result = await organizationsService.getOrganization(orgId); + if (result.ok) return c.json(result.data, HttpStatusCodes.OK); + + return c.json({ message: result.error.message }, getHttpStatus(result.error) as never); +}); diff --git a/src/domains/organizations/organizations.service.test.ts b/src/domains/organizations/organizations.service.test.ts new file mode 100644 index 00000000..5d1fd49c --- /dev/null +++ b/src/domains/organizations/organizations.service.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ErrorCode } from '@/lib/types'; + +import * as repo from './organizations.repository'; +import { createOrganization, getOrganization, listOrganizations } from './organizations.service'; + +vi.mock('@/domains/organizations/organizations.repository', () => ({ + findAllWithCounts: vi.fn(), + findByIdWithCounts: vi.fn(), + insert: vi.fn(), +})); + +vi.mock('@/lib/logger', () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + info: vi.fn(), + }, +})); + +const summaryRow = (id: number, name: string, orgManagerCount: number) => ({ + id, + name, + createdAt: new Date('2026-09-16T12:00:00.000Z'), + orgManagerCount, +}); + +describe('organizations service', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('listOrganizations', () => { + it('returns summaries ordered by name with orgManagerCount', async () => { + vi.mocked(repo.findAllWithCounts).mockResolvedValue({ + ok: true, + data: [summaryRow(1, 'Alpha Org', 2), summaryRow(2, 'Beta Org', 0)], + }); + + const result = await listOrganizations(); + + expect(result).toEqual({ + ok: true, + data: [ + { + id: 1, + name: 'Alpha Org', + createdAt: '2026-09-16T12:00:00.000Z', + orgManagerCount: 2, + }, + { + id: 2, + name: 'Beta Org', + createdAt: '2026-09-16T12:00:00.000Z', + orgManagerCount: 0, + }, + ], + }); + }); + + it('propagates repository errors', async () => { + vi.mocked(repo.findAllWithCounts).mockResolvedValue({ + ok: false, + error: { code: ErrorCode.INTERNAL_ERROR, message: 'An unexpected error occurred' }, + }); + + const result = await listOrganizations(); + expect(result.ok).toBe(false); + }); + }); + + describe('getOrganization', () => { + it('returns the summary for an existing org', async () => { + vi.mocked(repo.findByIdWithCounts).mockResolvedValue({ + ok: true, + data: summaryRow(1, 'Alpha Org', 3), + }); + + const result = await getOrganization(1); + + expect(result).toEqual({ + ok: true, + data: { + id: 1, + name: 'Alpha Org', + createdAt: '2026-09-16T12:00:00.000Z', + orgManagerCount: 3, + }, + }); + }); + + it('returns NOT_FOUND for a missing org', async () => { + vi.mocked(repo.findByIdWithCounts).mockResolvedValue({ ok: true, data: null }); + + const result = await getOrganization(999); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe(ErrorCode.NOT_FOUND); + } + }); + }); + + describe('createOrganization', () => { + it('returns the created organization', async () => { + vi.mocked(repo.insert).mockResolvedValue({ + ok: true, + data: { id: 5, name: 'New Org', createdAt: new Date('2026-09-16T12:00:00.000Z') }, + }); + + const result = await createOrganization({ name: 'New Org' }); + + expect(result).toEqual({ + ok: true, + data: { id: 5, name: 'New Org', createdAt: '2026-09-16T12:00:00.000Z' }, + }); + }); + + it('maps a duplicate name to CONFLICT', async () => { + vi.mocked(repo.insert).mockResolvedValue({ + ok: false, + error: { code: ErrorCode.DUPLICATE, message: 'Resource already exists' }, + }); + + const result = await createOrganization({ name: 'Taken Name' }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe(ErrorCode.CONFLICT); + } + }); + }); +}); diff --git a/src/domains/organizations/organizations.service.ts b/src/domains/organizations/organizations.service.ts new file mode 100644 index 00000000..55113c46 --- /dev/null +++ b/src/domains/organizations/organizations.service.ts @@ -0,0 +1,58 @@ +import type { Result } from '@/lib/types'; + +import { err, ErrorCode, ok } from '@/lib/types'; + +import type { + CreateOrganizationInput, + OrganizationRecord, + OrganizationResponse, + OrganizationSummary, + OrganizationSummaryRecord, +} from './organizations.types'; + +import * as repo from './organizations.repository'; + +// ─── Response mappers ───────────────────────────────────────────────────────── + +function toOrganizationResponse(row: OrganizationRecord): OrganizationResponse { + return { + id: row.id, + name: row.name, + createdAt: row.createdAt ? row.createdAt.toISOString() : null, + }; +} + +function toOrganizationSummary(row: OrganizationSummaryRecord): OrganizationSummary { + return { + ...toOrganizationResponse(row), + orgManagerCount: row.orgManagerCount, + }; +} + +// ─── Reads ──────────────────────────────────────────────────────────────────── + +export async function listOrganizations(): Promise> { + const result = await repo.findAllWithCounts(); + if (!result.ok) return result; + return ok(result.data.map(toOrganizationSummary)); +} + +export async function getOrganization(id: number): Promise> { + const result = await repo.findByIdWithCounts(id); + if (!result.ok) return result; + if (!result.data) return err(ErrorCode.NOT_FOUND); + return ok(toOrganizationSummary(result.data)); +} + +// ─── Writes ─────────────────────────────────────────────────────────────────── + +export async function createOrganization( + input: CreateOrganizationInput +): Promise> { + const result = await repo.insert(input); + if (!result.ok) { + // Unique violation on organizations.name → 409 Conflict + return result.error.code === ErrorCode.DUPLICATE ? err(ErrorCode.CONFLICT) : result; + } + return ok(toOrganizationResponse(result.data)); +} diff --git a/src/domains/organizations/organizations.types.ts b/src/domains/organizations/organizations.types.ts new file mode 100644 index 00000000..03940a4e --- /dev/null +++ b/src/domains/organizations/organizations.types.ts @@ -0,0 +1,31 @@ +import { z } from '@hono/zod-openapi'; + +export const createOrganizationRequestSchema = z.object({ + name: z.string().trim().min(1).max(100), +}); + +export const organizationResponseSchema = z.object({ + id: z.number().int(), + name: z.string(), + createdAt: z.string().datetime().nullable(), +}); + +export const organizationSummarySchema = organizationResponseSchema.extend({ + // Distinct users holding an Org Manager grant with projectId IS NULL. + orgManagerCount: z.number().int(), +}); + +export type CreateOrganizationInput = z.infer; +export type OrganizationResponse = z.infer; +export type OrganizationSummary = z.infer; + +// Repository row shape — createdAt stays a Date; the service serializes it. +export interface OrganizationRecord { + id: number; + name: string; + createdAt: Date | null; +} + +export interface OrganizationSummaryRecord extends OrganizationRecord { + orgManagerCount: number; +} diff --git a/src/domains/organizations/users/org-users.route.ts b/src/domains/organizations/users/org-users.route.ts index 4428fa60..0fe3a508 100644 --- a/src/domains/organizations/users/org-users.route.ts +++ b/src/domains/organizations/users/org-users.route.ts @@ -4,6 +4,8 @@ import * as HttpStatusPhrases from 'stoker/http-status-phrases'; import { jsonContent } from 'stoker/openapi/helpers'; import { createMessageObjectSchema } from 'stoker/openapi/schemas'; +import * as usersService from '@/domains/users/users.service'; +import { userResponseSchema } from '@/domains/users/users.types'; import { PERMISSIONS } from '@/lib/permissions'; import { getHttpStatus } from '@/lib/types'; import { authenticateUser, requirePermission } from '@/middlewares/role-auth'; @@ -13,15 +15,84 @@ import { removeOrgUser } from './org-users.repository'; // ── Shared param schema ──────────────────────────────────────────────────────── +const orgParamSchema = z.object({ + orgId: z.coerce + .number() + .int() + .positive() + .openapi({ + param: { name: 'orgId', in: 'path', required: true }, + example: 1, + }), +}); + const orgUserParamSchema = z.object({ - orgId: z.coerce.number().openapi({ - param: { name: 'orgId', in: 'path', required: true }, - example: 1, - }), - userId: z.coerce.number().openapi({ - param: { name: 'userId', in: 'path', required: true }, - example: 42, - }), + orgId: z.coerce + .number() + .int() + .positive() + .openapi({ + param: { name: 'orgId', in: 'path', required: true }, + example: 1, + }), + userId: z.coerce + .number() + .int() + .positive() + .openapi({ + param: { name: 'userId', in: 'path', required: true }, + example: 42, + }), +}); + +// ─── GET /organizations/:orgId/users ─────────────────────────────────────────── + +const listOrgUsersRoute = createRoute({ + tags: ['Organizations - Users'], + method: 'get', + path: '/organizations/{orgId}/users', + middleware: [ + authenticateUser, + requirePermission(PERMISSIONS.USER_VIEW, (c) => { + const orgId = Number(c.req.param('orgId')); + return Number.isFinite(orgId) ? { orgId } : {}; + }), + ] as const, + request: { params: orgParamSchema }, + responses: { + [HttpStatusCodes.OK]: jsonContent( + userResponseSchema.array().openapi('OrgUsers'), + 'The list of users in this organization' + ), + [HttpStatusCodes.NOT_FOUND]: jsonContent( + createMessageObjectSchema(HttpStatusPhrases.NOT_FOUND), + 'Organization not found' + ), + [HttpStatusCodes.UNAUTHORIZED]: jsonContent( + createMessageObjectSchema('Unauthorized'), + 'Authentication required' + ), + [HttpStatusCodes.FORBIDDEN]: jsonContent( + createMessageObjectSchema('Forbidden'), + 'Org-scoped user:view required' + ), + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent( + createMessageObjectSchema(HttpStatusPhrases.INTERNAL_SERVER_ERROR), + 'Internal server error' + ), + }, + summary: 'List users in an organization', + description: + 'Returns every member of the org with their role grants filtered to this org. Requires an org-scoped or global user:view grant — a project-pinned grant does not apply.', +}); + +server.openapi(listOrgUsersRoute, async (c) => { + const { orgId } = c.req.valid('param'); + + const result = await usersService.getUsersInOrg(orgId); + if (result.ok) return c.json(result.data, HttpStatusCodes.OK); + + return c.json({ message: result.error.message }, getHttpStatus(result.error) as never); }); // ─── DELETE /organizations/:orgId/users/:userId ──────────────────────────────── diff --git a/src/domains/users/user-auth.middleware.test.ts b/src/domains/users/user-auth.middleware.test.ts new file mode 100644 index 00000000..69fd3260 --- /dev/null +++ b/src/domains/users/user-auth.middleware.test.ts @@ -0,0 +1,112 @@ +import { Hono } from 'hono'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Grant } from '@/lib/types'; +import type { AppEnv } from '@/server/context.types'; + +import { PERMISSIONS } from '@/lib/permissions'; +import { ROLES } from '@/lib/roles'; + +import { requireUserAccess } from './user-auth.middleware'; +import { USER_ACTIONS } from './users.types'; + +// The CREATE branch reads the JSON body and calls canAssignRole — no DB access — +// but the module-level imports pull in repositories, so the DB boundary is mocked. +vi.mock('@/db', () => ({ + db: {}, +})); + +vi.mock('@/lib/logger', () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + info: vi.fn(), + }, +})); + +vi.mock('@/domains/user-roles/user-roles.repository', () => ({ + findOrgIdsForUser: vi.fn().mockResolvedValue([]), +})); + +const ORG = 1; +const PROJ = 10; + +const grant = (orgId: number | null, projectId: number | null, perms: string[]): Grant => ({ + orgId, + projectId, + permissions: new Set(perms) as ReadonlySet, +}); + +// Global grant (orgId + projectId both null) holding every SuperAdmin permission. +const SUPER_ADMIN = { + id: 1, + status: 'verified', + grants: [grant(null, null, Object.values(PERMISSIONS))], +}; + +// A Project Manager grant pinned to a single project. +const PROJECT_PINNED_PM = { + id: 2, + status: 'verified', + grants: [ + grant(ORG, PROJ, [ + PERMISSIONS.USER_CREATE, + PERMISSIONS.USER_VIEW, + PERMISSIONS.ROLE_ASSIGN_PROJECT, + ]), + ], +}; + +function appFor(user: object) { + const app = new Hono(); + + app.use('*', async (c, next) => { + c.set('user', user as never); + return next(); + }); + + app.post('/users/invite', requireUserAccess(USER_ACTIONS.CREATE), (c) => + c.json({ reached: true }) + ); + + return app; +} + +function postInvite(user: object, body: object) { + return appFor(user).request('/users/invite', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +describe('requireUserAccess(USER_ACTIONS.CREATE)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('lets a global SuperAdmin invite an Org Manager into an org', async () => { + const res = await postInvite(SUPER_ADMIN, { + orgId: ORG, + projectId: null, + roleName: ROLES.ORG_MANAGER, + }); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ reached: true }); + }); + + it('forbids a project-pinned Project Manager from inviting an Org Manager', async () => { + const res = await postInvite(PROJECT_PINNED_PM, { + orgId: ORG, + projectId: null, + roleName: ROLES.ORG_MANAGER, + }); + + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ + message: 'Forbidden: Insufficient privileges to assign this role.', + }); + }); +}); diff --git a/src/domains/users/users.route.ts b/src/domains/users/users.route.ts index 0e3ee853..3af6d43c 100644 --- a/src/domains/users/users.route.ts +++ b/src/domains/users/users.route.ts @@ -235,7 +235,8 @@ const createUserWithInvitationRoute = createRoute({ ), }, summary: 'Create user and send invitation', - description: 'Creates a new user in database and sends magic link invitation email', + description: + 'Creates a new user in database and sends magic link invitation email. Returns 201 when a new Fluent account is created (magic link sent) and 200 when an existing account is added to the org (login link sent).', }); server.openapi(createUserWithInvitationRoute, async (c) => { @@ -568,8 +569,8 @@ server.openapi(updateActiveOrgRoute, async (c) => { if (!belongsToOrg) { return c.json( { message: 'User does not belong to this organization' }, - HttpStatusCodes.FORBIDDEN // eslint-disable-next-line max-lines + HttpStatusCodes.FORBIDDEN ); } diff --git a/src/domains/users/users.service.test.ts b/src/domains/users/users.service.test.ts index 3bc88c8a..b966957a 100644 --- a/src/domains/users/users.service.test.ts +++ b/src/domains/users/users.service.test.ts @@ -1,6 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { db } from '@/db'; +import * as organizationsRepo from '@/domains/organizations/organizations.repository'; +import { findRoleGrantsByUserIds } from '@/domains/user-roles/user-roles.repository'; import { ErrorMessages } from '@/lib/types'; import { resetAllMocks, sampleUsers } from '@/test/utils/test-helpers'; @@ -12,6 +14,7 @@ import { getUserByEmailOrUsername, getUserById, getUserByUsername, + getUsersInOrg, toUserResponse, updateUser, } from './users.service'; @@ -39,6 +42,10 @@ vi.mock('@/domains/user-roles/user-roles.repository', () => ({ findRoleGrantsByUserIds: vi.fn().mockResolvedValue(new Map()), })); +vi.mock('@/domains/organizations/organizations.repository', () => ({ + findByIdWithCounts: vi.fn(), +})); + describe('user Service Functions', () => { const mockUser = sampleUsers.user1; const mockUserInput = sampleUsers.newUser; @@ -323,6 +330,61 @@ describe('user Service Functions', () => { }); }); + describe('getUsersInOrg', () => { + it('returns org members with grants filtered to that org only', async () => { + const ORG = 1; + const orgRow = { id: ORG, name: 'Alpha Org', createdAt: new Date(), orgManagerCount: 1 }; + vi.mocked(organizationsRepo.findByIdWithCounts).mockResolvedValue({ + ok: true, + data: orgRow, + }); + + (db.selectDistinct as any).mockReturnValue({ + from: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ user: mockUser }]), + }), + }), + }); + + // The user also holds grants in org 2; the repository filter must scope + // them out, so the mock only returns the org-1 grants it would produce. + const org1Grants = [ + { roleId: 2, roleName: 'Org Manager', orgId: ORG, projectId: null, orgName: 'Alpha Org' }, + { + roleId: 4, + roleName: 'Project Manager', + orgId: ORG, + projectId: 10, + orgName: 'Alpha Org', + }, + ]; + vi.mocked(findRoleGrantsByUserIds).mockResolvedValue(new Map([[mockUser.id, org1Grants]])); + + const result = await getUsersInOrg(ORG); + + expect(organizationsRepo.findByIdWithCounts).toHaveBeenCalledWith(ORG); + expect(findRoleGrantsByUserIds).toHaveBeenCalledWith([mockUser.id], [ORG]); + expect(result).toEqual({ + ok: true, + data: [{ ...toUserResponse(mockUser), orgGrants: org1Grants }], + }); + // No org-2 grant leaks through. + expect(result.ok && result.data[0].orgGrants!.every((g) => g.orgId === ORG)).toBe(true); + }); + + it('returns NOT_FOUND when the org does not exist', async () => { + vi.mocked(organizationsRepo.findByIdWithCounts).mockResolvedValue({ ok: true, data: null }); + + const result = await getUsersInOrg(999); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('NOT_FOUND'); + } + }); + }); + describe('deleteUser', () => { it('should delete the user and return a success result', async () => { (db.delete as any).mockReturnValue({ diff --git a/src/domains/users/users.service.ts b/src/domains/users/users.service.ts index 0b800a9a..a1b47b7a 100644 --- a/src/domains/users/users.service.ts +++ b/src/domains/users/users.service.ts @@ -1,9 +1,10 @@ import type { AppPolicyUser, Result } from '@/lib/types'; +import * as organizationsRepo from '@/domains/organizations/organizations.repository'; import { findRoleGrantsByUserIds } from '@/domains/user-roles/user-roles.repository'; import { logger } from '@/lib/logger'; import { PERMISSIONS } from '@/lib/permissions'; -import { ok } from '@/lib/types'; +import { err, ErrorCode, ok } from '@/lib/types'; import type { CreateUserInput, @@ -50,6 +51,15 @@ async function attachGrants(user: User): Promise { return response; } +type RoleGrantsMap = Awaited>; + +function mapUsersWithGrants(userRows: User[], grantsMap: RoleGrantsMap): UserResponse[] { + return userRows.map((u) => ({ + ...toUserResponse(u), + orgGrants: grantsMap.get(u.id) ?? [], + })); +} + export async function getAllUsers(): Promise> { const result = await repo.findAll(); if (!result.ok) return result; @@ -91,12 +101,24 @@ export async function getUsersForUser(user: AppPolicyUser): Promise ({ - ...toUserResponse(u), - orgGrants: grantsMap.get(u.id) ?? [], - })) + return ok(mapUsersWithGrants(userRows, grantsMap)); +} + +export async function getUsersInOrg(orgId: number): Promise> { + const orgResult = await organizationsRepo.findByIdWithCounts(orgId); + if (!orgResult.ok) return orgResult; + if (!orgResult.data) return err(ErrorCode.NOT_FOUND); + + const result = await repo.findByOrganizations([orgId]); + if (!result.ok) return result; + + const userRows = result.data; + const grantsMap = await findRoleGrantsByUserIds( + userRows.map((u) => u.id), + [orgId] ); + + return ok(mapUsersWithGrants(userRows, grantsMap)); } export async function getUserById(id: number): Promise> { diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts index e56ed734..0b1a94d2 100644 --- a/src/lib/permissions.ts +++ b/src/lib/permissions.ts @@ -25,6 +25,10 @@ export const PERMISSIONS = { CONTENT_ASSIGN: 'content:assign', CONTENT_UPDATE: 'content:update', + // ── Organizations ─────────────────────────────────────────────────── + ORG_VIEW: 'org:view', + ORG_CREATE: 'org:create', + // ── Membership / role assignment ──────────────────────────────────── MEMBERSHIP_REVOKE: 'membership:revoke', ROLE_ASSIGN_PROJECT: 'role:assign:project', diff --git a/src/lib/services/permissions/authorize.test.ts b/src/lib/services/permissions/authorize.test.ts index bbfbdf7d..914934c3 100644 --- a/src/lib/services/permissions/authorize.test.ts +++ b/src/lib/services/permissions/authorize.test.ts @@ -106,6 +106,40 @@ describe('authorize', () => { ); }); + it('global SuperAdmin grant authorizes ORG_CREATE at global scope', () => { + const user = { id: 1, grants: [grant(null, null, Object.values(PERMISSIONS))] }; + expect(authorize(user, PERMISSIONS.ORG_CREATE, {})).toBe(true); + }); + + it('org-scoped Org Manager grant does not authorize ORG_CREATE', () => { + // Every permission granted to Org Manager in seeds/rbac.ts — org:create is + // deliberately not among them. + const orgManagerPerms = [ + PERMISSIONS.PROJECT_VIEW, + PERMISSIONS.PROJECT_CREATE, + PERMISSIONS.PROJECT_UPDATE, + PERMISSIONS.PROJECT_DELETE, + PERMISSIONS.CONTENT_VIEW, + PERMISSIONS.CONTENT_ASSIGN, + PERMISSIONS.CONTENT_UPDATE, + PERMISSIONS.MEMBERSHIP_REVOKE, + PERMISSIONS.ROLE_ASSIGN_PROJECT, + PERMISSIONS.USER_VIEW, + PERMISSIONS.USER_CREATE, + PERMISSIONS.USER_UPDATE, + ]; + const user = { id: 2, grants: [grant(ORG, null, orgManagerPerms)] }; + expect(authorize(user, PERMISSIONS.ORG_CREATE, {})).toBe(false); + expect(authorize(user, PERMISSIONS.ORG_CREATE, { orgId: ORG })).toBe(false); + }); + + it('project-pinned USER_VIEW grant is not applicable at org scope', () => { + // A Project Manager pinned to a project holds user:view, but that grant must + // not satisfy the org-scoped member list (GET /organizations/{orgId}/users). + const user = { id: 3, grants: [grant(ORG, PROJ, [PERMISSIONS.USER_VIEW])] }; + expect(authorize(user, PERMISSIONS.USER_VIEW, { orgId: ORG })).toBe(false); + }); + it('org Member grant contributes no permissions — all authorize checks denied', () => { // Regression test per 2026-07-02 spec: Org Member carries zero role_permissions; // it exists only as an anchor row and must never satisfy any permission check.