Skip to content

feat(organizations): GET + POST + DELETE /api/organizations/domains#749

Merged
sweetmantech merged 4 commits into
mainfrom
feat/org-domain-endpoints
Jul 3, 2026
Merged

feat(organizations): GET + POST + DELETE /api/organizations/domains#749
sweetmantech merged 4 commits into
mainfrom
feat/org-domain-endpoints

Conversation

@sweetmantech

@sweetmantech sweetmantech commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements the organization domain-mapping endpoints from the tracking issue recoupable/chat#1841, per the OpenAPI contract in recoupable/docs#261.

Merge order: recoupable/docs#261 (contract) merges first, then this PR.

Rows in organization_domains drive email-domain auto-join on login/signup (lib/organizations/assignAccountToOrg.ts via selectOrgByDomain). These endpoints let org members manage those mappings.

Endpoints (app/api/organizations/domains/route.ts)

  • GET ?organization_id=<uuid>: lists the org's mapped domains. 200 {status:"success", domains:[{id, domain, organization_id, created_at}]}
  • POST {organizationId, domain}: maps a domain to the org. The domain is normalized (lowercase, trimmed, leading @ stripped) and must be a plausible bare domain (at least one dot; no spaces, slashes, or @). Idempotent when already mapped to the same org (returns the existing row). Returns 409 when the domain is mapped to a different org, since selectOrgByDomain uses .single() and duplicate rows would break auto-join. 200 {status:"success", id, domain, organization_id}
  • DELETE ?organization_id=<uuid>&domain=<domain>: removes the mapping, idempotent. 200 {status:"success"}

Errors on all: 400 invalid params, 401 unauthenticated (via validateAuthContext), 403 not a member, shape {status:"error", message} per the contract's OrganizationsErrorResponse.

Access control

Caller must be a member of the organization (or the org account itself) or a Recoup admin. New lib/organizations/canManageOrganization.ts composes the existing validateOrganizationAccess + isRecoupAdmin helpers (validateAuthContext's built-in org check alone would 403 Recoup admins who are not members).

New supabase functions (lib/supabase/organization_domains/)

selectOrganizationDomains (by org), selectOrganizationDomain (by domain, for the 409/idempotency check), insertOrganizationDomain, deleteOrganizationDomain, alongside the existing selectOrgByDomain.

Verification

  • TDD red -> green for every unit (12 new test files, 56 new tests)
  • Full suite: 695 files / 3818 tests passed, 0 failed (was 683/3762 before this PR)
  • tsc --noEmit: 200 errors before, 200 after (all pre-existing, zero new)
  • pnpm lint and pnpm format:check clean

Contract deviations

  • 401 responses produced inside the shared validateAuthContext helper use the pre-existing {status:"error", error} key instead of message. Changing that helper would touch every authenticated endpoint, so it is left as-is; all responses emitted by the new handlers/validators (400/403/409/500) follow the contract's {status:"error", message} shape.

Part of recoupable/chat#1841. Contract: recoupable/docs#261.

🤖 Generated with Claude Code


Summary by cubic

Adds GET, POST, and DELETE /api/organizations/domains so org members can manage email‑domain auto‑join mappings, per recoupable/docs#261. Also standardizes errors to a shared {status:"error", error} shape and hardens conflict handling to avoid double-mapping.

  • New Features

    • Endpoints: GET list, POST map, DELETE unmap organization domains.
    • Domain normalization; idempotent POST/DELETE; 409 on cross‑org conflict.
    • Access: org member or Recoup admin via canManageOrganization; CORS preflight supported.
    • Supabase helpers for select/insert/delete domain mappings.
  • Refactors

    • Replaced canManageOrgMembers with canManageOrganization; validators now use it.
    • Moved auth/access into validateGetOrgDomainsRequest, validateAddOrgDomainRequest, validateRemoveOrgDomainRequest.
    • Standardized error bodies to shared errorResponse ({status:"error", error}); status codes and success shapes unchanged.
    • selectOrganizationDomain now throws on query errors to prevent treating failures as “not mapped”; 500s return a generic "Internal server error" with leak‑regression tests.

Written for commit b10879e. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added organization domain management endpoints to view, add, and remove email-domain mappings.
    • Added automatic domain normalization and validation for cleaner, more consistent input.
  • Bug Fixes

    • Improved access checks so only authorized users can manage organization domains.
    • Added better conflict handling and idempotent behavior when re-adding existing domains.
  • Style

    • Added CORS support for preflight requests and API responses across domain management routes.

GET, POST, and DELETE /api/organizations/domains per the contract in
recoupable/docs#261. Rows in organization_domains drive email-domain
auto-join (assignAccountToOrg / selectOrgByDomain).

- GET lists an org's mapped domains (member or Recoup admin only)
- POST maps a domain to an org: normalizes (lowercase, trim, strip
  leading @), idempotent for the same org, 409 when the domain is
  already mapped to a different org (selectOrgByDomain uses .single(),
  duplicates would break auto-join)
- DELETE unmaps a domain, idempotent
- New supabase functions under lib/supabase/organization_domains/
- canManageOrganization helper: org member or Recoup admin

Part of recoupable/chat#1841

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
api Ready Ready Preview Jul 3, 2026 7:05pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@sweetmantech, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 26 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 42abd289-50cf-49f1-8d5f-32e3a8db674d

📥 Commits

Reviewing files that changed from the base of the PR and between 746ebfc and b10879e.

⛔ Files ignored due to path filters (12)
  • lib/organizations/__tests__/addOrgDomainHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/organizations/__tests__/addOrgMemberHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/organizations/__tests__/canManageOrgMembers.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/organizations/__tests__/getOrgDomainsHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/organizations/__tests__/removeOrgDomainHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/organizations/__tests__/removeOrgMemberHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/organizations/__tests__/validateAddOrgDomainRequest.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/organizations/__tests__/validateAddOrgMemberRequest.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/organizations/__tests__/validateGetOrgDomainsRequest.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/organizations/__tests__/validateRemoveOrgDomainRequest.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/organizations/__tests__/validateRemoveOrgMemberRequest.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/supabase/organization_domains/__tests__/selectOrganizationDomain.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (13)
  • lib/organizations/addOrgDomainHandler.ts
  • lib/organizations/addOrgMemberHandler.ts
  • lib/organizations/canManageOrgMembers.ts
  • lib/organizations/canManageOrganization.ts
  • lib/organizations/getOrgDomainsHandler.ts
  • lib/organizations/removeOrgDomainHandler.ts
  • lib/organizations/removeOrgMemberHandler.ts
  • lib/organizations/validateAddOrgDomainRequest.ts
  • lib/organizations/validateAddOrgMemberRequest.ts
  • lib/organizations/validateGetOrgDomainsRequest.ts
  • lib/organizations/validateRemoveOrgDomainRequest.ts
  • lib/organizations/validateRemoveOrgMemberRequest.ts
  • lib/supabase/organization_domains/selectOrganizationDomain.ts
📝 Walkthrough

Walkthrough

This PR adds a new /api/organizations/domains API route supporting OPTIONS, GET, POST, and DELETE methods for managing email-domain-to-organization mappings. It introduces validation schemas, an authorization helper, domain normalization, and Supabase CRUD functions for the organization_domains table.

Changes

Organization Domains Feature

Layer / File(s) Summary
Route wiring and CORS
app/api/organizations/domains/route.ts
Adds OPTIONS, GET, POST, DELETE handlers with CORS support delegating to dedicated handler functions.
Domain normalization and authorization helpers
lib/organizations/normalizeOrgDomain.ts, lib/organizations/canManageOrganization.ts
Adds a domain string normalizer and an authorization helper combining organization access checks with an admin fallback.
Supabase organization_domains data access
lib/supabase/organization_domains/*
Adds select (single/list), insert, and delete helpers for the organization_domains table with error logging.
GET domains: query validation and handler
lib/organizations/validateGetOrgDomainsQuery.ts, lib/organizations/getOrgDomainsHandler.ts
Validates organization_id query param and lists domains for an organization with auth checks and structured responses.
POST domain: body validation and handler
lib/organizations/validateAddOrgDomainBody.ts, lib/organizations/addOrgDomainHandler.ts
Validates request body, detects domain conflicts (409), supports idempotent reuse, and inserts new mappings.
DELETE domain: query validation and handler
lib/organizations/validateRemoveOrgDomainQuery.ts, lib/organizations/removeOrgDomainHandler.ts
Validates query params and deletes the domain mapping with auth checks and structured responses.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Route as "domains/route.ts"
    participant Handler as "addOrgDomainHandler"
    participant Auth as "canManageOrganization"
    participant DB as "Supabase organization_domains"

    Client->>Route: POST /api/organizations/domains
    Route->>Handler: addOrgDomainHandler(request)
    Handler->>Handler: validateAuthContext + validateAddOrgDomainBody
    Handler->>Auth: canManageOrganization(accountId, organizationId)
    Auth-->>Handler: true/false
    alt access denied
        Handler-->>Client: 403 error
    else access allowed
        Handler->>DB: selectOrganizationDomain(domain)
        DB-->>Handler: existing row or null
        alt domain mapped to different org
            Handler-->>Client: 409 conflict
        else no conflict
            Handler->>DB: insertOrganizationDomain(domain, organizationId)
            DB-->>Handler: inserted row or null
            Handler-->>Client: 200 success / 500 error
        end
    end
Loading

Possibly related issues

Poem

A domain hops in, all lowercase and trim,
Checked for @ signs, and access grows slim.
With CORS on its wings and Zod as its guide,
It lands in Supabase, mapping applied. 🐰
Add, list, remove — a tidy little dance,
Three-oh-nine or four-oh-three, given half a chance!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Solid & Clean Code ⚠️ Warning FAIL: route.ts exports 4 handlers, and add/get/removeOrgDomainHandler are 65-87 lines, violating the one-primary-function and keep-functions-small rules. Split route.ts and refactor the three domain handlers into smaller helpers/shared utilities so each file has one primary function and functions stay under 20 lines.
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/org-domain-endpoints

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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

12 issues found across 25 files

Confidence score: 2/5

  • The highest risk is in lib/organizations/addOrgDomainHandler.ts, lib/organizations/getOrgDomainsHandler.ts, and lib/organizations/removeOrgDomainHandler.ts: 500-path catch blocks return raw exception messages, which can expose internal DB/query/path details to clients if errors occur in production — replace these responses with a hardcoded safe message (for example, "Internal server error") and keep full details only in server logs before merging.
  • lib/organizations/__tests__/addOrgDomainHandler.test.ts, lib/organizations/__tests__/getOrgDomainsHandler.test.ts, and lib/organizations/__tests__/removeOrgDomainHandler.test.ts do not adequately cover the catch-block failure paths, so this leak can regress unnoticed — add explicit tests that simulate thrown errors and assert sanitized 500 bodies to de-risk future changes.
  • There is some non-blocking test/maintainability cleanup (lib/organizations/__tests__/validateAddOrgDomainBody.test.ts missing a status assertion and lib/supabase/organization_domains/__tests__/insertOrganizationDomain.test.ts not reliably restoring console.error) that could hide failures or cause flaky side effects — tighten these assertions and restore mocks in test hooks as follow-up or in this PR if quick.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="lib/supabase/organization_domains/__tests__/insertOrganizationDomain.test.ts">

<violation number="1" location="lib/supabase/organization_domains/__tests__/insertOrganizationDomain.test.ts:57">
P2: The `console.error` spy in the error-path test won't be restored if an assertion fails before `mockRestore()`, potentially silencing real `console.error` calls in subsequent tests. Switch `beforeEach` from `vi.clearAllMocks()` to `vi.restoreAllMocks()` — it automatically restores all spies after each test and eliminates the need for manual `mockRestore()` calls entirely.</violation>
</file>

<file name="lib/supabase/organization_domains/__tests__/selectOrganizationDomains.test.ts">

<violation number="1" location="lib/supabase/organization_domains/__tests__/selectOrganizationDomains.test.ts:30">
P3: None of the 3 tests assert that `.select()` was called, let alone with `"*"`. If the implementation changed to select a subset of columns, these tests would not catch it. Add `expect(selectFn).toHaveBeenCalledWith("*")` to the first test.</violation>
</file>

<file name="lib/supabase/organization_domains/__tests__/deleteOrganizationDomain.test.ts">

<violation number="1" location="lib/supabase/organization_domains/__tests__/deleteOrganizationDomain.test.ts:36">
P3: `console.error` spy cleanup won't run if an assertion fails. Move `consoleError.mockRestore()` to an `afterEach` block or use a try/finally to prevent spy leaks on test failure.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Client
    participant Route as /api/organizations/domains (route.ts)
    participant Handler as Handler (get/add/removeOrgDomainsHandler)
    participant Auth as validateAuthContext
    participant Access as canManageOrganization
    participant Validator as ValidateQueryOrBody
    participant Supabase as Supabase Helper (select/insert/delete)
    participant DB as organization_domains table
    participant AutoJoin as assignAccountToOrg (existing flow)

    Note over Client,AutoJoin: NEW: Domain mapping endpoints (GET/POST/DELETE)

    Client->>Route: HTTP request (GET/POST/DELETE)
    Route->>Handler: Forward to appropriate handler

    alt POST /api/organizations/domains
        Handler->>Auth: validateAuthContext(request)
        alt Unauthenticated
            Auth-->>Handler: 401 NextResponse
            Handler-->>Route: 401 {status:"error", error:"..."}
            Route-->>Client: 401
        else Authenticated
            Auth-->>Handler: {accountId, orgId, authToken}
            Handler->>Validator: validateAddOrgDomainBody(body)
            alt Invalid body
                Validator-->>Handler: 400 NextResponse
                Handler-->>Route: 400 {status:"error", message:"..."}
                Route-->>Client: 400
            else Valid body
                Validator-->>Handler: {organizationId, domain} (domain normalized)
                Handler->>Access: canManageOrganization({accountId, organizationId})
                alt No access
                    Access-->>Handler: false
                    Handler-->>Route: 403 {status:"error", message:"Access denied..."}
                    Route-->>Client: 403
                else Has access
                    Access-->>Handler: true
                    Handler->>Supabase: selectOrganizationDomain(normalizedDomain)
                    Supabase->>DB: SELECT * FROM organization_domains WHERE domain = ?
                    DB-->>Supabase: row or null
                    Supabase-->>Handler: row or null
                    alt Domain found and belongs to different org
                        Handler-->>Route: 409 {status:"error", message:"already mapped..."}
                        Route-->>Client: 409
                    else Domain not found or same org
                        opt Domain not found
                            Handler->>Supabase: insertOrganizationDomain({domain, organizationId})
                            Supabase->>DB: INSERT INTO organization_domains ... RETURNING *
                            DB-->>Supabase: new row
                            Supabase-->>Handler: new row
                        end
                        Handler-->>Route: 200 {status:"success", id, domain, organization_id}
                        Route-->>Client: 200
                    end
                end
            end
        end

    else GET /api/organizations/domains
        Handler->>Auth: validateAuthContext(request)
        alt Unauthenticated
            Auth-->>Handler: 401
            Handler-->>Route: 401
            Route-->>Client: 401
        else Authenticated
            Auth-->>Handler: auth context
            Handler->>Validator: validateGetOrgDomainsQuery(request)
            alt Invalid query
                Validator-->>Handler: 400
                Handler-->>Route: 400
                Route-->>Client: 400
            else Valid query
                Validator-->>Handler: {organization_id}
                Handler->>Access: canManageOrganization(...)
                alt No access
                    Access-->>Handler: false
                    Handler-->>Route: 403
                    Route-->>Client: 403
                else Has access
                    Access-->>Handler: true
                    Handler->>Supabase: selectOrganizationDomains(orgId)
                    Supabase->>DB: SELECT * FROM organization_domains WHERE organization_id = ? ORDER BY created_at
                    DB-->>Supabase: rows[]
                    alt DB error
                        Supabase-->>Handler: null
                        Handler-->>Route: 500 {status:"error", message:"..."}
                        Route-->>Client: 500
                    else Success
                        Supabase-->>Handler: domains[]
                        Handler-->>Route: 200 {status:"success", domains}
                        Route-->>Client: 200
                    end
                end
            end
        end

    else DELETE /api/organizations/domains
        Handler->>Auth: validateAuthContext(request)
        alt Unauthenticated
            Auth-->>Handler: 401
            Handler-->>Route: 401
            Route-->>Client: 401
        else Authenticated
            Auth-->>Handler: auth context
            Handler->>Validator: validateRemoveOrgDomainQuery(request)
            alt Invalid query
                Validator-->>Handler: 400
                Handler-->>Route: 400
                Route-->>Client: 400
            else Valid query
                Validator-->>Handler: {organization_id, domain} (domain normalized)
                Handler->>Access: canManageOrganization(...)
                alt No access
                    Access-->>Handler: false
                    Handler-->>Route: 403
                    Route-->>Client: 403
                else Has access
                    Access-->>Handler: true
                    Handler->>Supabase: deleteOrganizationDomain({domain, organizationId})
                    Supabase->>DB: DELETE FROM organization_domains WHERE organization_id = ? AND domain = ?
                    alt DB error
                        DB-->>Supabase: error
                        Supabase-->>Handler: false
                        Handler-->>Route: 500
                        Route-->>Client: 500
                    else Success
                        DB-->>Supabase: success
                        Supabase-->>Handler: true
                        Handler-->>Route: 200 {status:"success"}
                        Route-->>Client: 200
                    end
                end
            end
        end
    end

    Note over AutoJoin: EXISTING: Domain auto-join on login/signup
    AutoJoin->>Supabase: selectOrgByDomain(domain)
    Supabase->>DB: SELECT ... WHERE domain = ? (single row)
    DB-->>Supabase: row (or error if multiple found)
    Note over AutoJoin,DB: NEW: 409 on POST prevents duplicate rows breaking .single()
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread lib/organizations/addOrgDomainHandler.ts Outdated
Comment thread lib/organizations/removeOrgDomainHandler.ts Outdated
Comment thread lib/organizations/getOrgDomainsHandler.ts Outdated
Comment thread lib/organizations/__tests__/validateAddOrgDomainBody.test.ts Outdated

expect(result).toBeNull();
expect(consoleError).toHaveBeenCalled();
consoleError.mockRestore();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The console.error spy in the error-path test won't be restored if an assertion fails before mockRestore(), potentially silencing real console.error calls in subsequent tests. Switch beforeEach from vi.clearAllMocks() to vi.restoreAllMocks() — it automatically restores all spies after each test and eliminates the need for manual mockRestore() calls entirely.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/supabase/organization_domains/__tests__/insertOrganizationDomain.test.ts, line 57:

<comment>The `console.error` spy in the error-path test won't be restored if an assertion fails before `mockRestore()`, potentially silencing real `console.error` calls in subsequent tests. Switch `beforeEach` from `vi.clearAllMocks()` to `vi.restoreAllMocks()` — it automatically restores all spies after each test and eliminates the need for manual `mockRestore()` calls entirely.</comment>

<file context>
@@ -0,0 +1,59 @@
+
+    expect(result).toBeNull();
+    expect(consoleError).toHaveBeenCalled();
+    consoleError.mockRestore();
+  });
+});
</file context>

Comment thread lib/organizations/validateAddOrgDomainBody.ts Outdated
Comment thread lib/organizations/__tests__/removeOrgDomainHandler.test.ts
Comment thread lib/organizations/__tests__/addOrgDomainHandler.test.ts
const orderFn = vi.fn().mockResolvedValue({ data: rows, error: null });
const eqFn = vi.fn().mockReturnValue({ order: orderFn });
vi.mocked(supabase.from).mockReturnValue({
select: vi.fn().mockReturnValue({ eq: eqFn }),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: None of the 3 tests assert that .select() was called, let alone with "*". If the implementation changed to select a subset of columns, these tests would not catch it. Add expect(selectFn).toHaveBeenCalledWith("*") to the first test.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/supabase/organization_domains/__tests__/selectOrganizationDomains.test.ts, line 30:

<comment>None of the 3 tests assert that `.select()` was called, let alone with `"*"`. If the implementation changed to select a subset of columns, these tests would not catch it. Add `expect(selectFn).toHaveBeenCalledWith("*")` to the first test.</comment>

<file context>
@@ -0,0 +1,66 @@
+    const orderFn = vi.fn().mockResolvedValue({ data: rows, error: null });
+    const eqFn = vi.fn().mockReturnValue({ order: orderFn });
+    vi.mocked(supabase.from).mockReturnValue({
+      select: vi.fn().mockReturnValue({ eq: eqFn }),
+    } as never);
+
</file context>

});

it("returns false on delete error", async () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: console.error spy cleanup won't run if an assertion fails. Move consoleError.mockRestore() to an afterEach block or use a try/finally to prevent spy leaks on test failure.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/supabase/organization_domains/__tests__/deleteOrganizationDomain.test.ts, line 36:

<comment>`console.error` spy cleanup won't run if an assertion fails. Move `consoleError.mockRestore()` to an `afterEach` block or use a try/finally to prevent spy leaks on test failure.</comment>

<file context>
@@ -0,0 +1,51 @@
+  });
+
+  it("returns false on delete error", async () => {
+    const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
+    const eqDomainFn = vi.fn().mockResolvedValue({ error: { message: "boom" } });
+    const eqOrgFn = vi.fn().mockReturnValue({ eq: eqDomainFn });
</file context>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (8)
lib/organizations/validateRemoveOrgDomainQuery.ts (2)

7-12: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Deprecated Zod v4 constructor params.

{ message: "..." } and the chained .uuid() string method are deprecated in Zod v4 in favor of the unified error param and top-level z.uuid(). They still work today, but worth migrating to the recommended v4 API for consistency with the rest of the codebase going forward.

[dependency_check]

  • Flagging: Zod v4 constructor error param (message vs error) and chained .uuid() vs top-level z.uuid().
  • Verified: per Zod v4 changelog, "Replaces message param with error. The old message parameter is still supported but deprecated."
♻️ Suggested modernization
 export const removeOrgDomainQuerySchema = z.object({
   organization_id: z
-    .string({ message: "organization_id is required" })
-    .uuid("organization_id must be a valid UUID"),
-  domain: z.string({ message: "domain is required" }),
+    .string({ error: "organization_id is required" })
+    .pipe(z.uuid({ error: "organization_id must be a valid UUID" })),
+  domain: z.string({ error: "domain is required" }),
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/organizations/validateRemoveOrgDomainQuery.ts` around lines 7 - 12,
Update removeOrgDomainQuerySchema to use Zod v4’s unified error handling and
top-level UUID schema. Replace the z.string({ message: ... }) usage on
organization_id and domain with the error option, and swap the chained .uuid()
on organization_id for z.uuid() with the same validation message. Keep the
schema shape and field names unchanged while modernizing the constructor and
validation API.

20-25: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

badRequest helper is duplicated across validators.

The same badRequest implementation appears in validateAddOrgDomainBody.ts (per provided context snippet). Consider extracting this into a shared utility (e.g. lib/organizations/badRequest.ts or a networking helper) to avoid drift between validators.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/organizations/validateRemoveOrgDomainQuery.ts` around lines 20 - 25, The
badRequest helper is duplicated across organization validators and should be
centralized to prevent drift. Extract the shared NextResponse JSON error builder
used by validateRemoveOrgDomainQuery and validateAddOrgDomainBody into a common
utility such as badRequest.ts, then update both validators to import and use
that shared helper instead of keeping separate implementations.
lib/organizations/getOrgDomainsHandler.ts (1)

35-63: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

DRY: extract the repeated error-response construction.

The NextResponse.json({ status: "error", message }, { status, headers: getCorsHeaders() }) shape is duplicated for the 403, 500, and catch-all paths here and almost certainly across the sibling handlers in this cohort. A small shared helper (e.g. errorJson(message, status)) would keep the CORS/status/shape consistent and reduce copy-paste drift.

As per coding guidelines: "Extract shared logic into reusable utilities following Don't Repeat Yourself (DRY) principle."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/organizations/getOrgDomainsHandler.ts` around lines 35 - 63, The
error-response construction in getOrgDomainsHandler is duplicated across the
403, 500, and catch-all paths, so extract it into a shared helper such as
errorJson(message, status) and use that in this handler. Make the helper return
the standardized NextResponse.json error payload with getCorsHeaders() so the
shape, headers, and status stay consistent. If there are sibling handlers with
the same pattern, update them to use the same helper to avoid copy-paste drift.

Source: Coding guidelines

lib/organizations/validateGetOrgDomainsQuery.ts (1)

6-10: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Prefer z.uuid() here. z.string({ message: ... }) still works in Zod 4, so only the UUID validator needs updating.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/organizations/validateGetOrgDomainsQuery.ts` around lines 6 - 10, The
getOrgDomainsQuerySchema validator still uses z.string(...).uuid(...) for
organization_id, but the review asks to switch to z.uuid() directly. Update the
organization_id field in getOrgDomainsQuerySchema to use z.uuid() and keep the
existing required/error messaging behavior consistent with the schema.
lib/organizations/validateAddOrgDomainBody.ts (3)

19-24: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate badRequest helper across validators.

The same badRequest(message) shape (400 JSON + getCorsHeaders()) appears to be reimplemented in lib/organizations/validateRemoveOrgDomainQuery.ts per the provided cross-file snippets. Extract this into a shared utility (e.g. lib/networking/badRequest.ts) to avoid drift between validators.

As per path instructions, "DRY: Consolidate similar logic into shared utilities."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/organizations/validateAddOrgDomainBody.ts` around lines 19 - 24, The
badRequest(message) helper is duplicated across the organization domain
validators, so extract the shared 400 JSON response with getCorsHeaders() into a
reusable utility and have both validateAddOrgDomainBody and
validateRemoveOrgDomainQuery call it. Move the common response-building logic
into a shared helper such as badRequest in a networking utility, then replace
the local helper definitions with that shared function to keep the response
shape consistent and avoid drift.

Source: Path instructions


13-17: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Prefer inferred type over hand-written interface.

AddOrgDomainBody is manually authored. Path instructions for lib/**/validate*.ts files call for exporting the schema's inferred type so the two never drift as the schema evolves.

♻️ Suggested fix
-export interface AddOrgDomainBody {
-  organizationId: string;
-  /** The normalized bare email domain (lowercase, no "@") */
-  domain: string;
-}
+export type AddOrgDomainBody = z.infer<typeof addOrgDomainBodySchema>;

As per path instructions, "Create validate functions ... that export both the schema and inferred TypeScript type."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/organizations/validateAddOrgDomainBody.ts` around lines 13 - 17, The
AddOrgDomainBody type is hand-written and can drift from the validation schema;
update validateAddOrgDomainBody to export the schema’s inferred TypeScript type
instead of a manual interface. Use the existing validation schema symbol in this
module to derive the type, and keep the exported name AddOrgDomainBody so
callers continue to reference the same type while staying aligned with the
schema as it evolves.

Source: Path instructions


6-11: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Using deprecated Zod 4 APIs.

.string().uuid(...) and the { message: ... } constructor param are both deprecated in Zod 4 in favor of the top-level z.uuid() and the unified { error: ... } param. They still work today but will be removed in a future major version, and z.uuid() in v4 is also stricter (validates RFC 9562/4122 variant bits) — worth confirming that's the desired behavior for existing organizationId values.

♻️ Suggested modernization
 export const addOrgDomainBodySchema = z.object({
-  organizationId: z
-    .string({ message: "organizationId is required" })
-    .uuid("organizationId must be a valid UUID"),
-  domain: z.string({ message: "domain is required" }),
+  organizationId: z.uuid({ error: "organizationId must be a valid UUID" }),
+  domain: z.string({ error: "domain is required" }),
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/organizations/validateAddOrgDomainBody.ts` around lines 6 - 11, The
addOrgDomainBodySchema uses deprecated Zod 4 patterns for validation messages
and UUID checks. Update the organizationId field to use the top-level z.uuid()
with the unified { error: ... } option instead of z.string(...).uuid(...), and
switch the domain field constructor to the unified error parameter as well. Keep
the changes localized in addOrgDomainBodySchema and confirm the stricter RFC
9562/4122 UUID validation still matches expected organizationId values.

Source: Path instructions

lib/organizations/addOrgDomainHandler.ts (1)

41-44: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Repeated error-response boilerplate.

The {status:"error", message} + status code + getCorsHeaders() shape is repeated four times. Extracting a small helper (similar to badRequest in the validator file) would reduce duplication and centralize the response contract.

As per path instructions, "DRY: Extract common validation and error handling logic."

Also applies to: 50-56, 62-65, 79-84

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/organizations/addOrgDomainHandler.ts` around lines 41 - 44, The
error-response pattern in addOrgDomainHandler is duplicated across multiple
branches, so extract a small helper to centralize it. Create a reusable response
helper for the common NextResponse.json({ status: "error", message }, { status,
headers: getCorsHeaders() }) shape, similar to the validator file’s badRequest
helper, and use it in the access-denied and other validation/error paths in
addOrgDomainHandler to keep the response contract consistent and DRY.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/organizations/addOrgDomainHandler.ts`:
- Around line 22-87: The addOrgDomainHandler function is too long and should be
refactored to fit the function-length limits. Extract the repeated
NextResponse.json error निर्माण for the 403/409/500 cases into a small helper
such as errorResponse, and move the existing-or-insert domain lookup logic
around selectOrganizationDomain and insertOrganizationDomain into a separate
helper. Keep addOrgDomainHandler focused on auth, validation, and orchestration
while preserving the same behavior and response shapes.
- Around line 77-86: The catch block in addOrgDomainHandler is leaking raw
exception details by returning error.message to the client. Keep the full error
logged server-side with console.error, but change the NextResponse.json payload
to always return a generic message for failures instead of using the caught
Error’s message. Use the addOrgDomainHandler error handling path and the
existing error instanceof Error branch as the place to update this behavior.
- Around line 47-59: The domain mapping check in addOrgDomainHandler is racy
because selectOrganizationDomain and insertOrganizationDomain are separate
operations, so concurrent requests can both pass the pre-check. Move the
one-domain-per-organization guarantee into the database by enforcing uniqueness
on organization_domains.domain, then change the addOrgDomainHandler flow to use
an atomic insert/upsert path and handle duplicate-key conflicts by returning the
existing conflict response. Keep the existing logic around validated.domain and
validated.organizationId, but make the insert path safe under concurrency.

In `@lib/organizations/getOrgDomainsHandler.ts`:
- Around line 55-63: The 500 response in getOrgDomainsHandler is exposing
internal details by returning error.message to callers. Keep the full error only
in the existing server-side console.error inside getOrgDomainsHandler, and
change the NextResponse.json payload to return a generic client-facing message
instead of using error.message when handling the catch block.

In `@lib/supabase/organization_domains/selectOrganizationDomain.ts`:
- Around line 14-23: The selectOrganizationDomain lookup currently returns null
for Supabase query failures, which makes addOrgDomainHandler treat an actual
error as “not mapped” and continue to insert. Update selectOrganizationDomain to
distinguish “no row found” from “query failed” by throwing the Supabase error or
returning an error-aware result instead of null, and then adjust the
addOrgDomainHandler call site to handle that distinct failure path without
falling through to insertOrganizationDomain.

---

Nitpick comments:
In `@lib/organizations/addOrgDomainHandler.ts`:
- Around line 41-44: The error-response pattern in addOrgDomainHandler is
duplicated across multiple branches, so extract a small helper to centralize it.
Create a reusable response helper for the common NextResponse.json({ status:
"error", message }, { status, headers: getCorsHeaders() }) shape, similar to the
validator file’s badRequest helper, and use it in the access-denied and other
validation/error paths in addOrgDomainHandler to keep the response contract
consistent and DRY.

In `@lib/organizations/getOrgDomainsHandler.ts`:
- Around line 35-63: The error-response construction in getOrgDomainsHandler is
duplicated across the 403, 500, and catch-all paths, so extract it into a shared
helper such as errorJson(message, status) and use that in this handler. Make the
helper return the standardized NextResponse.json error payload with
getCorsHeaders() so the shape, headers, and status stay consistent. If there are
sibling handlers with the same pattern, update them to use the same helper to
avoid copy-paste drift.

In `@lib/organizations/validateAddOrgDomainBody.ts`:
- Around line 19-24: The badRequest(message) helper is duplicated across the
organization domain validators, so extract the shared 400 JSON response with
getCorsHeaders() into a reusable utility and have both validateAddOrgDomainBody
and validateRemoveOrgDomainQuery call it. Move the common response-building
logic into a shared helper such as badRequest in a networking utility, then
replace the local helper definitions with that shared function to keep the
response shape consistent and avoid drift.
- Around line 13-17: The AddOrgDomainBody type is hand-written and can drift
from the validation schema; update validateAddOrgDomainBody to export the
schema’s inferred TypeScript type instead of a manual interface. Use the
existing validation schema symbol in this module to derive the type, and keep
the exported name AddOrgDomainBody so callers continue to reference the same
type while staying aligned with the schema as it evolves.
- Around line 6-11: The addOrgDomainBodySchema uses deprecated Zod 4 patterns
for validation messages and UUID checks. Update the organizationId field to use
the top-level z.uuid() with the unified { error: ... } option instead of
z.string(...).uuid(...), and switch the domain field constructor to the unified
error parameter as well. Keep the changes localized in addOrgDomainBodySchema
and confirm the stricter RFC 9562/4122 UUID validation still matches expected
organizationId values.

In `@lib/organizations/validateGetOrgDomainsQuery.ts`:
- Around line 6-10: The getOrgDomainsQuerySchema validator still uses
z.string(...).uuid(...) for organization_id, but the review asks to switch to
z.uuid() directly. Update the organization_id field in getOrgDomainsQuerySchema
to use z.uuid() and keep the existing required/error messaging behavior
consistent with the schema.

In `@lib/organizations/validateRemoveOrgDomainQuery.ts`:
- Around line 7-12: Update removeOrgDomainQuerySchema to use Zod v4’s unified
error handling and top-level UUID schema. Replace the z.string({ message: ... })
usage on organization_id and domain with the error option, and swap the chained
.uuid() on organization_id for z.uuid() with the same validation message. Keep
the schema shape and field names unchanged while modernizing the constructor and
validation API.
- Around line 20-25: The badRequest helper is duplicated across organization
validators and should be centralized to prevent drift. Extract the shared
NextResponse JSON error builder used by validateRemoveOrgDomainQuery and
validateAddOrgDomainBody into a common utility such as badRequest.ts, then
update both validators to import and use that shared helper instead of keeping
separate implementations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 204cf44f-d863-4008-8204-370ad78f0eb5

📥 Commits

Reviewing files that changed from the base of the PR and between b41a1f7 and 746ebfc.

⛔ Files ignored due to path filters (12)
  • lib/organizations/__tests__/addOrgDomainHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/organizations/__tests__/canManageOrganization.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/organizations/__tests__/getOrgDomainsHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/organizations/__tests__/normalizeOrgDomain.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/organizations/__tests__/removeOrgDomainHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/organizations/__tests__/validateAddOrgDomainBody.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/organizations/__tests__/validateGetOrgDomainsQuery.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/organizations/__tests__/validateRemoveOrgDomainQuery.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/supabase/organization_domains/__tests__/deleteOrganizationDomain.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/supabase/organization_domains/__tests__/insertOrganizationDomain.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/supabase/organization_domains/__tests__/selectOrganizationDomain.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/supabase/organization_domains/__tests__/selectOrganizationDomains.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (13)
  • app/api/organizations/domains/route.ts
  • lib/organizations/addOrgDomainHandler.ts
  • lib/organizations/canManageOrganization.ts
  • lib/organizations/getOrgDomainsHandler.ts
  • lib/organizations/normalizeOrgDomain.ts
  • lib/organizations/removeOrgDomainHandler.ts
  • lib/organizations/validateAddOrgDomainBody.ts
  • lib/organizations/validateGetOrgDomainsQuery.ts
  • lib/organizations/validateRemoveOrgDomainQuery.ts
  • lib/supabase/organization_domains/deleteOrganizationDomain.ts
  • lib/supabase/organization_domains/insertOrganizationDomain.ts
  • lib/supabase/organization_domains/selectOrganizationDomain.ts
  • lib/supabase/organization_domains/selectOrganizationDomains.ts

Comment thread lib/organizations/addOrgDomainHandler.ts
Comment on lines +47 to +59
const existing = await selectOrganizationDomain(validated.domain);

if (existing && existing.organization_id !== validated.organizationId) {
return NextResponse.json(
{
status: "error",
message: `Domain "${validated.domain}" is already mapped to a different organization`,
},
{ status: 409, headers: getCorsHeaders() },
);
}

const row = existing ?? (await insertOrganizationDomain(validated));

@coderabbitai coderabbitai Bot Jul 3, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## File list\n'
git ls-files 'lib/organizations/*' 'lib/supabase/**' | sed -n '1,200p'

printf '\n## Outline: addOrgDomainHandler.ts\n'
ast-grep outline lib/organizations/addOrgDomainHandler.ts --view expanded || true

printf '\n## Search helpers\n'
rg -n "selectOrganizationDomain|insertOrganizationDomain|organization_domains|domain" lib/organizations lib/supabase -g '!**/*.map' || true

Repository: recoupable/api

Length of output: 37593


🏁 Script executed:

#!/bin/bash
set -euo pipefail
pwd
ls -la

Repository: recoupable/api

Length of output: 2193


🏁 Script executed:

#!/bin/bash
set -euo pipefail
git ls-files | rg '^lib/organizations/|^lib/supabase/' | sed -n '1,200p'

Repository: recoupable/api

Length of output: 10889


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## addOrgDomainHandler.ts\n'
cat -n lib/organizations/addOrgDomainHandler.ts | sed -n '1,220p'

printf '\n## organization_domains helpers\n'
for f in \
  lib/supabase/organization_domains/selectOrganizationDomain.ts \
  lib/supabase/organization_domains/insertOrganizationDomain.ts \
  lib/supabase/organization_domains/selectOrgByDomain.ts \
  lib/supabase/organization_domains/selectOrganizationDomains.ts
do
  printf '\n### %s\n' "$f"
  cat -n "$f" | sed -n '1,220p'
done

printf '\n## tests for insert/select\n'
for f in \
  lib/supabase/organization_domains/__tests__/insertOrganizationDomain.test.ts \
  lib/supabase/organization_domains/__tests__/selectOrganizationDomain.test.ts \
  lib/organizations/__tests__/addOrgDomainHandler.test.ts
do
  printf '\n### %s\n' "$f"
  cat -n "$f" | sed -n '1,260p'
done

Repository: recoupable/api

Length of output: 19865


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## schema/search for organization_domains\n'
rg -n "organization_domains|unique.*domain|domain.*unique|create table.*organization_domains|index.*organization_domains" . \
  -g '!**/node_modules/**' -g '!**/.git/**' -g '!**/dist/**' -g '!**/build/**' -g '!**/.next/**' || true

printf '\n## likely schema files\n'
git ls-files | rg 'supabase|schema|migration|sql|database' | sed -n '1,250p'

Repository: recoupable/api

Length of output: 13956


🏁 Script executed:

#!/bin/bash
set -euo pipefail

cat -n types/database.types.ts | sed -n '2168,2215p'

Repository: recoupable/api

Length of output: 1774


Make the domain mapping write atomic in lib/organizations/addOrgDomainHandler.ts:47-59. selectOrganizationDomain() and insertOrganizationDomain() are separate calls, so concurrent POSTs can both miss the pre-check and race the insert. Enforce uniqueness on organization_domains.domain and handle the insert with an upsert/duplicate-key path so the one-domain-per-organization invariant is owned by the database.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/organizations/addOrgDomainHandler.ts` around lines 47 - 59, The domain
mapping check in addOrgDomainHandler is racy because selectOrganizationDomain
and insertOrganizationDomain are separate operations, so concurrent requests can
both pass the pre-check. Move the one-domain-per-organization guarantee into the
database by enforcing uniqueness on organization_domains.domain, then change the
addOrgDomainHandler flow to use an atomic insert/upsert path and handle
duplicate-key conflicts by returning the existing conflict response. Keep the
existing logic around validated.domain and validated.organizationId, but make
the insert path safe under concurrency.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed the invariant belongs in the database — but that's a database submodule migration (unique on organization_domains.domain, plus account_organization_ids(account_id, organization_id) for the same race on the members endpoint), not app-level code in this PR. b10879e closes the adjacent misclassification hole (select error no longer falls through to insert). Filing a follow-up issue for the constraints + upsert conversion.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@sweetmantech That's a reasonable scoping decision — the uniqueness invariant really does belong at the database schema level (in the database submodule) rather than being simulated with app-level check-then-insert logic here, and consolidating it with the analogous account_organization_ids(account_id, organization_id) race on the members endpoint into one migration makes sense. Good catch on b10879e closing the select-error misclassification gap in the meantime — that at least prevents silently proceeding to insert on a failed lookup.

Happy to open the follow-up issue for you now if you'd like — just confirm and I'll file it with a summary of both races (domains + memberships), the required unique constraints, and the upsert/duplicate-key handling needed in the app code once the constraints land.


✏️ Learnings added
Learnt from: sweetmantech
Repo: recoupable/api PR: 749
File: lib/organizations/addOrgDomainHandler.ts:0-0
Timestamp: 2026-07-03T19:04:48.211Z
Learning: In recoupable/api, the one-domain-per-organization invariant for `organization_domains.domain` and the one-membership-per-account-per-organization invariant for `account_organization_ids(account_id, organization_id)` are intended to be enforced via unique constraints added in the `database` submodule (via migration), not via app-level check-then-insert logic in handlers like `lib/organizations/addOrgDomainHandler.ts`. App-level insert/upsert logic should be converted to rely on these constraints once added.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Comment thread lib/organizations/addOrgDomainHandler.ts
Comment thread lib/organizations/getOrgDomainsHandler.ts Outdated
Comment thread lib/supabase/organization_domains/selectOrganizationDomain.ts
…omes

- Consolidate duplicate access helpers into one canManageOrganization
  (org member or Recoup admin); members validators now use it and
  canManageOrgMembers is deleted
- Move auth + access checks into per-endpoint request validators
  (validateGetOrgDomainsRequest, validateAddOrgDomainRequest,
  validateRemoveOrgDomainRequest), absorbing the old query/body
  validators; handlers slim to validator -> data call -> response
- Harden the three 500 catch blocks: hardcoded "Internal server error"
  (real error only in console.error) with leak-regression tests
- Strengthen error-shape assertions (body.status + body.message)

Response contract unchanged per recoupable/docs#261 (including the 409
cross-org conflict and idempotent POST/DELETE).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

0 issues found across 27 files (changes from recent commits).

Requires human review: Auto-approval blocked by 3 unresolved issues from previous reviews.

Re-trigger cubic

body: AddOrgDomainBody;
}

function badRequest(message: string): NextResponse {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

DRY - this should already exist somewhere as a shared lib.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in b10879e — local badRequest() deleted; all error paths across the members+domains surface now use the shared lib/networking/errorResponse.ts. Note this converges the error envelope on {status:\"error\", error} (the shared helper's and validateAuthContext's shape) — docs follow-up PR updates the contract to match.

query: RemoveOrgDomainQuery;
}

function badRequest(message: string): NextResponse {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

DRY - use existing shared lib

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in b10879e — same conversion as the add validator; shared errorResponse everywhere on this surface.

…rorResponse envelope

- Replace hand-rolled { status: "error", message } bodies with the shared
  lib/networking/errorResponse helper across the organizations members and
  domains validators + handlers, so the whole surface emits one
  { status: "error", error } envelope (matching validateAuthContext 401s).
  Error body key changes from "message" to "error" on these endpoints;
  status codes and success shapes are unchanged.
- selectOrganizationDomain now throws on Supabase query errors instead of
  returning null, so addOrgDomainHandler can no longer mistake a transient
  failure for "domain not mapped" and double-map a domain; the handler's
  try/catch turns the throw into the hardened 500 (new test covers it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sweetmantech

Copy link
Copy Markdown
Contributor Author

Preview verification — 2026-07-03 (final build)

Preview https://api-3r8p6e7v2-recoup.vercel.app, confirmed built from PR head b10879e9 (post-review: shared errorResponse envelope + selectOrganizationDomain hardening). Auth: Privy bearer. Throwaway org 29f1a3c6…; test domain endpoint-test-20260703.example.

# Check (documented) Actual Result
1 GET with no mappings → {status, domains: []} 200, empty array
2 POST with @Endpoint-Test-20260703.Example → normalized 200, stored as endpoint-test-20260703.example, row d6229983…
3 GET → mapping listed with id/domain/organization_id/created_at 200, exact shape
4 Repeat POST same org → idempotent 200, identical row id
5 POST same domain, different org → 409 409 {"status":"error","error":"Domain … is already mapped to a different organization"}
6 Auto-join e2e: signup with email at mapped domain account 6d028518… created → membership row in test org (DB-verified joined_test_org: true)
7 DELETE mapping → 200; repeat → idempotent 200 both 200 {status:"success"}
8 Post-delete signup does NOT auto-join account c253e124… → no membership row (DB-verified false)
9 400 — "not a domain" rejected 400 {"status":"error","error":"domain must be a bare email domain …"}
10 400 — GET missing organization_id 400
11 401 — no credentials 401 {"status":"error","error":"Exactly one of x-api-key or Authorization must be provided"}
12 Error envelope = {status, error} everywhere (per review convergence) 409/400/401 all use error key — matches docs#263
13 Cleanup test membership removed via the merged DELETE /api/organizations/members (200)

Not exercised live: 403 (no non-member, non-admin credential available) — covered by unit tests.

Every Done-when criterion from recoupable/chat#1841 for this item is verified. Merge order: this PR, then docs#263.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 22 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="lib/organizations/addOrgMemberHandler.ts">

<violation number="1" location="lib/organizations/addOrgMemberHandler.ts:3">
P1: The `errorResponse` utility produces `{status: "error", error}` but the contract (per PR description and original target-branch code) specifies `{status: "error", message}`. Replacing inlined `NextResponse.json` calls with `errorResponse` changes the response envelope key from `message` to `error`, which will break any client inspecting `response.message`. Either update `errorResponse` to emit `message` as the key, or update the contract and commit message to reflect the new `error` key.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { errorResponse } from "@/lib/networking/errorResponse";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: The errorResponse utility produces {status: "error", error} but the contract (per PR description and original target-branch code) specifies {status: "error", message}. Replacing inlined NextResponse.json calls with errorResponse changes the response envelope key from message to error, which will break any client inspecting response.message. Either update errorResponse to emit message as the key, or update the contract and commit message to reflect the new error key.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/organizations/addOrgMemberHandler.ts, line 3:

<comment>The `errorResponse` utility produces `{status: "error", error}` but the contract (per PR description and original target-branch code) specifies `{status: "error", message}`. Replacing inlined `NextResponse.json` calls with `errorResponse` changes the response envelope key from `message` to `error`, which will break any client inspecting `response.message`. Either update `errorResponse` to emit `message` as the key, or update the contract and commit message to reflect the new `error` key.</comment>

<file context>
@@ -1,5 +1,6 @@
 import { NextRequest, NextResponse } from "next/server";
 import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
+import { errorResponse } from "@/lib/networking/errorResponse";
 import { validateAddOrgMemberRequest } from "@/lib/organizations/validateAddOrgMemberRequest";
 import { getOrCreateAccountByEmail } from "@/lib/accounts/getOrCreateAccountByEmail";
</file context>

@sweetmantech sweetmantech merged commit 8b8816d into main Jul 3, 2026
6 checks passed
sweetmantech added a commit to recoupable/docs that referenced this pull request Jul 3, 2026
…tatus, error} (#263)

The api converged these endpoints' error paths on the shared
errorResponse helper (recoupable/api#749), which emits
{status:"error", error} — the same shape validateAuthContext's 401s
always had. New ErrorEnvelope schema; the 16 error responses across
the five member/domain operations now reference it. The pre-existing
artists endpoint keeps OrganizationsErrorResponse ({status, message}),
which is what it actually emits.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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