feat: multi-tenancy with orgs, teams, and location scoping#5
Conversation
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>
📝 WalkthroughWalkthroughThis 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
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
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
📝 Coding Plan
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. Comment |
There was a problem hiding this comment.
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)andrequireRole(context, [\"admin\"])guards fromsrc/utils/auth-guard.tsfor 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
signalsWhereInputtoeventsWhereInputworks 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 ateamfield toTeamMemberfor back-reference.The
TeamMembertype includesuser: User!but notteam: 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, orwriteFileSyncfails, 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
locationIdsactually exist in thelocationstable. 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,
requireOrgAdminwill throw aFORBIDDENerror (since no membership exists), which may be misleading. The team resolver'supdateTeamchecks existence first and returnsNOT_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
📒 Files selected for processing (28)
.claude/settings.json.dockerignoreDockerfilepackage.jsonprisma/migrations/20260319000000_add_teams_and_evolve_orgs/migration.sqlprisma/migrations/20260319010000_rename_active_team_to_default_team/migration.sqlprisma/schema.prismascripts/build-docs.tssrc/docs/docs.htmlsrc/docs/index.tssrc/index.tssrc/lib/auth.tssrc/resolvers/alert.resolver.tssrc/resolvers/event.resolver.tssrc/resolvers/index.tssrc/resolvers/organisation.resolver.tssrc/resolvers/signal.resolver.tssrc/resolvers/team.resolver.tssrc/resolvers/user.resolver.tssrc/schema/index.tssrc/schema/typeDefs/mutation.tssrc/schema/typeDefs/query.tssrc/schema/typeDefs/types/organisation.tssrc/schema/typeDefs/types/team.tssrc/schema/typeDefs/types/user.tssrc/utils/auth-guard.tssrc/utils/location-scope.tstsconfig.build.json
| -- 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"); |
There was a problem hiding this comment.
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.
| """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! |
There was a problem hiding this comment.
🧩 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.prismaRepository: 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.
…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.
There was a problem hiding this comment.
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
locationIdin the array doesn't exist in thelocationstable, 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
📒 Files selected for processing (13)
.claude/settings.jsonprisma/migrations/20260319020000_fix_duplicate_org_slugs/migration.sqlprisma/migrations/20260319202006_fix_duplicate_org/migration.sqlscripts/build-docs.tssrc/docs/docs.htmlsrc/resolvers/alert.resolver.tssrc/resolvers/event.resolver.tssrc/resolvers/organisation.resolver.tssrc/resolvers/signal.resolver.tssrc/resolvers/team.resolver.tssrc/schema/typeDefs/mutation.tssrc/schema/typeDefs/query.tssrc/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
| -- 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; |
There was a problem hiding this comment.
🧩 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/ | sortRepository: CLEAR-Initiative/clear-api
Length of output: 1186
🏁 Script executed:
cat prisma/migrations/20260319000000_add_teams_and_evolve_orgs/migration.sqlRepository: 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.
| 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, | ||
| }); | ||
| }, |
There was a problem hiding this comment.
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.
| 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", | ||
| }, | ||
| }); | ||
| }, |
There was a problem hiding this comment.
Handle duplicate membership and verify target user exists.
Two edge cases can produce unhelpful Prisma errors instead of clean GraphQL errors:
- If
args.userIdis already a member, Prisma throws a unique constraint violation (P2002). - If
args.userIddoesn't exist in theusertable, 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.
| return context.prisma.teamMembers.create({ | ||
| data: { | ||
| teamId: args.teamId, | ||
| userId: args.userId, | ||
| role: args.role ?? "viewer", | ||
| }, | ||
| }); | ||
| }, |
There was a problem hiding this comment.
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.
| 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.
Summary
organisationstable (slug, isActive, timestamps), addteams,teamMembers, andteamLocationstables for flexible geographic scoping. Teams can be scoped to any level of the location hierarchy (country, state, locality) — decoupled from org structure.resolveTeamMembershipguard,buildLocationFilterForTeamutility using PostgreSQL recursive CTE to expand location hierarchy, anddefaultTeamIdon user for frontend convenience.myOrganisations,myTeams), and full CRUD mutations. Updatesignals,events,alertsqueries to accept explicitteamIdargument for stateless, per-request location filtering.Design decisions
tenantIdstamped on rows. Filtering happens at query time based on the team's location scope.teamIdis an explicit query argument (not hidden server state), making each request self-contained and cacheable.defaultTeamIdon user is just a frontend convenience to remember last-selected team.Test plan
bun run typecheckpasses (only pre-existing nodemailer errors)signals(teamId: "...")returns scoped resultssignals()as global admin → returns all signalssignals()as non-admin without teamId → returnsBAD_USER_INPUTerror🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Chores