Skip to content

feat(configstore): org team CRUD APIs, admin UI, and legacy table-name grandfathering#970

Merged
benben merged 1 commit into
mainfrom
ben/org-teams-crud
Jul 21, 2026
Merged

feat(configstore): org team CRUD APIs, admin UI, and legacy table-name grandfathering#970
benben merged 1 commit into
mainfrom
ben/org-teams-crud

Conversation

@benben

@benben benben commented Jul 20, 2026

Copy link
Copy Markdown
Member

Follow-up to #969 (duckgres_org_teams; squash-merged, so this is now based on main). Adds the CRUD surfaces for org team rows: an internal provisioning API for the PostHog backend, an admin API + console UI for operators, and the legacy table-name grandfathering model.

Migration 000025

  • duckgres_org_teams gains three nullable legacy overrides: events_table_name, persons_table_name, schema_data_imports_name.
    • NULL (every team created going forward) means derive from schema_name: events at <schema>.events, persons at <schema>.persons, data imports under <schema>_data_imports.
    • Non-NULL values are grandfathered explicit names for pre-existing teams whose tables predate the schema-per-team convention; the PostHog-side backfill sets them.
  • Unique index on (org_id, schema_name) — two teams in one org can never share a schema. Safe on existing data: 000024 backfilled at most one row per org.

Provisioning API (internal, X-Duckgres-Internal-Secret)

  • GET /api/v1/orgs/:id/teams — list the org's rows (404 unknown org).
  • POST /api/v1/orgs/:id/teams — create/upsert. Body: team_id (required), schema_name (required; lowercase [a-z0-9_], ≤63, not digit-led), enabled (default true), backfill_enabled, and the three legacy *_name fields (presence-aware; "" clears back to NULL). Duplicate schema in the org → 409.
    This endpoint IS the grandfather path: upserting an existing (org, team) row may overwrite schema_name and the legacy names, because the PostHog backfill must replace the migration's team_<id> placeholder — so schema immutability is deliberately NOT enforced here. It IS enforced on the user-facing admin surface.
  • DELETE /api/v1/orgs/:id/teams/:team_id — deletes the config row only (never warehouse data). Transactional rules:
    • Billing team deleted → the remaining team with the oldest created_at (ties by team_id) automatically becomes billing, and unacked usage buckets are re-attributed in the same transaction (ReattributeUsageTeamTx, same as a billing repoint). Response carries new_billing_team_id.
    • Last team → 409; deleting the org is the only way.
  • Provision body gains optional team_id + schema_name that create the first (billing) team row with an explicit schema. Deploy ordering: the legacy default_team_id field keeps working — this ships before the PostHog-side change; if both are sent they must agree or 400.

Admin API + console

Both API packages register on the same audited gin group, so the admin surface adds only non-colliding routes: GET /teams (cross-org list), POST /teams (create-only, org_id in the body like POST /users — existing row is a 409, never an overwrite), PUT /orgs/:id/teams/:team_id (enabled / backfill_enabled incl. explicit-null clear / is_billing_team: true repoint with atomic usage re-attribution; clearing billing and changing schema_name are rejected). Per-org list and delete reuse the provisioning routes; all mutations are audited (team.* actions).

UI: new Org teams sidebar entry directly below Organizations (table of all teams: org, team id, schema, enabled, billing, backfill, created; search; create/edit/delete dialogs — create warns the schema name can never change, delete surfaces the billing handover and refuses the last team up front), a Teams card on the org detail page, and an Org Teams entry in the config-store explorer right after Orgs.

Tests

  • Migration coverage: columns nullable, unique (org,schema) index, v8-replay, GORM-model/goose-schema metadata match extended to the new columns/index.
  • Postgres-backed (tests/configstore/org_teams_postgres_test.go + admin/provisioning suites): grandfather overwrite + presence-aware preserves/clears, schema 409 in-org and OK cross-org (plus DB-level index enforcement), billing handover ordered by created_at (not team id) with usage re-attribution, last-team refusal, provision with team_id+schema_name, legacy default_team_id path unchanged.
  • Admin UI: typecheck, eslint, build, and vitest (new OrgTeams.test.tsx) green.
  • e2e harness gains an org_teams_crud leg (list/upsert/grandfather/409/delete rules on the ext org, self-cleaning).

🤖 Generated with Claude Code

…e grandfathering

Managed warehouses now carry many PostHog teams per org
(duckgres_org_teams, migration 000024); this adds the surfaces to manage
those rows.

Migration 000025 adds three nullable legacy table-name overrides to
duckgres_org_teams (events_table_name, persons_table_name,
schema_data_imports_name — NULL means derive from schema_name:
<schema>.events, <schema>.persons, <schema>_data_imports; non-NULL values
are grandfathered explicit names for pre-existing teams) and a unique
(org_id, schema_name) index so two teams in one org can never share a
schema.

Provisioning API (internal secret, for the PostHog backend):

- GET  /api/v1/orgs/:id/teams — list the org's team rows
- POST /api/v1/orgs/:id/teams — create/upsert. This IS the grandfather
  path: upserting an existing (org, team) row may overwrite schema_name
  and the legacy table names, because the PostHog backfill replaces the
  migration's 'team_<id>' placeholder through it. schema_name is
  validated as a safe lowercase identifier; a duplicate within the org
  is a 409.
- DELETE /api/v1/orgs/:id/teams/:team_id — config-only delete (never
  warehouse data). Deleting the billing team promotes the remaining team
  with the oldest created_at and re-attributes unacked usage buckets in
  the same transaction (ReattributeUsageTeamTx); the org's last team is
  refused with a 409 (deleting the org is the only way).
- The provision body gains optional team_id + schema_name that create
  the first (billing) team row with an explicit schema. The legacy
  default_team_id field keeps working (this ships before the
  PostHog-side change); when both are given they must agree or 400.

Admin API + console (user-facing surface — schema_name is immutable
here, unlike the provisioning grandfather path): GET /teams (cross-org
list), POST /teams (create-only, org_id in the body), and
PUT /orgs/:id/teams/:team_id (enabled / backfill_enabled / billing
repoint with atomic usage re-attribution; billing can never be cleared,
only repointed). Per-org list and delete reuse the provisioning routes
on the same audited router group — gin refuses duplicate paths. The UI
gains an 'Org teams' sidebar page below Organizations (full CRUD with
last-team and billing-handover warnings), a Teams card on the org detail
page, and an Org Teams entry in the config-store explorer after Orgs.

Shared rules live in configstore (UpsertOrgTeamTx, DeleteOrgTeamTx,
ValidateOrgTeamSchemaName). Tests cover the migration shape, the
grandfather overwrite, per-org schema 409 (and cross-org reuse), billing
handover ordering by created_at with usage re-attribution, last-team
refusal, and both provision paths; the e2e harness gains an
org_teams_crud leg.
@github-actions

Copy link
Copy Markdown

Test Impact Plan

Deterministic summary of how this PR changes tests, CI runners, and coverage-risk signals.

Summary

Area Added Changed Deleted
Test files 1 7 0
E2E/journey files 0 0 0
Workflow files 0 0 0

Signals

  • Test cases: +22 / -0
  • Assertions: +133 / -0
  • Skips or known failures added: 0
  • Workflow continue-on-error added: 0
  • Workflow path filters added: 0
  • Test commands removed from justfile: 0
  • E2E/journey retry lines added: 0

Coverage risk: neutral or increased

No coverage-reduction warnings detected.

@benben
benben merged commit 6518697 into main Jul 21, 2026
31 of 32 checks passed
@benben
benben deleted the ben/org-teams-crud branch July 21, 2026 08:36
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