Appearance
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>.sqland live insupabase/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 viadb:tags.domain.gostructs 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 in20260616000000_core_domain.sql. - All new endpoints mount under
/api/v1and requireRequireAuth. Writes require manager roles viaRequireRole(...)â 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 thereforeyannick@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.goand use the existingtestPool(t)/sign(t, sub, role)/do(t, ...)helpers. They skip whenDATABASE_URLis 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(replaceNNwith 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 regenerateapps/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 themembership_roleenum.supabase/migrations/20260618100100_nodes.sqlânodestable + seed two nodes (Node 1 Montreal, Node 2 Toronto).supabase/migrations/20260618100200_referrals.sqlâreferralstable linking referrer â referee users.supabase/migrations/20260618100300_users_v2_fields.sqlâ addsnode_id,referred_by_user_id,photo_urltopublic.users.supabase/migrations/20260618100400_mandates_contract_keys.sqlâ addspdf_key,md_key,json_keytopublic.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, wirereferred_by_user_idchains to mirror the v2REFERRAL_SEED.libs/auth/jwt.goâ addRolePrincipalPartnerconst; introduce aManagerRolesslice that includes Managing Partner, Admin, and Principal Partner.apps/api/internal/domain/domain.goâ extendUserandMandatestructs + theirSchemas; addNodeandReferralstructs +Schema()constructors; allow"Principal Partner"in user validate.apps/api/internal/router/router.goâ mountnodesandreferralsCRUD; switch the managerRequireRole(...)call to useauth.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 byswag initat 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 validatecaseline) - Modify:
apps/api/internal/router/router.go(replace explicitRequireRole(auth.RoleManagingPartner, auth.RoleAdmin)withRequireRole(auth.ManagerRoles...)) - Modify:
apps/api/internal/router/router_test.go(addTestPrincipalPartnerAcceptedByManagerEndpoints)
Interfaces:
Produces:
auth.RolePrincipalPartner(Roleconst ="Principal Partner"),auth.ManagerRoles([]Role={RoleManagingPartner, RoleAdmin, RolePrincipalPartner}). All later tasks useauth.ManagerRoles...when callingRequireRole.[ ] 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 -vExpected: 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 resetExpected: 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 -vExpected: PASS.
- [ ] Step 9: Run the full API test suite â no regressions
bash
bun nx test apiExpected: 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(addNodestruct +NodeSchema()) - Modify:
apps/api/internal/router/router.go(addcrud.Mountline) - Create:
apps/api/internal/router/nodes_test.go
Interfaces:
Produces:
gotype 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 -vExpected: 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 resetExpected: completes without error.
- [ ] Step 7: Run test to verify it passes
bash
bun nx test api -- -run 'TestNodes' -vExpected: 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(addReferralstruct +ReferralSchema()) - Modify:
apps/api/internal/router/router.go(mount line) - Create:
apps/api/internal/router/referrals_test.go
Interfaces:
Produces:
gotype 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 -vExpected: 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 -vExpected: 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(extendUserstruct +UserSchema()) - Modify:
apps/api/internal/router/router_test.go(addTestUserUpdateWithV2Fields)
Interfaces:
Consumes:
Node(Task 2) for the FK target.Produces:
Userstruct now carriesgoNodeID *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
Usermodel 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 -vExpected: 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
Userstruct
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 -vExpected: PASS.
- [ ] Step 7: Full regression
bash
bun nx test apiExpected: 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(extendMandatestruct +MandateSchema()) - Modify:
apps/api/internal/router/router_test.go(addTestMandateRoundTripsContractKeys)
Interfaces:
Produces:
Mandatestruct carriesgoPDFKey *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(), ¤t); 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 -vExpected: 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
Mandatestruct + 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 -vExpected: PASS.
- [ ] Step 6: Full regression
bash
bun nx test apiExpected: 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(addTestYannickIsPrincipalPartnersmoke test)
Interfaces:
Consumes:
Principal Partnerenum value (Task 1),nodestable (Task 2),referralstable (Task 3), new user columns (Task 4).Produces:
yannick@gravitasacumen.orgexists withmembership_role = 'Principal Partner';Node 1haslead_id = priya@;Node 2haslead_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' -vExpected: 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 resetExpected: 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' -vExpected: both PASS.
- [ ] Step 6: Full regression
bash
bun nx test apiExpected: 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 apiExpected: 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.yamlExpected: 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 apiExpected: 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 changedOpen 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.
- â
nodestable + entity + CRUD â Task 2. - â
referralstable + entity + CRUD â Task 3. - â
Extend
userswithnode_id,referred_by_user_id,photo_urlâ Task 4. - â
Extend
mandateswithpdf_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.tsupdate +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+.
- Orval regen +