Skip to content

feat: multi-tenancy with orgs, teams, and location scoping#5

Merged
positonic merged 5 commits intomainfrom
feat-multi-tenancy
Mar 19, 2026
Merged

feat: multi-tenancy with orgs, teams, and location scoping#5
positonic merged 5 commits intomainfrom
feat-multi-tenancy

Conversation

@positonic
Copy link
Copy Markdown
Contributor

@positonic positonic commented Mar 19, 2026

Summary

  • Schema: Evolve organisations table (slug, isActive, timestamps), add teams, teamMembers, and teamLocations tables for flexible geographic scoping. Teams can be scoped to any level of the location hierarchy (country, state, locality) — decoupled from org structure.
  • Auth & utilities: Add resolveTeamMembership guard, buildLocationFilterForTeam utility using PostgreSQL recursive CTE to expand location hierarchy, and defaultTeamId on user for frontend convenience.
  • GraphQL API: Add Organisation/Team types, queries (myOrganisations, myTeams), and full CRUD mutations. Update signals, events, alerts queries to accept explicit teamId argument for stateless, per-request location filtering.

Design decisions

  • Signals/events are objective data — no tenantId stamped on rows. Filtering happens at query time based on the team's location scope.
  • teamId is an explicit query argument (not hidden server state), making each request self-contained and cacheable.
  • defaultTeamId on user is just a frontend convenience to remember last-selected team.

Test plan

  • bun run typecheck passes (only pre-existing nodemailer errors)
  • Create org → create team → set team locations → add member → query signals(teamId: "...") returns scoped results
  • Query signals() as global admin → returns all signals
  • Query signals() as non-admin without teamId → returns BAD_USER_INPUT error

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Team and organisation management (CRUD, membership roles, scopes) with role-based access.
    • Alerts, signals, and events require auth and can be filtered by team scope.
    • Queries & mutations for org/team membership and default team selection; users can set a default team.
    • Interactive, prebuilt GraphQL documentation page served at /docs.
  • Bug Fixes

    • Deduplicated and enforced unique organisation slugs; renamed active→default team column.
  • Chores

    • Added docs build step and updated build/deploy config.

positonic and others added 4 commits March 19, 2026 19:48
Move docs generation from runtime schema introspection to a pre-built
HTML file. Adds scripts/build-docs.ts, updates Dockerfile to copy the
built HTML, and moves @graphql-tools/schema to devDependencies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Evolve organisations table (add slug, isActive, timestamps). Add teams,
teamMembers, and teamLocations tables for flexible geographic scoping.
Add defaultTeamId on user for frontend convenience.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add resolveTeamMembership guard, buildLocationFilterForTeam utility
using recursive CTE for hierarchy expansion, and defaultTeamId to
Better Auth config. Simplify context to remove server-side team state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add Organisation and Team types, queries (myOrganisations, myTeams),
and mutations (CRUD for orgs/teams, member management, setTeamLocations,
setDefaultTeam). Update signals/events/alerts queries to accept explicit
teamId argument for location-based filtering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 19, 2026

📝 Walkthrough

Walkthrough

This PR adds team and organisation support: database migrations and Prisma schema for teams, team-members, and team-locations; GraphQL types, queries, mutations, and resolvers with authorization and team-scoped filtering; location-scope utilities; pre-generated docs build and serving; and build/docker/tooling updates.

Changes

Cohort / File(s) Summary
Database Schema & Migrations
prisma/migrations/20260319000000_add_teams_and_evolve_orgs/migration.sql, prisma/migrations/20260319010000_rename_active_team_to_default_team/migration.sql, prisma/migrations/20260319020000_fix_duplicate_org_slugs/migration.sql, prisma/migrations/20260319202006_fix_duplicate_org/migration.sql, prisma/schema.prisma
Added teams, team_members, team_locations; added slug, is_active, timestamps to organisations; added/renamed active_team_iddefault_team_id on user; updated organisationUsers defaults/constraints; dedupe/fix migrations included.
GraphQL SDL & Registry
src/schema/typeDefs/types/organisation.ts, src/schema/typeDefs/types/team.ts, src/schema/typeDefs/types/user.ts, src/schema/typeDefs/query.ts, src/schema/typeDefs/mutation.ts, src/schema/index.ts
New Organisation, OrgMember, Team, TeamMember types and enums/inputs; extended User with defaultTeam and teamMemberships; added queries (myOrganisations, organisation, myTeams, team) and many mutations for org/team CRUD and membership; made alerts/signals/events accept teamId.
Resolvers
src/resolvers/organisation.resolver.ts, src/resolvers/team.resolver.ts, src/resolvers/user.resolver.ts, src/resolvers/index.ts
New organisation and team resolver modules with full CRUD, membership and location management, default team setting, and authorization helpers; added User field resolvers; registered new resolvers.
Scoped Query Changes
src/resolvers/alert.resolver.ts, src/resolvers/event.resolver.ts, src/resolvers/signal.resolver.ts
Converted alerts/events/signals queries to async, require auth, enforce admin vs team-scoped access, validate membership, and apply location/event filters.
Auth & Location Utilities
src/utils/auth-guard.ts, src/utils/location-scope.ts
Added resolveTeamMembership helper (admin bypass + membership check). Added location-scope utilities: getExpandedLocationIds, buildLocationFilterForTeam, buildEventLocationFilterForTeam (recursive expansion via SQL CTE, Prisma filters).
Docs Generation & Serving
scripts/build-docs.ts, src/docs/docs.html, src/docs/index.ts, src/index.ts, Dockerfile
Added script to generate src/docs/docs.html; router now serves pre-generated docs (with dynamic fallback); server wiring passes typeDefs/resolvers to Apollo; Dockerfile now copies dist/docs/docs.html from build stage.
Build / Tooling / Ignore / Permissions
package.json, tsconfig.build.json, .dockerignore, .claude/settings.json
Build now runs build:docs before TS build; added tsconfig.build.json; moved @graphql-tools/schema to devDependencies; expanded .dockerignore; broadened .claude allowed Bash commands (tsc, bun, git/gh wildcards).
Static Docs
src/docs/docs.html
Added large, self-contained HTML docs page with copy-to-clipboard and scroll-highlight client JS; included as build artifact.
New Script
scripts/build-docs.ts
Added TypeScript script to produce docs HTML from schema/typeDefs at build time.
Minor Auth Config
src/lib/auth.ts
Added optional defaultTeamId to user.additionalFields config.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Resolver as GraphQL Resolver
    participant Auth as Auth Guard
    participant DB as Prisma/Database

    Client->>Resolver: Query alerts/events/signals (teamId?)
    Resolver->>Auth: requireAuth(context)
    Auth->>DB: load user/session
    DB-->>Auth: user data
    Auth-->>Resolver: authenticated user

    alt no teamId
        Resolver->>Auth: check user.role == "admin"
        alt is admin
            Resolver->>DB: findMany (unscoped)
            DB-->>Resolver: results
        else not admin
            Resolver-->>Client: GraphQLError (BAD_USER_INPUT)
        end
    else teamId provided
        Resolver->>Auth: resolveTeamMembership(prisma, userId, teamId)
        Auth->>DB: prisma.teamMembers.findUnique(...)
        DB-->>Auth: membership or not found
        alt membership exists
            Resolver->>DB: buildLocationFilterForTeam(teamId)
            DB-->>Resolver: expanded location IDs
            Resolver->>DB: findMany with where filter
            DB-->>Resolver: filtered results
            Resolver-->>Client: results
        else no membership
            Resolver-->>Client: GraphQLError (FORBIDDEN)
        end
    end
Loading
sequenceDiagram
    participant Client
    participant Mutation as Mutation Resolver
    participant Auth as Auth Guard
    participant OrgAuth as Org Authorization
    participant DB as Prisma/Database

    Client->>Mutation: createTeam(input)
    Mutation->>Auth: requireAuth(context)
    Auth->>DB: load user
    DB-->>Auth: user
    Auth-->>Mutation: user data

    Mutation->>OrgAuth: requireOrgAdminForTeam(userId, orgId)
    OrgAuth->>DB: check organisationUsers role
    DB-->>OrgAuth: role
    alt authorized (admin or owner/admin)
        Mutation->>DB: prisma.teams.create(...)
        DB-->>Mutation: created team
        Mutation-->>Client: Team
    else not authorized
        Mutation-->>Client: GraphQLError (FORBIDDEN)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 Hops of joy across the branch,

Teams and orgs now find their stance,
Docs pre-baked and answers neat,
Guards at gates keep access sweet,
A carrot-coded celebratory dance 🥕✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 72.73% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: multi-tenancy with orgs, teams, and location scoping' directly and accurately summarizes the main feature introduced: comprehensive multi-tenancy support with organizations, teams, and location-based filtering.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-multi-tenancy
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/resolvers/alert.resolver.ts (1)

19-42: ⚠️ Potential issue | 🔴 Critical

alert(id) still bypasses the new team scope.

The collection query is now authenticated and membership-scoped, but Lines 40-41 still return any alert by ID with no auth or event/location filter. That leaves a straightforward cross-tenant read path once an alert ID is known.

As per coding guidelines, "Use requireAuth(context) and requireRole(context, [\"admin\"]) guards from src/utils/auth-guard.ts for GraphQL resolver authentication and authorization".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/resolvers/alert.resolver.ts` around lines 19 - 42, The alert resolver
currently returns any alert by ID without auth or team-scoping; fix it by
calling requireAuth(context) at the start of the alert resolver to get the user,
then fetch the alert including its event->location to obtain the alert's teamId
via prisma.alerts.findUnique (include event -> location), return null if not
found, and if user.role !== "admin" call resolveTeamMembership(context.prisma,
user.id, teamId, user.role) to enforce membership before returning the alert;
keep admin users able to access any alert.
🧹 Nitpick comments (5)
src/utils/location-scope.ts (1)

59-66: Consider extracting a shared location filter type.

The type cast from signalsWhereInput to eventsWhereInput works because both models share the same location field names (originId, destinationId, locationId). This is safe but couples the implementation to this structural assumption.

If the filter shape is reused elsewhere, consider extracting a shared interface or using a generic approach to make the relationship explicit.

♻️ Optional: Generic approach
type LocationFilterableWhereInput = {
  OR?: Array<{
    originId?: { in: string[] };
    destinationId?: { in: string[] };
    locationId?: { in: string[] };
  }>;
};

// Then use this type in the function signature
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/location-scope.ts` around lines 59 - 66, The cast in
buildEventLocationFilterForTeam hides the structural coupling between
signalsWhereInput and eventsWhereInput; define a shared
LocationFilterableWhereInput type (or a generic type parameter) that captures
the OR with originId/destinationId/locationId shapes and use it as the return
type of buildLocationFilterForTeam and as the explicit return for
buildEventLocationFilterForTeam, replacing the unsafe cast; update function
signatures (e.g., buildLocationFilterForTeam, buildEventLocationFilterForTeam)
to reference this shared type or generic so the relationship is explicit and
reusable.
src/schema/typeDefs/types/team.ts (1)

21-28: Consider adding a team field to TeamMember for back-reference.

The TeamMember type includes user: User! but not team: Team. This could be useful when querying a user's team memberships and needing to access team details in a single query.

💡 Optional: Add team back-reference
   """Links a user to a team with a team-level role."""
   type TeamMember {
     id: String!
+    """The team this membership belongs to."""
+    team: Team!
     user: User!
     """Team-level role: lead, analyst, or viewer."""
     role: String!
     createdAt: DateTime!
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/schema/typeDefs/types/team.ts` around lines 21 - 28, Add a back-reference
field to the TeamMember GraphQL type so clients can fetch team details from a
membership record: update the TeamMember type definition to include a
non-nullable team: Team! (or team: Team if nullable desired) alongside the
existing user: User! field, and ensure resolvers/data loaders that populate
TeamMember (e.g., TeamMember resolver or the service that returns memberships)
return/resolve the corresponding Team object for the new team field.
scripts/build-docs.ts (1)

11-16: Consider adding error handling for build failures.

The script runs synchronously without try/catch. If introspectSchema, renderDocsPage, or writeFileSync fails, the build will fail silently with an unhandled exception. Consider wrapping in try/catch to provide clearer error messages during CI/CD.

💡 Optional: Add error handling
+try {
   const schema = makeExecutableSchema({ typeDefs });
   const schemaData = introspectSchema(schema);
   const html = renderDocsPage(schemaData);

   const outPath = join(__dirname, "../src/docs/docs.html");
   writeFileSync(outPath, html, "utf-8");
   console.log(`docs: wrote ${outPath}`);
+} catch (error) {
+  console.error("docs: build failed", error);
+  process.exit(1);
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/build-docs.ts` around lines 11 - 16, Wrap the sequence that builds
and writes the docs (calls to makeExecutableSchema, introspectSchema,
renderDocsPage and writeFileSync using outPath/__dirname) in a try/catch block;
in the catch, log a clear error message including the caught error (and
optionally the failing step), and exit the process with a non‑zero code so CI
fails visibly (e.g., process.exit(1)). Ensure the try covers schema creation,
introspection, HTML rendering and file write so any exception is caught and
reported.
src/resolvers/team.resolver.ts (1)

259-272: Consider validating location IDs exist before creating associations.

The mutation doesn't verify that the provided locationIds actually exist in the locations table. If invalid IDs are passed, Prisma will throw a foreign key constraint error. This might be acceptable depending on your error handling strategy, but a pre-check would provide cleaner error messages.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/resolvers/team.resolver.ts` around lines 259 - 272, Before
deleting/creating team location rows in the replacement block, verify all
provided args.locationIds exist by querying context.prisma.locations.findMany({
where: { id: { in: args.locationIds } } }), compare the returned ids to
args.locationIds, and if any are missing throw a clear error (e.g., new Error or
ApolloError) listing the missing IDs; only proceed to the existing
context.prisma.$transaction that uses context.prisma.teamLocations.deleteMany
and context.prisma.teamLocations.create when the validation passes, then return
context.prisma.teams.findUnique as before.
src/resolvers/organisation.resolver.ts (1)

97-109: Consider checking organisation exists before authorization.

If the organisation doesn't exist, requireOrgAdmin will throw a FORBIDDEN error (since no membership exists), which may be misleading. The team resolver's updateTeam checks existence first and returns NOT_FOUND.

♻️ Proposed fix for consistency
     updateOrganisation: async (
       _parent: unknown,
       args: { id: string; input: UpdateOrganisationInput },
       context: Context,
     ) => {
       const user = requireAuth(context);
+      const org = await context.prisma.organisations.findUnique({
+        where: { id: args.id },
+      });
+      if (!org) {
+        throw new GraphQLError("Organisation not found", {
+          extensions: { code: "NOT_FOUND" },
+        });
+      }
       await requireOrgAdmin(context.prisma, user, args.id);

       return context.prisma.organisations.update({
         where: { id: args.id },
         data: args.input,
       });
     },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/resolvers/organisation.resolver.ts` around lines 97 - 109, Before calling
requireOrgAdmin in updateOrganisation, first query the organisation via
context.prisma.organisations.findUnique (or findFirst) using args.id to confirm
it exists; if not found, return or throw a GraphQL NOT_FOUND error consistent
with updateTeam. Only after confirming existence call requireOrgAdmin and then
proceed with context.prisma.organisations.update using args.input.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@prisma/migrations/20260319000000_add_teams_and_evolve_orgs/migration.sql`:
- Around line 7-14: The backfill UPDATE for "organisations" that sets "slug" =
LOWER(REPLACE("name", ' ', '-')) can produce duplicate slugs and will break when
creating the unique index "organisations_slug_key"; modify the backfill to
generate collision-safe unique slugs (e.g., compute a base_slug from
LOWER(REPLACE("name",' ','-')) and use ROW_NUMBER() OVER (PARTITION BY base_slug
ORDER BY id) to append a suffix like "-<n>" for duplicates) and update only rows
with slug IS NULL before altering the column to NOT NULL and creating the unique
index.

In `@src/docs/docs.html`:
- Line 143: The checked-in prebuilt docs.html is stale and missing schema
changes (teamId on alerts/signals/events, new organisation/team
queries/mutations, updated createSignal shape and new org/team types in the type
index); regenerate the docs artifact using the repository's docs generator (the
same code path used by src/docs/index.ts) so docs.html reflects the current
schema, verify that createSignal, teamId, alerts/signals/events, the
organisation/team queries/mutations and the type index are present and correct,
replace the committed docs.html with the regenerated file, and commit the
updated artifact before merging.

In `@src/resolvers/event.resolver.ts`:
- Around line 44-59: The event resolver (function "event" in event.resolver.ts)
currently returns any event by ID without auth; fix it by calling
requireAuth(context) at the top, then fetch the event once
(prisma.events.findUnique) to read its teamId, and if the requesting user is not
an admin call resolveTeamMembership(context.prisma, user.id, event.teamId,
user.role) to enforce membership before returning the event; alternatively
short-circuit admins with requireRole(context, ["admin"]) if you want admin-only
access — use requireAuth, requireRole, resolveTeamMembership and
prisma.events.findUnique/findFirst to implement this check.

In `@src/resolvers/organisation.resolver.ts`:
- Line 3: Remove the unused import requireRole from the import statement that
currently reads "import { requireAuth, requireRole } from
\"../utils/auth-guard.js\""; keep only requireAuth so the resolver imports the
used symbol (requireAuth) and eliminate requireRole to fix the unused-import
pipeline failure.
- Around line 136-144: The removeOrgMember resolver should handle the case where
the organisation membership doesn't exist by catching Prisma's P2025 error; wrap
the await context.prisma.organisationUsers.delete call inside a try/catch in the
removeOrgMember function, and if the caught error has error.code === 'P2025'
return false (or the same boolean behavior as removeTeamMember), otherwise
rethrow or propagate the error so other failures surface; update any surrounding
logic to return true after successful delete.

In `@src/resolvers/signal.resolver.ts`:
- Around line 23-38: The signal resolver currently returns any signal by ID
without auth or team-scope checks; update the signal resolver to call
requireAuth(context) (and requireRole(context, ["admin"]) if you choose
admin-only) and enforce team membership like the signals list: after
requireAuth, use resolveTeamMembership(context.prisma, user.id, signal.teamId,
user.role) or fetch the signal and verify its teamId against
buildLocationFilterForTeam/context membership before returning; ensure you
import and use requireAuth/requireRole from src/utils/auth-guard.ts and reuse
resolveTeamMembership/buildLocationFilterForTeam or apply an equivalent where
filter so non-admins can only access signals in their team.

In `@src/resolvers/team.resolver.ts`:
- Around line 229-234: The update block using context.prisma.teamMembers.update
can throw a Prisma P2025 when the team member doesn't exist; wrap the update in
a try/catch (or pre-check with findUnique) and handle
Prisma.PrismaClientKnownRequestError with code 'P2025' by throwing a clear
GraphQL error (matching removeTeamMember behavior) instead of letting the raw
Prisma error bubble up; reference context.prisma.teamMembers.update and
removeTeamMember to implement the same not-found handling and return the
expected GraphQL-friendly error.
- Around line 274-300: In setDefaultTeam, verify the team exists before updating
the user's defaultTeamId: call context.prisma.teams.findUnique({ where: { id:
args.teamId } }) (or equivalent) and if it returns null throw a GraphQLError
(e.g. "Team not found" with an appropriate extensions.code like BAD_USER_INPUT
or NOT_FOUND); only after the team exists proceed with the user update via
context.prisma.user.update and then return the found team (avoid updating when
team is missing so admin cannot set a non-existent team).
- Around line 199-204: The delete call on context.prisma.teamMembers.delete can
raise Prisma P2025 if the teamId_userId pair doesn't exist; catch that error
around the resolver (the function containing the delete call) and map it to a
clean GraphQL NOT_FOUND error instead of letting it bubble up. Specifically,
wrap the context.prisma.teamMembers.delete invocation (using args.teamId and
args.userId) in a try/catch, detect Prisma's error.code === 'P2025' (or message
indicating "Record to delete does not exist"), and throw a GraphQL-friendly
error (e.g., new ApolloError('Team member not found', 'NOT_FOUND') or new
GraphQLError) so callers receive a controlled NOT_FOUND response. Ensure other
errors are re-thrown.

In `@src/schema/typeDefs/mutation.ts`:
- Around line 90-113: The schema exposes role arguments as free-form Strings for
addOrgMember, addTeamMember, and updateTeamMemberRole which allows invalid
values to be persisted to organisationUsers.role and teamMembers.role; add
GraphQL enum types (e.g., OrgMemberRole with values OWNER, ADMIN, MEMBER and
TeamMemberRole with values LEAD, ANALYST, VIEWER), change the role argument
types on addOrgMember, addTeamMember, and updateTeamMemberRole to use those
enums, and then update the corresponding resolver functions (the resolvers that
write to organisationUsers.role and teamMembers.role) to validate/convert the
enum input to the exact DB values expected by Prisma (rejecting unknowns) before
saving so only allowed role values are persisted and used by authorization
checks.

In `@src/schema/typeDefs/query.ts`:
- Around line 14-27: Update the GraphQL field descriptions for alerts, alert,
signals, signal, and events in the Query typeDefs to explicitly state the
authentication and team scoping behavior: note that requests require an
authenticated user, that admins may pass an optional teamId to scope results
across teams, and that non-admin users must query within a team they belong to
(teamId will be ignored or cause an error if outside their teams); keep the
descriptions concise and use the triple-quoted docstring format ("""...""") so
autogenerated docs reflect the current resolver contract for alerts, alert,
signals, signal, and events.

In `@src/schema/typeDefs/types/organisation.ts`:
- Around line 4-37: The GraphQL SDL is missing description strings for the new
fields and inputs; add triple-quoted descriptions for Organisation.isActive,
Organisation.createdAt, Organisation.updatedAt, OrgMember.createdAt, and for the
input types CreateOrganisationInput and UpdateOrganisationInput so the generated
docs include them; locate the Organisation and OrgMember type blocks and the
CreateOrganisationInput / UpdateOrganisationInput blocks in
src/schema/typeDefs/types/organisation.ts and insert concise """..."""
descriptions for each undocumented field and input (e.g., explain isActive
purpose, timestamp semantics, and input field meanings) following the existing
documentation style.

---

Outside diff comments:
In `@src/resolvers/alert.resolver.ts`:
- Around line 19-42: The alert resolver currently returns any alert by ID
without auth or team-scoping; fix it by calling requireAuth(context) at the
start of the alert resolver to get the user, then fetch the alert including its
event->location to obtain the alert's teamId via prisma.alerts.findUnique
(include event -> location), return null if not found, and if user.role !==
"admin" call resolveTeamMembership(context.prisma, user.id, teamId, user.role)
to enforce membership before returning the alert; keep admin users able to
access any alert.

---

Nitpick comments:
In `@scripts/build-docs.ts`:
- Around line 11-16: Wrap the sequence that builds and writes the docs (calls to
makeExecutableSchema, introspectSchema, renderDocsPage and writeFileSync using
outPath/__dirname) in a try/catch block; in the catch, log a clear error message
including the caught error (and optionally the failing step), and exit the
process with a non‑zero code so CI fails visibly (e.g., process.exit(1)). Ensure
the try covers schema creation, introspection, HTML rendering and file write so
any exception is caught and reported.

In `@src/resolvers/organisation.resolver.ts`:
- Around line 97-109: Before calling requireOrgAdmin in updateOrganisation,
first query the organisation via context.prisma.organisations.findUnique (or
findFirst) using args.id to confirm it exists; if not found, return or throw a
GraphQL NOT_FOUND error consistent with updateTeam. Only after confirming
existence call requireOrgAdmin and then proceed with
context.prisma.organisations.update using args.input.

In `@src/resolvers/team.resolver.ts`:
- Around line 259-272: Before deleting/creating team location rows in the
replacement block, verify all provided args.locationIds exist by querying
context.prisma.locations.findMany({ where: { id: { in: args.locationIds } } }),
compare the returned ids to args.locationIds, and if any are missing throw a
clear error (e.g., new Error or ApolloError) listing the missing IDs; only
proceed to the existing context.prisma.$transaction that uses
context.prisma.teamLocations.deleteMany and context.prisma.teamLocations.create
when the validation passes, then return context.prisma.teams.findUnique as
before.

In `@src/schema/typeDefs/types/team.ts`:
- Around line 21-28: Add a back-reference field to the TeamMember GraphQL type
so clients can fetch team details from a membership record: update the
TeamMember type definition to include a non-nullable team: Team! (or team: Team
if nullable desired) alongside the existing user: User! field, and ensure
resolvers/data loaders that populate TeamMember (e.g., TeamMember resolver or
the service that returns memberships) return/resolve the corresponding Team
object for the new team field.

In `@src/utils/location-scope.ts`:
- Around line 59-66: The cast in buildEventLocationFilterForTeam hides the
structural coupling between signalsWhereInput and eventsWhereInput; define a
shared LocationFilterableWhereInput type (or a generic type parameter) that
captures the OR with originId/destinationId/locationId shapes and use it as the
return type of buildLocationFilterForTeam and as the explicit return for
buildEventLocationFilterForTeam, replacing the unsafe cast; update function
signatures (e.g., buildLocationFilterForTeam, buildEventLocationFilterForTeam)
to reference this shared type or generic so the relationship is explicit and
reusable.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a004a9e6-eab6-4be2-b63d-bdfb05577e03

📥 Commits

Reviewing files that changed from the base of the PR and between 3a85a10 and 2e58f56.

📒 Files selected for processing (28)
  • .claude/settings.json
  • .dockerignore
  • Dockerfile
  • package.json
  • prisma/migrations/20260319000000_add_teams_and_evolve_orgs/migration.sql
  • prisma/migrations/20260319010000_rename_active_team_to_default_team/migration.sql
  • prisma/schema.prisma
  • scripts/build-docs.ts
  • src/docs/docs.html
  • src/docs/index.ts
  • src/index.ts
  • src/lib/auth.ts
  • src/resolvers/alert.resolver.ts
  • src/resolvers/event.resolver.ts
  • src/resolvers/index.ts
  • src/resolvers/organisation.resolver.ts
  • src/resolvers/signal.resolver.ts
  • src/resolvers/team.resolver.ts
  • src/resolvers/user.resolver.ts
  • src/schema/index.ts
  • src/schema/typeDefs/mutation.ts
  • src/schema/typeDefs/query.ts
  • src/schema/typeDefs/types/organisation.ts
  • src/schema/typeDefs/types/team.ts
  • src/schema/typeDefs/types/user.ts
  • src/utils/auth-guard.ts
  • src/utils/location-scope.ts
  • tsconfig.build.json

Comment on lines +7 to +14
-- Backfill existing rows
UPDATE "organisations" SET "slug" = LOWER(REPLACE("name", ' ', '-')) WHERE "slug" IS NULL;
UPDATE "organisations" SET "updated_at" = CURRENT_TIMESTAMP WHERE "updated_at" IS NULL;

-- Now make slug required and unique
ALTER TABLE "organisations" ALTER COLUMN "slug" SET NOT NULL;
ALTER TABLE "organisations" ALTER COLUMN "updated_at" SET NOT NULL;
CREATE UNIQUE INDEX "organisations_slug_key" ON "organisations"("slug");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential slug collision during backfill.

The backfill generates slugs using LOWER(REPLACE("name", ' ', '-')). If two organisations have names that normalize to the same slug (e.g., "My Org" and "my-org"), the subsequent unique constraint creation will fail.

Consider adding a suffix to handle collisions:

🛡️ Proposed safer backfill approach
-- Backfill with collision handling using row_number
UPDATE "organisations" o
SET "slug" = subq.unique_slug
FROM (
  SELECT id, 
    CASE 
      WHEN rn = 1 THEN base_slug
      ELSE base_slug || '-' || rn::text
    END AS unique_slug
  FROM (
    SELECT id, 
      LOWER(REPLACE("name", ' ', '-')) AS base_slug,
      ROW_NUMBER() OVER (PARTITION BY LOWER(REPLACE("name", ' ', '-')) ORDER BY id) AS rn
    FROM "organisations"
  ) ranked
) subq
WHERE o.id = subq.id AND o."slug" IS NULL;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@prisma/migrations/20260319000000_add_teams_and_evolve_orgs/migration.sql`
around lines 7 - 14, The backfill UPDATE for "organisations" that sets "slug" =
LOWER(REPLACE("name", ' ', '-')) can produce duplicate slugs and will break when
creating the unique index "organisations_slug_key"; modify the backfill to
generate collision-safe unique slugs (e.g., compute a base_slug from
LOWER(REPLACE("name",' ','-')) and use ROW_NUMBER() OVER (PARTITION BY base_slug
ORDER BY id) to append a suffix like "-<n>" for duplicates) and update only rows
with slug IS NULL before altering the column to NOT NULL and creating the unique
index.

Comment thread src/docs/docs.html Outdated
Comment thread src/resolvers/event.resolver.ts Outdated
Comment thread src/resolvers/organisation.resolver.ts Outdated
Comment thread src/resolvers/organisation.resolver.ts Outdated
Comment thread src/resolvers/team.resolver.ts Outdated
Comment thread src/resolvers/team.resolver.ts
Comment thread src/schema/typeDefs/mutation.ts Outdated
Comment on lines +90 to +113
"""Add a member to an organisation."""
addOrgMember(orgId: String!, userId: String!, role: String): OrgMember!

"""Remove a member from an organisation."""
removeOrgMember(orgId: String!, userId: String!): Boolean!

# ─── Teams ─────────────────────────────────────────────────────────────────
"""Create a new team within an organisation. Requires org admin or owner."""
createTeam(input: CreateTeamInput!): Team!

"""Update an existing team."""
updateTeam(id: String!, input: UpdateTeamInput!): Team!

"""Delete a team."""
deleteTeam(id: String!): Boolean!

"""Add a member to a team."""
addTeamMember(teamId: String!, userId: String!, role: String): TeamMember!

"""Remove a member from a team."""
removeTeamMember(teamId: String!, userId: String!): Boolean!

"""Update a team member's role."""
updateTeamMemberRole(teamId: String!, userId: String!, role: String!): TeamMember!
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -nC2 'addOrgMember|role:\s*args\.role' src/resolvers/organisation.resolver.ts
rg -nC2 'addTeamMember|updateTeamMemberRole|role:\s*args\.role' src/resolvers/team.resolver.ts
rg -nC2 'model organisationUsers|model teamMembers|role\s+String' prisma/schema.prisma

Repository: CLEAR-Initiative/clear-api

Length of output: 1783


Don't expose membership roles as free-form Strings in the GraphQL schema.

The addOrgMember, addTeamMember, and updateTeamMemberRole mutations accept arbitrary string values for the role argument, and the resolvers write those values directly to organisationUsers.role and teamMembers.role without validation. Valid roles only exist as comments in the Prisma schema (owner | admin | member for organisation members, lead | analyst | viewer for team members). This allows invalid roles to be persisted in the database and will cause authorization checks downstream to fail unpredictably.

Define GraphQL enums for each role set and use them as the types for these arguments. Add corresponding validation in the resolvers to reject invalid role values.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/schema/typeDefs/mutation.ts` around lines 90 - 113, The schema exposes
role arguments as free-form Strings for addOrgMember, addTeamMember, and
updateTeamMemberRole which allows invalid values to be persisted to
organisationUsers.role and teamMembers.role; add GraphQL enum types (e.g.,
OrgMemberRole with values OWNER, ADMIN, MEMBER and TeamMemberRole with values
LEAD, ANALYST, VIEWER), change the role argument types on addOrgMember,
addTeamMember, and updateTeamMemberRole to use those enums, and then update the
corresponding resolver functions (the resolvers that write to
organisationUsers.role and teamMembers.role) to validate/convert the enum input
to the exact DB values expected by Prisma (rejecting unknowns) before saving so
only allowed role values are persisted and used by authorization checks.

Comment thread src/schema/typeDefs/query.ts Outdated
Comment thread src/schema/typeDefs/types/organisation.ts
…ations

- Implemented role-based access control for fetching alerts, events, and signals.
- Non-admin users must now provide a teamId to access alerts, events, and signals within their team scope.
- Added error handling for missing team memberships and improved error messages for forbidden access.
- Updated organisation resolvers to include checks for organisation existence and improved error handling for member removal.
- Introduced new enums for organisation and team member roles in the GraphQL schema.
- Added fields for organisation creation and updates, including active status and timestamps.
- Created migrations to deduplicate organisation slugs and rename foreign key constraints.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (3)
src/resolvers/team.resolver.ts (2)

20-29: Consider optimizing with a single query.

The current approach makes two database calls. This could be consolidated using Prisma's relation includes.

♻️ Proposed optimization
     myTeams: async (_parent: unknown, _args: unknown, context: Context) => {
       const user = requireAuth(context);
-      const memberships = await context.prisma.teamMembers.findMany({
+      const memberships = await context.prisma.teamMembers.findMany({
         where: { userId: user.id },
-        select: { teamId: true },
+        include: { team: true },
       });
-      return context.prisma.teams.findMany({
-        where: { id: { in: memberships.map((m) => m.teamId) } },
-      });
+      return memberships.map((m) => m.team);
     },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/resolvers/team.resolver.ts` around lines 20 - 29, The myTeams resolver
currently does two DB calls (teamMembers.findMany then teams.findMany); replace
this with a single query against context.prisma.teams that filters by the
relation to team members (e.g., where: { members: { some: { userId: user.id } }
}) after obtaining the user via requireAuth(context). Update the resolver to
call requireAuth(context) as before, then use one context.prisma.teams.findMany
call that uses the relation filter (members.some.userId) to return the user's
teams.

285-298: Consider validating locationIds exist before creating team locations.

If any locationId in the array doesn't exist in the locations table, Prisma will throw a foreign key constraint error. Consider validating the location IDs upfront for a cleaner error message.

🛡️ Proposed enhancement
       await requireTeamLeadOrOrgAdmin(
         context.prisma,
         user,
         args.teamId,
         team.organisationId,
       );

+      // Validate all locationIds exist
+      if (args.locationIds.length > 0) {
+        const existingLocations = await context.prisma.locations.findMany({
+          where: { id: { in: args.locationIds } },
+          select: { id: true },
+        });
+        const existingIds = new Set(existingLocations.map((l) => l.id));
+        const invalidIds = args.locationIds.filter((id) => !existingIds.has(id));
+        if (invalidIds.length > 0) {
+          throw new GraphQLError(`Invalid location IDs: ${invalidIds.join(", ")}`, {
+            extensions: { code: "BAD_USER_INPUT" },
+          });
+        }
+      }
+
       // Replace all team locations in a transaction
       await context.prisma.$transaction([
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/resolvers/team.resolver.ts` around lines 285 - 298, Before performing the
transaction in the resolver that replaces team locations, validate that all ids
in args.locationIds exist in the locations table: query
context.prisma.locations.findMany or count({ where: { id: { in: args.locationIds
} } }) and compare to args.locationIds.length, and if there is a mismatch throw
a clear user error (e.g., "Some locationIds do not exist") referencing
args.locationIds; only then run the existing context.prisma.$transaction that
uses teamLocations.deleteMany and teamLocations.create to avoid FK constraint
errors and produce a nicer error message.
src/resolvers/signal.resolver.ts (1)

4-6: Consolidate duplicate imports from the same module.

Lines 4 and 5 both import from ../utils/auth-guard.js. Merge them into a single import statement.

♻️ Proposed fix
-import { requireAuth, requireRole } from "../utils/auth-guard.js";
-import { resolveTeamMembership } from "../utils/auth-guard.js";
+import { requireAuth, requireRole, resolveTeamMembership } from "../utils/auth-guard.js";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/resolvers/signal.resolver.ts` around lines 4 - 6, The import statements
are duplicated for the same module; consolidate the two imports from
../utils/auth-guard.js into one line by combining requireAuth, requireRole and
resolveTeamMembership into a single named import (e.g., import { requireAuth,
requireRole, resolveTeamMembership } from "../utils/auth-guard.js") and keep
buildLocationFilterForTeam imported separately from ../utils/location-scope.js.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@prisma/migrations/20260319020000_fix_duplicate_org_slugs/migration.sql`:
- Around line 1-9: The CREATE UNIQUE INDEX in migration
20260319000000_add_teams_and_evolve_orgs is being created before duplicates are
removed, so move that CREATE UNIQUE INDEX statement out of that first migration
and add it to the end of this migration (20260319020000_fix_duplicate_org_slugs)
after the UPDATE deduplication runs, or alternatively inline the deduplication
UPDATE from this migration into the first migration immediately before its
CREATE UNIQUE INDEX; also fix the deduplication UPDATE logic (referencing the
organisations table and the sub query with ROW_NUMBER() AS rn) so it never
produces a conflicting slug (e.g., when computing suffixes ensure you generate a
unique suffix per base slug by consulting existing slugs or using a max-suffix +
1 per partition rather than naively appending rn) before creating the unique
index.

In `@src/resolvers/organisation.resolver.ts`:
- Around line 97-119: The updateOrganisation resolver currently lets Prisma
raise a P2002 unique-constraint error when changing slug; modify
updateOrganisation (and its call to context.prisma.organisations.update) to
either pre-check slug uniqueness (query organisations.findUnique({ where: {
slug: args.input.slug } }) and ensure found.id !== args.id) or wrap the update
call in a try/catch that catches Prisma.PrismaClientKnownRequestError with code
"P2002" (and target including "slug") and rethrow a GraphQLError with
extensions.code = "BAD_USER_INPUT" and a clear message like "Slug already in
use"; keep the existing requireOrgAdmin check and only apply this handling when
args.input.slug is present.
- Around line 121-136: The addOrgMember resolver currently calls
context.prisma.organisationUsers.create directly and lets Prisma surface
P2002/P2003 errors; before creating, verify the target user exists (query
prisma.user.findUnique by args.userId) and throw a clear GraphQL error if not
found, then check for existing membership (query
prisma.organisationUsers.findUnique or findFirst by userId and organisationId)
and throw a clear "user already a member" GraphQL error if present; only then
call context.prisma.organisationUsers.create (retain requireAuth and
requireOrgAdmin checks) so you convert Prisma constraint errors into explicit,
user-friendly GraphQL errors.

In `@src/resolvers/team.resolver.ts`:
- Around line 168-175: The create call can throw on duplicate membership; before
calling context.prisma.teamMembers.create (inside the add-team-member resolver
handling args.teamId and args.userId), first query
context.prisma.teamMembers.findUnique or findFirst for { teamId: args.teamId,
userId: args.userId } and if found return it or throw a clear user-friendly
error like "User is already a member of this team"; alternatively wrap
context.prisma.teamMembers.create in a try/catch and handle
PrismaClientKnownRequestError with code 'P2002' (unique constraint) to return a
friendly message—update the resolver to use one of these approaches around
teamMembers.create.

---

Nitpick comments:
In `@src/resolvers/signal.resolver.ts`:
- Around line 4-6: The import statements are duplicated for the same module;
consolidate the two imports from ../utils/auth-guard.js into one line by
combining requireAuth, requireRole and resolveTeamMembership into a single named
import (e.g., import { requireAuth, requireRole, resolveTeamMembership } from
"../utils/auth-guard.js") and keep buildLocationFilterForTeam imported
separately from ../utils/location-scope.js.

In `@src/resolvers/team.resolver.ts`:
- Around line 20-29: The myTeams resolver currently does two DB calls
(teamMembers.findMany then teams.findMany); replace this with a single query
against context.prisma.teams that filters by the relation to team members (e.g.,
where: { members: { some: { userId: user.id } } }) after obtaining the user via
requireAuth(context). Update the resolver to call requireAuth(context) as
before, then use one context.prisma.teams.findMany call that uses the relation
filter (members.some.userId) to return the user's teams.
- Around line 285-298: Before performing the transaction in the resolver that
replaces team locations, validate that all ids in args.locationIds exist in the
locations table: query context.prisma.locations.findMany or count({ where: { id:
{ in: args.locationIds } } }) and compare to args.locationIds.length, and if
there is a mismatch throw a clear user error (e.g., "Some locationIds do not
exist") referencing args.locationIds; only then run the existing
context.prisma.$transaction that uses teamLocations.deleteMany and
teamLocations.create to avoid FK constraint errors and produce a nicer error
message.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d39ff8fe-eb03-4ef5-a216-b48b146d3263

📥 Commits

Reviewing files that changed from the base of the PR and between 2e58f56 and 531dfc1.

📒 Files selected for processing (13)
  • .claude/settings.json
  • prisma/migrations/20260319020000_fix_duplicate_org_slugs/migration.sql
  • prisma/migrations/20260319202006_fix_duplicate_org/migration.sql
  • scripts/build-docs.ts
  • src/docs/docs.html
  • src/resolvers/alert.resolver.ts
  • src/resolvers/event.resolver.ts
  • src/resolvers/organisation.resolver.ts
  • src/resolvers/signal.resolver.ts
  • src/resolvers/team.resolver.ts
  • src/schema/typeDefs/mutation.ts
  • src/schema/typeDefs/query.ts
  • src/schema/typeDefs/types/organisation.ts
✅ Files skipped from review due to trivial changes (2)
  • prisma/migrations/20260319202006_fix_duplicate_org/migration.sql
  • src/schema/typeDefs/query.ts
🚧 Files skipped from review as they are similar to previous changes (6)
  • scripts/build-docs.ts
  • .claude/settings.json
  • src/schema/typeDefs/types/organisation.ts
  • src/resolvers/alert.resolver.ts
  • src/schema/typeDefs/mutation.ts
  • src/resolvers/event.resolver.ts

Comment on lines +1 to +9
-- Deduplicate organisation slugs by appending the row number for collisions.
-- Only the first org (by id) keeps the base slug; subsequent duplicates get "-2", "-3", etc.
UPDATE "organisations" o
SET "slug" = o."slug" || '-' || sub.rn::text
FROM (
SELECT id, ROW_NUMBER() OVER (PARTITION BY "slug" ORDER BY id) AS rn
FROM "organisations"
) sub
WHERE o.id = sub.id AND sub.rn > 1;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify that the first migration creates the unique index before this migration runs

echo "=== Checking first migration for CREATE UNIQUE INDEX ==="
cat prisma/migrations/20260319000000_add_teams_and_evolve_orgs/migration.sql | grep -n "CREATE UNIQUE INDEX"

echo ""
echo "=== Listing migration order by timestamp ==="
ls -1 prisma/migrations/ | sort

Repository: CLEAR-Initiative/clear-api

Length of output: 1186


🏁 Script executed:

cat prisma/migrations/20260319000000_add_teams_and_evolve_orgs/migration.sql

Repository: CLEAR-Initiative/clear-api

Length of output: 4502


Critical: Migration order causes CREATE UNIQUE INDEX to fail before deduplication runs.

The first migration (20260319000000_add_teams_and_evolve_orgs) backfills slugs with LOWER(REPLACE("name", ' ', '-')), which can create duplicates if multiple organisations share the same name or differ only in whitespace/casing. When it then attempts to create the unique index on line 14, the statement will fail due to existing duplicate values, causing the entire first migration to roll back. The deduplication migration will never execute.

Move the CREATE UNIQUE INDEX statement from the first migration to the end of this migration, or inline the deduplication logic in the first migration before the index creation.

Note: The deduplication logic itself also has an edge case where it can create new duplicates (e.g., if slugs ["my-org", "my-org", "my-org-2"] exist, the deduplicated result becomes ["my-org", "my-org-2", "my-org-2"]), though this is less critical as it would require the first migration to succeed, which is unlikely given the backfill issue.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@prisma/migrations/20260319020000_fix_duplicate_org_slugs/migration.sql`
around lines 1 - 9, The CREATE UNIQUE INDEX in migration
20260319000000_add_teams_and_evolve_orgs is being created before duplicates are
removed, so move that CREATE UNIQUE INDEX statement out of that first migration
and add it to the end of this migration (20260319020000_fix_duplicate_org_slugs)
after the UPDATE deduplication runs, or alternatively inline the deduplication
UPDATE from this migration into the first migration immediately before its
CREATE UNIQUE INDEX; also fix the deduplication UPDATE logic (referencing the
organisations table and the sub query with ROW_NUMBER() AS rn) so it never
produces a conflicting slug (e.g., when computing suffixes ensure you generate a
unique suffix per base slug by consulting existing slugs or using a max-suffix +
1 per partition rather than naively appending rn) before creating the unique
index.

Comment on lines +97 to +119
updateOrganisation: async (
_parent: unknown,
args: { id: string; input: UpdateOrganisationInput },
context: Context,
) => {
const user = requireAuth(context);

const org = await context.prisma.organisations.findUnique({
where: { id: args.id },
});
if (!org) {
throw new GraphQLError("Organisation not found", {
extensions: { code: "NOT_FOUND" },
});
}

await requireOrgAdmin(context.prisma, user, args.id);

return context.prisma.organisations.update({
where: { id: args.id },
data: args.input,
});
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Handle slug uniqueness conflict on update.

The createOrganisation mutation validates slug uniqueness, but updateOrganisation does not. If a user updates the slug to one that already exists, Prisma will throw an unhandled unique constraint error (P2002) instead of a clean BAD_USER_INPUT response.

🛡️ Proposed fix
       await requireOrgAdmin(context.prisma, user, args.id);

+      if (args.input.slug) {
+        const existing = await context.prisma.organisations.findUnique({
+          where: { slug: args.input.slug },
+        });
+        if (existing && existing.id !== args.id) {
+          throw new GraphQLError("An organisation with this slug already exists", {
+            extensions: { code: "BAD_USER_INPUT" },
+          });
+        }
+      }
+
       return context.prisma.organisations.update({
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/resolvers/organisation.resolver.ts` around lines 97 - 119, The
updateOrganisation resolver currently lets Prisma raise a P2002
unique-constraint error when changing slug; modify updateOrganisation (and its
call to context.prisma.organisations.update) to either pre-check slug uniqueness
(query organisations.findUnique({ where: { slug: args.input.slug } }) and ensure
found.id !== args.id) or wrap the update call in a try/catch that catches
Prisma.PrismaClientKnownRequestError with code "P2002" (and target including
"slug") and rethrow a GraphQLError with extensions.code = "BAD_USER_INPUT" and a
clear message like "Slug already in use"; keep the existing requireOrgAdmin
check and only apply this handling when args.input.slug is present.

Comment on lines +121 to +136
addOrgMember: async (
_parent: unknown,
args: { orgId: string; userId: string; role?: string },
context: Context,
) => {
const user = requireAuth(context);
await requireOrgAdmin(context.prisma, user, args.orgId);

return context.prisma.organisationUsers.create({
data: {
userId: args.userId,
organisationId: args.orgId,
role: args.role ?? "member",
},
});
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Handle duplicate membership and verify target user exists.

Two edge cases can produce unhelpful Prisma errors instead of clean GraphQL errors:

  1. If args.userId is already a member, Prisma throws a unique constraint violation (P2002).
  2. If args.userId doesn't exist in the user table, Prisma throws a foreign key constraint error (P2003).
🛡️ Proposed fix
     addOrgMember: async (
       _parent: unknown,
       args: { orgId: string; userId: string; role?: string },
       context: Context,
     ) => {
       const user = requireAuth(context);
       await requireOrgAdmin(context.prisma, user, args.orgId);

+      const targetUser = await context.prisma.user.findUnique({
+        where: { id: args.userId },
+      });
+      if (!targetUser) {
+        throw new GraphQLError("User not found", {
+          extensions: { code: "NOT_FOUND" },
+        });
+      }
+
+      const existingMembership = await context.prisma.organisationUsers.findUnique({
+        where: {
+          userId_organisationId: {
+            userId: args.userId,
+            organisationId: args.orgId,
+          },
+        },
+      });
+      if (existingMembership) {
+        throw new GraphQLError("User is already a member of this organisation", {
+          extensions: { code: "BAD_USER_INPUT" },
+        });
+      }
+
       return context.prisma.organisationUsers.create({
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/resolvers/organisation.resolver.ts` around lines 121 - 136, The
addOrgMember resolver currently calls context.prisma.organisationUsers.create
directly and lets Prisma surface P2002/P2003 errors; before creating, verify the
target user exists (query prisma.user.findUnique by args.userId) and throw a
clear GraphQL error if not found, then check for existing membership (query
prisma.organisationUsers.findUnique or findFirst by userId and organisationId)
and throw a clear "user already a member" GraphQL error if present; only then
call context.prisma.organisationUsers.create (retain requireAuth and
requireOrgAdmin checks) so you convert Prisma constraint errors into explicit,
user-friendly GraphQL errors.

Comment on lines +168 to +175
return context.prisma.teamMembers.create({
data: {
teamId: args.teamId,
userId: args.userId,
role: args.role ?? "viewer",
},
});
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Handle duplicate team membership gracefully.

If the user is already a team member, Prisma will throw a unique constraint violation error (P2023 or similar) that isn't caught. Consider checking for existing membership first or handling the error with a user-friendly message.

🛡️ Proposed fix - pre-check for existing membership
       if (!orgMembership) {
         throw new GraphQLError(
           "User must be a member of the organisation first",
           { extensions: { code: "BAD_USER_INPUT" } },
         );
       }

+      const existingMember = await context.prisma.teamMembers.findUnique({
+        where: {
+          teamId_userId: { teamId: args.teamId, userId: args.userId },
+        },
+      });
+      if (existingMember) {
+        throw new GraphQLError("User is already a member of this team", {
+          extensions: { code: "BAD_USER_INPUT" },
+        });
+      }
+
       return context.prisma.teamMembers.create({
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return context.prisma.teamMembers.create({
data: {
teamId: args.teamId,
userId: args.userId,
role: args.role ?? "viewer",
},
});
},
const existingMember = await context.prisma.teamMembers.findUnique({
where: {
teamId_userId: { teamId: args.teamId, userId: args.userId },
},
});
if (existingMember) {
throw new GraphQLError("User is already a member of this team", {
extensions: { code: "BAD_USER_INPUT" },
});
}
return context.prisma.teamMembers.create({
data: {
teamId: args.teamId,
userId: args.userId,
role: args.role ?? "viewer",
},
});
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/resolvers/team.resolver.ts` around lines 168 - 175, The create call can
throw on duplicate membership; before calling context.prisma.teamMembers.create
(inside the add-team-member resolver handling args.teamId and args.userId),
first query context.prisma.teamMembers.findUnique or findFirst for { teamId:
args.teamId, userId: args.userId } and if found return it or throw a clear
user-friendly error like "User is already a member of this team"; alternatively
wrap context.prisma.teamMembers.create in a try/catch and handle
PrismaClientKnownRequestError with code 'P2002' (unique constraint) to return a
friendly message—update the resolver to use one of these approaches around
teamMembers.create.

@positonic positonic merged commit 4fc030b into main Mar 19, 2026
1 of 2 checks passed
@coderabbitai coderabbitai Bot mentioned this pull request Mar 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant