feat(configstore): org team CRUD APIs, admin UI, and legacy table-name grandfathering#970
Merged
Conversation
…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.
Test Impact PlanDeterministic summary of how this PR changes tests, CI runners, and coverage-risk signals. Summary
Signals
Coverage risk: neutral or increased No coverage-reduction warnings detected. |
This was referenced Jul 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_teamsgains three nullable legacy overrides:events_table_name,persons_table_name,schema_data_imports_name.NULL(every team created going forward) means derive fromschema_name: events at<schema>.events, persons at<schema>.persons, data imports under<schema>_data_imports.(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*_namefields (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 overwriteschema_nameand the legacy names, because the PostHog backfill must replace the migration'steam_<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:created_at(ties byteam_id) automatically becomes billing, and unacked usage buckets are re-attributed in the same transaction (ReattributeUsageTeamTx, same as a billing repoint). Response carriesnew_billing_team_id.team_id+schema_namethat create the first (billing) team row with an explicit schema. Deploy ordering: the legacydefault_team_idfield 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_idin the body likePOST /users— existing row is a 409, never an overwrite),PUT /orgs/:id/teams/:team_id(enabled /backfill_enabledincl. explicit-null clear /is_billing_team: truerepoint with atomic usage re-attribution; clearing billing and changingschema_nameare 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
(org,schema)index, v8-replay, GORM-model/goose-schema metadata match extended to the new columns/index.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 bycreated_at(not team id) with usage re-attribution, last-team refusal, provision withteam_id+schema_name, legacydefault_team_idpath unchanged.OrgTeams.test.tsx) green.org_teams_crudleg (list/upsert/grandfather/409/delete rules on the ext org, self-cleaning).🤖 Generated with Claude Code