Skip to content

PR 1 — Backend Foundation (v2 Admin Migration) Implementation Plan ​

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Land all backend foundations that the v2 admin migration depends on — new Principal Partner membership role, new nodes and referrals entities, extended users (node_id, referred_by_user_id, photo_url) and mandates (pdf_key, md_key, json_key), and Yannick seeded as Principal Partner. Pure backend PR — no Angular work, no R2 client, no Claude call (those land in later PRs).

Architecture: Postgres schema changes via Supabase migrations; Go domain types + generic crud.Mount engine pattern (already established for orgs/mandates/tasks/users) in apps/api; JWT role union extended in libs/auth. Swagger regenerates from Go annotations and feeds Orval downstream, but Orval regen happens in PR 3 — this PR only needs to leave swagger.yaml current.

Tech Stack: Postgres 15 (Supabase), Go 1.x with Gin + pgx v5, swaggo/swag for OpenAPI generation, Go stdlib testing with integration tests against a real local Postgres.

Global Constraints ​

  • Migration filenames follow YYYYMMDDHHMMSS_<description>.sql and live in supabase/migrations/. Use timestamps after the most recent existing migration (20260616010000_rls_policies.sql) — i.e. 20260618HHMMSS_*. Pick monotonically increasing minutes within this PR.
  • All API JSON fields are camelCase via Go json: tags; all DB columns are snake_case via db: tags. domain.go structs bridge the two.
  • New tables must include id uuid primary key default gen_random_uuid(), created_at timestamptz not null default now(), updated_at timestamptz not null default now(), matching the established convention in 20260616000000_core_domain.sql.
  • All new endpoints mount under /api/v1 and require RequireAuth. Writes require manager roles via RequireRole(...) — Principal Partner is added to the manager set in Task 1.
  • Email domain for seeded users is @gravitasacumen.org (enforced by trigger). Yannick's seed email is therefore yannick@gravitasacumen.org (the v2 admin.js uses @damien.in — that's prototype-only; the production seed uses the workspace domain).
  • Tests live in apps/api/internal/router/*_test.go and use the existing testPool(t) / sign(t, sub, role) / do(t, ...) helpers. They skip when DATABASE_URL is unset/unreachable — that's expected and CI-safe.
  • No RLS policies in this PR. The existing deny-all posture remains; app-level role checks gate writes. RLS work is tracked separately (WS2.2 per the codebase).
  • Branch: feature/gac-NN-pr1-backend-foundation (replace NN with the Linear issue id when filed). Per [[git-branch-hygiene]] don't delete branches; per [[no-claude-coauthor-trailer]] no Co-Authored-By Claude trailers on commits.
  • Don't run bun run api:gen (Orval) in this PR — TS client regen is PR 3's responsibility. Do regenerate apps/api/docs/swagger.yaml (Task 7) so PR 3 starts from a current spec.

File Structure ​

New files:

  • supabase/migrations/20260618100000_membership_role_principal_partner.sql — adds 'Principal Partner' to the membership_role enum.
  • supabase/migrations/20260618100100_nodes.sql — nodes table + seed two nodes (Node 1 Montreal, Node 2 Toronto).
  • supabase/migrations/20260618100200_referrals.sql — referrals table linking referrer → referee users.
  • supabase/migrations/20260618100300_users_v2_fields.sql — adds node_id, referred_by_user_id, photo_url to public.users.
  • supabase/migrations/20260618100400_mandates_contract_keys.sql — adds pdf_key, md_key, json_key to public.mandates.
  • apps/api/internal/router/nodes_test.go — integration tests for /api/v1/nodes.
  • apps/api/internal/router/referrals_test.go — integration tests for /api/v1/referrals.

Modified files:

  • supabase/seed.sql — add Yannick (yannick@gravitasacumen.org, Principal Partner, no node), assign existing seeded users to nodes, wire referred_by_user_id chains to mirror the v2 REFERRAL_SEED.
  • libs/auth/jwt.go — add RolePrincipalPartner const; introduce a ManagerRoles slice that includes Managing Partner, Admin, and Principal Partner.
  • apps/api/internal/domain/domain.go — extend User and Mandate structs + their Schemas; add Node and Referral structs + Schema() constructors; allow "Principal Partner" in user validate.
  • apps/api/internal/router/router.go — mount nodes and referrals CRUD; switch the manager RequireRole(...) call to use auth.ManagerRoles....
  • apps/api/internal/router/router_test.go — extend user/mandate tests to exercise the new fields; add a Principal-Partner role-acceptance test.
  • apps/api/docs/swagger.yaml — regenerated by swag init at Task 7 (do not hand-edit).

Each task below produces an independently reviewable, testable deliverable. Each ends with a commit.


Task 1: Add Principal Partner membership role end-to-end ​

Adds the enum value in Postgres, the Go constant + ManagerRoles slice in libs/auth, accepts the value in the User schema validator in domain.go, and wires the router to admit PP for manager-gated endpoints.

Files:

  • Create: supabase/migrations/20260618100000_membership_role_principal_partner.sql
  • Modify: libs/auth/jwt.go
  • Modify: apps/api/internal/domain/domain.go:186 (the user schema validate case line)
  • Modify: apps/api/internal/router/router.go (replace explicit RequireRole(auth.RoleManagingPartner, auth.RoleAdmin) with RequireRole(auth.ManagerRoles...))
  • Modify: apps/api/internal/router/router_test.go (add TestPrincipalPartnerAcceptedByManagerEndpoints)

Interfaces:

  • Produces: auth.RolePrincipalPartner (Role const = "Principal Partner"), auth.ManagerRoles ([]Role = {RoleManagingPartner, RoleAdmin, RolePrincipalPartner}). All later tasks use auth.ManagerRoles... when calling RequireRole.

  • [ ] Step 1: Write the failing test

Add to apps/api/internal/router/router_test.go (append near the other role tests):

go
func TestPrincipalPartnerAcceptedByManagerEndpoints(t *testing.T) {
    gin.SetMode(gin.TestMode)
    pool := testPool(t)
    r := router.New(config.Config{SupabaseJWTSecret: testSecret}, pool)

    pp := sign(t, "00000000-0000-0000-0000-0000000000aa", "Principal Partner")

    rec := do(t, r, http.MethodGet, "/api/v1/organizations", pp, "")
    if rec.Code != http.StatusOK {
        t.Fatalf("PP list orgs: status = %d, want 200; body = %s", rec.Code, rec.Body.String())
    }
}
  • [ ] Step 2: Run test to verify it fails
bash
bun nx test api -- -run TestPrincipalPartnerAcceptedByManagerEndpoints -v

Expected: FAIL — either the JWT signs but middleware rejects "Principal Partner" (403), or sign() rejects an unknown role string. Either way, red is correct.

  • [ ] Step 3: Add the enum value migration

Create supabase/migrations/20260618100000_membership_role_principal_partner.sql:

sql
-- Adds Principal Partner to the membership_role enum.
-- Principal Partners sit above Managing Partners in the cooperative governance
-- model and have cross-node visibility in the admin console.
alter type public.membership_role add value if not exists 'Principal Partner';
  • [ ] Step 4: Add the Go constant + ManagerRoles slice

In libs/auth/jwt.go, extend the role constants block:

go
type Role string

const (
    RoleMember           Role = "Member Partner"
    RoleManagingPartner  Role = "Managing Partner"
    RolePrincipalPartner Role = "Principal Partner"
    RoleAdmin            Role = "Admin"
)

// ManagerRoles enumerates every role that has back-office write access.
// Use this with RequireRole(ManagerRoles...) instead of listing roles inline,
// so a future role addition is a one-line change here.
var ManagerRoles = []Role{RoleManagingPartner, RoleAdmin, RolePrincipalPartner}
  • [ ] Step 5: Allow the new value in the User schema validator

In apps/api/internal/domain/domain.go, find the switch in UserSchema().Validate that currently reads:

go
case "Member Partner", "Managing Partner", "Admin":

Change to:

go
case "Member Partner", "Managing Partner", "Principal Partner", "Admin":
  • [ ] Step 6: Switch the router manager middleware to ManagerRoles

In apps/api/internal/router/router.go, replace:

go
managers := auth.RequireRole(auth.RoleManagingPartner, auth.RoleAdmin)

with:

go
managers := auth.RequireRole(auth.ManagerRoles...)
  • [ ] Step 7: Apply the migration locally
bash
supabase db reset

Expected: completes without error; new migration 20260618100000_membership_role_principal_partner.sql runs.

  • [ ] Step 8: Run test to verify it passes
bash
bun nx test api -- -run TestPrincipalPartnerAcceptedByManagerEndpoints -v

Expected: PASS.

  • [ ] Step 9: Run the full API test suite — no regressions
bash
bun nx test api

Expected: all existing tests still pass; the new one passes.

  • [ ] Step 10: Commit
bash
git add supabase/migrations/20260618100000_membership_role_principal_partner.sql \
        libs/auth/jwt.go \
        apps/api/internal/domain/domain.go \
        apps/api/internal/router/router.go \
        apps/api/internal/router/router_test.go
git commit -m "feat(auth): add Principal Partner membership role"

Task 2: Node entity (CRUD) ​

Adds the nodes table (the geographical groupings of members), the Node Go struct + Schema, mounts /api/v1/nodes, and seeds Node 1 (Montreal) and Node 2 (Toronto) directly in the migration so the table is never empty in dev.

Files:

  • Create: supabase/migrations/20260618100100_nodes.sql
  • Modify: apps/api/internal/domain/domain.go (add Node struct + NodeSchema())
  • Modify: apps/api/internal/router/router.go (add crud.Mount line)
  • Create: apps/api/internal/router/nodes_test.go

Interfaces:

  • Produces:

    go
    type Node struct {
        ID        string    `db:"id"         json:"id"`
        Name      string    `db:"name"       json:"name"`
        Region    string    `db:"region"     json:"region"`
        LeadID    *string   `db:"lead_id"    json:"leadId,omitempty"`
        CreatedAt time.Time `db:"created_at" json:"createdAt"`
        UpdatedAt time.Time `db:"updated_at" json:"updatedAt"`
    }
    func NodeSchema() crud.Schema[Node]
  • Later tasks (User extension in Task 4) reference nodes(id) as an FK target.

  • [ ] Step 1: Write the failing test

Create apps/api/internal/router/nodes_test.go:

go
package router_test

import (
    "encoding/json"
    "net/http"
    "testing"

    "github.com/gin-gonic/gin"
    "seeg-dev/dispatch/apps/api/internal/config"
    "seeg-dev/dispatch/apps/api/internal/router"
)

func TestNodesListSeeded(t *testing.T) {
    gin.SetMode(gin.TestMode)
    pool := testPool(t)
    r := router.New(config.Config{SupabaseJWTSecret: testSecret}, pool)

    manager := sign(t, "00000000-0000-0000-0000-0000000000a1", "Managing Partner")
    rec := do(t, r, http.MethodGet, "/api/v1/nodes", manager, "")
    if rec.Code != http.StatusOK {
        t.Fatalf("list nodes: status = %d, want 200; body = %s", rec.Code, rec.Body.String())
    }

    var got []map[string]any
    if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
        t.Fatalf("unmarshal: %v", err)
    }
    if len(got) < 2 {
        t.Fatalf("expected at least 2 seeded nodes, got %d", len(got))
    }
    // First seeded node is Montreal.
    if got[0]["name"] != "Node 1" || got[0]["region"] != "Montreal, Quebec" {
        t.Fatalf("first node = %#v, want {name: Node 1, region: Montreal, Quebec}", got[0])
    }
}

func TestNodesAnonRejected(t *testing.T) {
    gin.SetMode(gin.TestMode)
    pool := testPool(t)
    r := router.New(config.Config{SupabaseJWTSecret: testSecret}, pool)

    rec := do(t, r, http.MethodGet, "/api/v1/nodes", "", "")
    if rec.Code != http.StatusUnauthorized {
        t.Fatalf("anon list: status = %d, want 401", rec.Code)
    }
}

Match the existing package router_test declaration and helper visibility from router_test.go. If the helpers (testPool, sign, do, testSecret) live in a different file in the same router_test package they're already visible.

  • [ ] Step 2: Run test to verify it fails
bash
bun nx test api -- -run TestNodesListSeeded -v

Expected: FAIL — /api/v1/nodes returns 404 (not mounted yet).

  • [ ] Step 3: Add the migration

Create supabase/migrations/20260618100100_nodes.sql:

sql
-- Nodes are geographical groupings of member-partners. Each node has a
-- node lead (a user with managing-partner-ish authority over that node).
-- Cross-node visibility is reserved for Principal Partners and Admins.
create table public.nodes (
    id         uuid primary key default gen_random_uuid(),
    name       text not null,
    region     text not null,
    lead_id    uuid null references public.users(id) on delete set null,
    created_at timestamptz not null default now(),
    updated_at timestamptz not null default now()
);

create index nodes_lead_id_idx on public.nodes(lead_id);

-- Seed the two nodes the design system documents.
insert into public.nodes (name, region) values
    ('Node 1', 'Montreal, Quebec'),
    ('Node 2', 'Toronto, Ontario');

Note: lead_id is left null in the seed; Task 6 (seed update) backfills it to point at the appropriate user.

  • [ ] Step 4: Add the Go struct and Schema

In apps/api/internal/domain/domain.go, after the existing entity blocks, add:

go
// ── Node ────────────────────────────────────────────────────────

type Node struct {
    ID        string    `db:"id"         json:"id"`
    Name      string    `db:"name"       json:"name"`
    Region    string    `db:"region"     json:"region"`
    LeadID    *string   `db:"lead_id"    json:"leadId,omitempty"`
    CreatedAt time.Time `db:"created_at" json:"createdAt"`
    UpdatedAt time.Time `db:"updated_at" json:"updatedAt"`
}

func NodeSchema() crud.Schema[Node] {
    return crud.Schema[Node]{
        Table: "nodes",
        Columns: []string{"id", "name", "region", "lead_id", "created_at", "updated_at"},
        Values: func(n Node) []any {
            return []any{n.Name, n.Region, n.LeadID}
        },
        Validate: func(n Node) error {
            if n.Name == "" {
                return errors.New("name is required")
            }
            if n.Region == "" {
                return errors.New("region is required")
            }
            return nil
        },
    }
}

If the existing schemas use a different Columns/Values convention (e.g. excluding id/created_at/updated_at from Values because the engine fills those automatically), mirror that convention exactly — read one existing schema (OrganizationSchema()) in the same file before writing this one. Do not invent a new convention.

  • [ ] Step 5: Mount the CRUD route

In apps/api/internal/router/router.go, after the existing crud.Mount(...) lines for orgs/mandates/etc, add:

go
crud.Mount(api, "nodes", crud.New(pool, domain.NodeSchema()), managers)
  • [ ] Step 6: Apply the migration
bash
supabase db reset

Expected: completes without error.

  • [ ] Step 7: Run test to verify it passes
bash
bun nx test api -- -run 'TestNodes' -v

Expected: both TestNodesListSeeded and TestNodesAnonRejected PASS.

  • [ ] Step 8: Commit
bash
git add supabase/migrations/20260618100100_nodes.sql \
        apps/api/internal/domain/domain.go \
        apps/api/internal/router/router.go \
        apps/api/internal/router/nodes_test.go
git commit -m "feat(api): add nodes entity + CRUD"

Task 3: Referral entity (CRUD) ​

Adds the referrals table linking a referrer user to a referee user, with a created-at timestamp. Mounts /api/v1/referrals.

Files:

  • Create: supabase/migrations/20260618100200_referrals.sql
  • Modify: apps/api/internal/domain/domain.go (add Referral struct + ReferralSchema())
  • Modify: apps/api/internal/router/router.go (mount line)
  • Create: apps/api/internal/router/referrals_test.go

Interfaces:

  • Produces:

    go
    type Referral struct {
        ID         string    `db:"id"           json:"id"`
        ReferrerID string    `db:"referrer_id"  json:"referrerId"`
        RefereeID  string    `db:"referee_id"   json:"refereeId"`
        CreatedAt  time.Time `db:"created_at"   json:"createdAt"`
    }
    func ReferralSchema() crud.Schema[Referral]
  • [ ] Step 1: Write the failing test

Create apps/api/internal/router/referrals_test.go:

go
package router_test

import (
    "fmt"
    "net/http"
    "testing"

    "github.com/gin-gonic/gin"
    "seeg-dev/dispatch/apps/api/internal/config"
    "seeg-dev/dispatch/apps/api/internal/router"
)

func TestReferralsCreateAndList(t *testing.T) {
    gin.SetMode(gin.TestMode)
    pool := testPool(t)
    r := router.New(config.Config{SupabaseJWTSecret: testSecret}, pool)

    // Use two seeded users (Sarah refers LÊa, per the existing seed pattern).
    var sarahID, leaID string
    if err := pool.QueryRow(t.Context(),
        "select id from public.users where email = 'sarah@gravitasacumen.org'").Scan(&sarahID); err != nil {
        t.Fatalf("lookup sarah: %v", err)
    }
    if err := pool.QueryRow(t.Context(),
        "select id from public.users where email = 'lea@gravitasacumen.org'").Scan(&leaID); err != nil {
        t.Fatalf("lookup lea: %v", err)
    }

    manager := sign(t, "00000000-0000-0000-0000-0000000000a2", "Managing Partner")
    body := fmt.Sprintf(`{"referrerId":%q,"refereeId":%q}`, sarahID, leaID)
    rec := do(t, r, http.MethodPost, "/api/v1/referrals", manager, body)
    if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
        t.Fatalf("create referral: status = %d, body = %s", rec.Code, rec.Body.String())
    }

    rec = do(t, r, http.MethodGet, "/api/v1/referrals", manager, "")
    if rec.Code != http.StatusOK {
        t.Fatalf("list referrals: status = %d", rec.Code)
    }
}

If t.Context() is not available on this Go version, use context.Background() and import context.

  • [ ] Step 2: Run test to verify it fails
bash
bun nx test api -- -run TestReferralsCreateAndList -v

Expected: FAIL — /api/v1/referrals returns 404.

  • [ ] Step 3: Add the migration

Create supabase/migrations/20260618100200_referrals.sql:

sql
-- A referral records that one member-partner brought another into the
-- cooperative. The relationship is informational today (no waterfall share),
-- but the cooperative may activate a referrer share in a future compensation
-- policy — see docs/superpowers/specs/2026-06-18-v1-to-v2-design-system-migration.md §4.8.
create table public.referrals (
    id          uuid primary key default gen_random_uuid(),
    referrer_id uuid not null references public.users(id) on delete cascade,
    referee_id  uuid not null references public.users(id) on delete cascade,
    created_at  timestamptz not null default now(),
    unique (referee_id)
);

create index referrals_referrer_id_idx on public.referrals(referrer_id);

The unique (referee_id) constraint enforces "one member has at most one referrer."

  • [ ] Step 4: Add the Go struct and Schema

In apps/api/internal/domain/domain.go:

go
// ── Referral ────────────────────────────────────────────────────

type Referral struct {
    ID         string    `db:"id"          json:"id"`
    ReferrerID string    `db:"referrer_id" json:"referrerId"`
    RefereeID  string    `db:"referee_id"  json:"refereeId"`
    CreatedAt  time.Time `db:"created_at"  json:"createdAt"`
}

func ReferralSchema() crud.Schema[Referral] {
    return crud.Schema[Referral]{
        Table:   "referrals",
        Columns: []string{"id", "referrer_id", "referee_id", "created_at"},
        Values: func(r Referral) []any {
            return []any{r.ReferrerID, r.RefereeID}
        },
        Validate: func(r Referral) error {
            if r.ReferrerID == "" {
                return errors.New("referrerId is required")
            }
            if r.RefereeID == "" {
                return errors.New("refereeId is required")
            }
            if r.ReferrerID == r.RefereeID {
                return errors.New("referrer and referee must differ")
            }
            return nil
        },
    }
}

Mirror the Columns/Values convention used by the schemas already in the file.

  • [ ] Step 5: Mount the route

In apps/api/internal/router/router.go, after the nodes mount:

go
crud.Mount(api, "referrals", crud.New(pool, domain.ReferralSchema()), managers)
  • [ ] Step 6: Apply the migration + run the test
bash
supabase db reset
bun nx test api -- -run TestReferralsCreateAndList -v

Expected: PASS.

  • [ ] Step 7: Commit
bash
git add supabase/migrations/20260618100200_referrals.sql \
        apps/api/internal/domain/domain.go \
        apps/api/internal/router/router.go \
        apps/api/internal/router/referrals_test.go
git commit -m "feat(api): add referrals entity + CRUD"

Task 4: Extend users with node_id, referred_by_user_id, photo_url ​

Adds three nullable columns to public.users (FK to nodes, FK to users for direct referrer pointer, photo URL string), extends the User Go struct + schema columns/values, and proves end-to-end via integration test.

Files:

  • Create: supabase/migrations/20260618100300_users_v2_fields.sql
  • Modify: apps/api/internal/domain/domain.go (extend User struct + UserSchema())
  • Modify: apps/api/internal/router/router_test.go (add TestUserUpdateWithV2Fields)

Interfaces:

  • Consumes: Node (Task 2) for the FK target.

  • Produces: User struct now carries

    go
    NodeID            *string `db:"node_id"             json:"nodeId,omitempty"`
    ReferredByUserID  *string `db:"referred_by_user_id" json:"referredByUserId,omitempty"`
    PhotoURL          *string `db:"photo_url"           json:"photoUrl,omitempty"`

    The Angular User model will pick these up after Orval regen in PR 3.

  • [ ] Step 1: Write the failing test

Append to apps/api/internal/router/router_test.go:

go
func TestUserUpdateWithV2Fields(t *testing.T) {
    gin.SetMode(gin.TestMode)
    pool := testPool(t)
    r := router.New(config.Config{SupabaseJWTSecret: testSecret}, pool)

    var leaID, nodeID string
    if err := pool.QueryRow(t.Context(),
        "select id from public.users where email = 'lea@gravitasacumen.org'").Scan(&leaID); err != nil {
        t.Fatalf("lookup lea: %v", err)
    }
    if err := pool.QueryRow(t.Context(),
        "select id from public.nodes where name = 'Node 1'").Scan(&nodeID); err != nil {
        t.Fatalf("lookup node 1: %v", err)
    }

    manager := sign(t, "00000000-0000-0000-0000-0000000000a3", "Managing Partner")
    body := fmt.Sprintf(`{
        "fullName": "LÊa Fontaine",
        "email": "lea@gravitasacumen.org",
        "membershipRole": "Member Partner",
        "function": "Bilingual Agent",
        "status": "Active",
        "gacxBalance": 1310,
        "nodeId": %q,
        "photoUrl": "https://i.pravatar.cc/120?u=lea"
    }`, nodeID)

    rec := do(t, r, http.MethodPut, "/api/v1/users/"+leaID, manager, body)
    if rec.Code != http.StatusOK {
        t.Fatalf("update lea: status = %d, body = %s", rec.Code, rec.Body.String())
    }

    var got struct {
        NodeID   *string `json:"nodeId"`
        PhotoURL *string `json:"photoUrl"`
    }
    if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
        t.Fatalf("unmarshal: %v", err)
    }
    if got.NodeID == nil || *got.NodeID != nodeID {
        t.Fatalf("nodeId = %v, want %s", got.NodeID, nodeID)
    }
    if got.PhotoURL == nil || *got.PhotoURL == "" {
        t.Fatalf("photoUrl = %v, want non-empty", got.PhotoURL)
    }
}

Add "encoding/json" and "fmt" to the imports of router_test.go if not already present.

  • [ ] Step 2: Run test to verify it fails
bash
bun nx test api -- -run TestUserUpdateWithV2Fields -v

Expected: FAIL — request body fields nodeId/photoUrl are ignored (struct doesn't carry them) and the response lacks them.

  • [ ] Step 3: Add the migration

Create supabase/migrations/20260618100300_users_v2_fields.sql:

sql
-- v2 admin migration: members are grouped under a node, may have been
-- referred by another member, and carry a profile photo URL (pulled from
-- Google Workspace at sign-in, later editable).
alter table public.users
    add column node_id              uuid null references public.nodes(id) on delete set null,
    add column referred_by_user_id  uuid null references public.users(id) on delete set null,
    add column photo_url            text null;

create index users_node_id_idx on public.users(node_id);
create index users_referred_by_user_id_idx on public.users(referred_by_user_id);
  • [ ] Step 4: Extend the User struct

In apps/api/internal/domain/domain.go, add the three pointer fields to the existing User struct:

go
type User struct {
    ID                string    `db:"id"                    json:"id"`
    Email             string    `db:"email"                 json:"email"`
    FullName          string    `db:"full_name"             json:"fullName"`
    MembershipRole    string    `db:"membership_role"       json:"membershipRole"`
    Function          *string   `db:"function"              json:"function,omitempty"`
    Status            string    `db:"status"                json:"status"`
    GacxBalance       float64   `db:"gacx_balance"          json:"gacxBalance"`
    NodeID            *string   `db:"node_id"               json:"nodeId,omitempty"`
    ReferredByUserID  *string   `db:"referred_by_user_id"   json:"referredByUserId,omitempty"`
    PhotoURL          *string   `db:"photo_url"             json:"photoUrl,omitempty"`
    CreatedAt         time.Time `db:"created_at"            json:"createdAt"`
    UpdatedAt         time.Time `db:"updated_at"            json:"updatedAt"`
}

If the existing User struct already declares some of these field tag combinations differently, preserve the existing tags and only add the three new fields — do not rewrite tag conventions you didn't author.

  • [ ] Step 5: Extend the User schema's Columns/Values

In UserSchema() in the same file, extend Columns to include the three new column names and Values to include the three new fields. Keep the field order identical between Columns and Values. Example shape:

go
Columns: []string{
    "id", "email", "full_name", "membership_role", "function", "status",
    "gacx_balance", "node_id", "referred_by_user_id", "photo_url",
    "created_at", "updated_at",
},
Values: func(u User) []any {
    return []any{
        u.Email, u.FullName, u.MembershipRole, u.Function, u.Status,
        u.GacxBalance, u.NodeID, u.ReferredByUserID, u.PhotoURL,
    }
},

Match the exact column-order convention the existing schema uses (whether or not id / created_at / updated_at appear in Values depends on how the engine fills them — read the existing schema first).

  • [ ] Step 6: Apply the migration + run the test
bash
supabase db reset
bun nx test api -- -run TestUserUpdateWithV2Fields -v

Expected: PASS.

  • [ ] Step 7: Full regression
bash
bun nx test api

Expected: all tests pass — existing user tests still work because new fields are nullable and omitted from existing test payloads.

  • [ ] Step 8: Commit
bash
git add supabase/migrations/20260618100300_users_v2_fields.sql \
        apps/api/internal/domain/domain.go \
        apps/api/internal/router/router_test.go
git commit -m "feat(api): extend users with node, referrer pointer, photo URL"

Task 5: Extend mandates with R2 contract artifact keys ​

Adds three nullable text columns to public.mandates (pdf_key, md_key, json_key). No backend logic populates them in this PR — PR 9 (Go contract ingest endpoint) is what writes them. This task only puts the schema in place so PR 9 can land cleanly without a coupled migration.

Files:

  • Create: supabase/migrations/20260618100400_mandates_contract_keys.sql
  • Modify: apps/api/internal/domain/domain.go (extend Mandate struct + MandateSchema())
  • Modify: apps/api/internal/router/router_test.go (add TestMandateRoundTripsContractKeys)

Interfaces:

  • Produces: Mandate struct carries

    go
    PDFKey  *string `db:"pdf_key"  json:"pdfKey,omitempty"`
    MDKey   *string `db:"md_key"   json:"mdKey,omitempty"`
    JSONKey *string `db:"json_key" json:"jsonKey,omitempty"`
  • [ ] Step 1: Write the failing test

Append to apps/api/internal/router/router_test.go:

go
func TestMandateRoundTripsContractKeys(t *testing.T) {
    gin.SetMode(gin.TestMode)
    pool := testPool(t)
    r := router.New(config.Config{SupabaseJWTSecret: testSecret}, pool)

    // Pick any seeded mandate.
    var mandateID string
    if err := pool.QueryRow(t.Context(),
        "select id from public.mandates limit 1").Scan(&mandateID); err != nil {
        t.Fatalf("lookup mandate: %v", err)
    }

    manager := sign(t, "00000000-0000-0000-0000-0000000000a4", "Managing Partner")

    // Read first to grab existing required fields, then echo them with R2 keys set.
    rec := do(t, r, http.MethodGet, "/api/v1/mandates/"+mandateID, manager, "")
    if rec.Code != http.StatusOK {
        t.Fatalf("get mandate: status = %d", rec.Code)
    }

    var current map[string]any
    if err := json.Unmarshal(rec.Body.Bytes(), &current); err != nil {
        t.Fatalf("unmarshal: %v", err)
    }
    current["pdfKey"] = "contracts/" + mandateID + "/original.pdf"
    current["mdKey"] = "contracts/" + mandateID + "/text.md"
    current["jsonKey"] = "contracts/" + mandateID + "/extract.json"

    body, _ := json.Marshal(current)
    rec = do(t, r, http.MethodPut, "/api/v1/mandates/"+mandateID, manager, string(body))
    if rec.Code != http.StatusOK {
        t.Fatalf("update mandate: status = %d, body = %s", rec.Code, rec.Body.String())
    }

    var got struct {
        PDFKey  *string `json:"pdfKey"`
        MDKey   *string `json:"mdKey"`
        JSONKey *string `json:"jsonKey"`
    }
    if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
        t.Fatalf("unmarshal: %v", err)
    }
    if got.PDFKey == nil || got.MDKey == nil || got.JSONKey == nil {
        t.Fatalf("expected all three R2 keys populated, got %+v", got)
    }
}
  • [ ] Step 2: Run test to verify it fails
bash
bun nx test api -- -run TestMandateRoundTripsContractKeys -v

Expected: FAIL — fields are ignored on PUT and absent from the response.

  • [ ] Step 3: Add the migration

Create supabase/migrations/20260618100400_mandates_contract_keys.sql:

sql
-- v2 admin migration: each mandate may carry a signed PDF contract. The PDF
-- is stored on Cloudflare R2 (key in pdf_key), converted to markdown by the
-- Python pdf-convert sidecar (key in md_key), and structured-extracted by
-- Claude server-side (key in json_key). All three keys are populated by the
-- Go contract-ingest + ai/contract-extract endpoints landing in a later PR;
-- this migration only places the columns so that later PR is purely additive.
alter table public.mandates
    add column pdf_key  text null,
    add column md_key   text null,
    add column json_key text null;
  • [ ] Step 4: Extend the Mandate struct + schema

In apps/api/internal/domain/domain.go, add the three pointer fields to Mandate:

go
PDFKey  *string `db:"pdf_key"  json:"pdfKey,omitempty"`
MDKey   *string `db:"md_key"   json:"mdKey,omitempty"`
JSONKey *string `db:"json_key" json:"jsonKey,omitempty"`

In MandateSchema(), extend Columns with "pdf_key", "md_key", "json_key" and Values with m.PDFKey, m.MDKey, m.JSONKey — same positional convention as Task 4.

  • [ ] Step 5: Apply the migration + run the test
bash
supabase db reset
bun nx test api -- -run TestMandateRoundTripsContractKeys -v

Expected: PASS.

  • [ ] Step 6: Full regression
bash
bun nx test api

Expected: all tests pass.

  • [ ] Step 7: Commit
bash
git add supabase/migrations/20260618100400_mandates_contract_keys.sql \
        apps/api/internal/domain/domain.go \
        apps/api/internal/router/router_test.go
git commit -m "feat(api): add R2 contract artifact keys to mandates"

Task 6: Update seed — Yannick (Principal Partner) + wire existing seeds to nodes & referrals ​

Edits supabase/seed.sql to (1) add Yannick as the Principal Partner with no node, (2) assign existing seeded users to Node 1 / Node 2 in a way that mirrors the v2 admin.js NODE_SEED / REFERRAL_SEED intent, (3) backfill nodes.lead_id to the appropriate user, (4) insert referral rows.

Files:

  • Modify: supabase/seed.sql
  • Modify: apps/api/internal/router/router_test.go (add TestYannickIsPrincipalPartner smoke test)

Interfaces:

  • Consumes: Principal Partner enum value (Task 1), nodes table (Task 2), referrals table (Task 3), new user columns (Task 4).

  • Produces: yannick@gravitasacumen.org exists with membership_role = 'Principal Partner'; Node 1 has lead_id = priya@; Node 2 has lead_id = marc@; Sarah and LÊa sit on Node 1, Marc on Node 2; LÊa was referred by Sarah, Sarah by Priya.

  • [ ] Step 1: Write the failing test

Append to apps/api/internal/router/router_test.go:

go
func TestYannickIsPrincipalPartner(t *testing.T) {
    pool := testPool(t)
    var role string
    if err := pool.QueryRow(t.Context(),
        "select membership_role from public.users where email = 'yannick@gravitasacumen.org'").
        Scan(&role); err != nil {
        t.Fatalf("lookup yannick: %v", err)
    }
    if role != "Principal Partner" {
        t.Fatalf("yannick role = %q, want Principal Partner", role)
    }
}

func TestNodesHaveLeadsAfterSeed(t *testing.T) {
    pool := testPool(t)
    rows, err := pool.Query(t.Context(),
        "select name, (lead_id is not null) as has_lead from public.nodes order by name")
    if err != nil {
        t.Fatalf("query nodes: %v", err)
    }
    defer rows.Close()

    var leads = map[string]bool{}
    for rows.Next() {
        var name string
        var hasLead bool
        if err := rows.Scan(&name, &hasLead); err != nil {
            t.Fatal(err)
        }
        leads[name] = hasLead
    }
    if !leads["Node 1"] || !leads["Node 2"] {
        t.Fatalf("expected both nodes to have leads after seed, got %v", leads)
    }
}
  • [ ] Step 2: Run tests to verify they fail
bash
bun nx test api -- -run 'TestYannickIsPrincipalPartner|TestNodesHaveLeadsAfterSeed' -v

Expected: FAIL — Yannick missing, node leads null.

  • [ ] Step 3: Edit supabase/seed.sql

Append at the bottom of supabase/seed.sql (after the existing seed user inserts and public.users role updates):

sql
-- ── Yannick (Principal Partner) ─────────────────────────────────
-- Founder; sees every node. Email on the workspace domain so the
-- handle_new_user trigger admits it.
insert into auth.users (id, email, raw_user_meta_data)
values (
    '00000000-0000-0000-0000-0000000000y1',
    'yannick@gravitasacumen.org',
    jsonb_build_object('name', 'Yannick Bidounga Prince')
)
on conflict (id) do nothing;

update public.users
set membership_role = 'Principal Partner',
    function        = 'Founder',
    status          = 'Active'
where email = 'yannick@gravitasacumen.org';

-- ── Node assignments ───────────────────────────────────────────
-- Mirror the v2 admin.js NODE_SEED intent:
--   Node 1 (Montreal) lead = Priya Nair; members = Sarah Laurent, LÊa Fontaine
--   Node 2 (Toronto)  lead = Marc Tremblay; members = (additional users land here in PR 5+)

update public.nodes n
set lead_id = u.id
from public.users u
where n.name = 'Node 1' and u.email = 'priya@gravitasacumen.org';

update public.nodes n
set lead_id = u.id
from public.users u
where n.name = 'Node 2' and u.email = 'marc@gravitasacumen.org';

update public.users
set node_id = (select id from public.nodes where name = 'Node 1')
where email in ('sarah@gravitasacumen.org', 'lea@gravitasacumen.org', 'priya@gravitasacumen.org');

update public.users
set node_id = (select id from public.nodes where name = 'Node 2')
where email = 'marc@gravitasacumen.org';

-- Yannick stays on no node (Principal Partner — cross-node visibility).
-- Damien (admin@) stays on no node as well.

-- ── Referral chain ─────────────────────────────────────────────
-- Sarah was referred by Priya; LÊa was referred by Sarah.
insert into public.referrals (referrer_id, referee_id)
select p.id, s.id
from public.users p, public.users s
where p.email = 'priya@gravitasacumen.org' and s.email = 'sarah@gravitasacumen.org'
on conflict (referee_id) do nothing;

insert into public.referrals (referrer_id, referee_id)
select s.id, l.id
from public.users s, public.users l
where s.email = 'sarah@gravitasacumen.org' and l.email = 'lea@gravitasacumen.org'
on conflict (referee_id) do nothing;

-- Mirror the referrer pointer on the user row (kept in sync for easy queries;
-- referrals table is the source of truth).
update public.users u
set referred_by_user_id = r.referrer_id
from public.referrals r
where r.referee_id = u.id;
  • [ ] Step 4: Reset DB to apply the new seed
bash
supabase db reset

Expected: completes cleanly. The handle_new_user trigger creates Yannick's public.users row with default role Member Partner, then the subsequent update flips him to Principal Partner.

  • [ ] Step 5: Run the seed tests
bash
bun nx test api -- -run 'TestYannickIsPrincipalPartner|TestNodesHaveLeadsAfterSeed' -v

Expected: both PASS.

  • [ ] Step 6: Full regression
bash
bun nx test api

Expected: all tests pass.

  • [ ] Step 7: Commit
bash
git add supabase/seed.sql apps/api/internal/router/router_test.go
git commit -m "feat(seed): add Yannick as Principal Partner, wire nodes + referrals"

Task 7: Regenerate swagger.yaml ​

Final task: run swag init so apps/api/docs/swagger.yaml reflects the new entities and fields. PR 3 picks this up and runs Orval to regenerate the Angular client.

Files:

  • Modify: apps/api/docs/swagger.yaml (regenerated; do not hand-edit)

  • [ ] Step 1: Regenerate the spec

bash
bun nx swagger api

Expected: completes, writes apps/api/docs/swagger.yaml.

  • [ ] Step 2: Verify the new entities and fields appear
bash
grep -E 'domain\.(Node|Referral)|nodeId|referredByUserId|photoUrl|pdfKey|mdKey|jsonKey' apps/api/docs/swagger.yaml

Expected: each grep token appears at least once. If nodeId / referredByUserId / photoUrl are missing under domain.User, the struct tags weren't picked up — re-check Task 4 step 4.

  • [ ] Step 3: Verify the test suite still passes (sanity)
bash
bun nx test api

Expected: all tests pass.

  • [ ] Step 4: Commit
bash
git add apps/api/docs/swagger.yaml
git commit -m "chore(api): regenerate swagger spec for v2 admin entities"

Verification before opening the PR ​

Run from the repo root (or worktree root):

bash
supabase db reset            # full migration + seed replay must succeed
bun nx test api              # all tests must pass
bun nx lint api              # if lint exists for the api project; skip otherwise
git status                   # confirm only the expected files changed

Open the PR against master. Per [[linear-status-workflow]] move the Linear ticket to In Review when the PR opens; Done only after merge.


Self-review — checked against the migration spec ​

  • ✅ Principal Partner enum + JWT + validate + manager middleware — Task 1.
  • ✅ nodes table + entity + CRUD — Task 2.
  • ✅ referrals table + entity + CRUD — Task 3.
  • ✅ Extend users with node_id, referred_by_user_id, photo_url — Task 4.
  • ✅ Extend mandates with pdf_key, md_key, json_key — Task 5.
  • ✅ Yannick seeded as Principal Partner; existing seeds wired to nodes + referrals — Task 6.
  • ✅ Swagger regenerated for PR 3 to consume — Task 7.
  • ❌ Out of scope (correctly deferred):
    • Orval regen + roles.ts update + ScopeService / NavPinService → PR 3.
    • R2 client config, contract ingest, Python sidecar, Claude call → PR 8–11.
    • Compensation policy schema + endpoints → PR 2.
    • Admin sidebar reorg, wizard, editor pages → PR 4+.