Files
paliad/internal/db/migrations/111_project_admin_and_select.down.sql
mAi 2ed0ef3177 feat(team-admin): t-paliad-223 Slice A — Project Admin role + inheritable role-edit gate
#48 — adds 'admin' as fifth project_teams.responsibility value, plumbs an
inheritable role-edit gate via the materialised ltree path.

- migration 110: ALTER responsibility CHECK, CREATE paliad.effective_project_admin(uuid,uuid) STABLE SECURITY DEFINER (mirrors can_see_project shape), REPLACE project_teams_update / _insert / _delete RLS policies. Idempotent + down-mig provided. Dry-run BEGIN..ROLLBACK clean on live supabase.
- services/approval_levels.go: ResponsibilityAdmin const + IsValidResponsibility extension. responsibilityOpensGate UNCHANGED — admin is orthogonal to the 4-Augen approval gate.
- services/team_service.go: ChangeResponsibility() with last-admin guard inside tx (counts admins on project + ancestor chain, excludes the row being changed). RemoveMember() also runs the guard when removing an admin row. New IsEffectiveProjectAdmin() driving the frontend affordance. legacyRoleFromResponsibility: admin → 'lead' (deprecated shadow column).
- services/project_service.go: ErrLastProjectAdmin sentinel mapped to 409 in writeServiceError.
- handlers/teams.go: new PATCH /api/projects/{id}/team/{user_id}. RLS-enforced; non-admins get 404 to avoid existence leakage.
- handlers/projects.go: GET /api/projects/{id} now wraps the payload with effective_admin bool so the frontend drives the inline-select affordance without a second round-trip.
- frontend/src/projects-detail.tsx + client/projects-detail.ts: admin appears as 5th option in 'Mitglied hinzufügen' dropdown. Team-list Rolle cell switches to an inline <select> for callers with effective_admin (read-only span otherwise). Optimistic PATCH with rollback on error (last-admin guard / 403 from RLS / etc.) surfaced as transient toast in #team-msg.
- i18n: +6 keys (admin label + admin.hint + 3 error toasts × 2 langs).
- tests: TestIsValidResponsibility now covers admin; new TestLegacyRoleFromResponsibility pins the mapping table.

go build && go test -short ./internal/... && bun run build all clean.
2026-05-20 14:46:36 +02:00

66 lines
2.5 KiB
SQL

-- Reverse of 111_project_admin_and_select.up.sql.
--
-- Drops effective_project_admin, restores the original RLS policies,
-- and shrinks the responsibility CHECK back to four values. Any rows
-- still carrying responsibility='admin' would violate the restored
-- CHECK; the down-migration backfills them to 'lead' (the closest
-- existing role) before re-adding the constraint.
-- ============================================================================
-- 1. Backfill any responsibility='admin' rows to 'lead'.
-- ============================================================================
UPDATE paliad.project_teams
SET responsibility = 'lead'
WHERE responsibility = 'admin';
-- ============================================================================
-- 2. Restore the original CHECK (lead/member/observer/external).
-- ============================================================================
ALTER TABLE paliad.project_teams
DROP CONSTRAINT IF EXISTS project_teams_responsibility_check;
ALTER TABLE paliad.project_teams
ADD CONSTRAINT project_teams_responsibility_check
CHECK (responsibility IN ('lead', 'member', 'observer', 'external'));
-- ============================================================================
-- 3. Restore the pre-110 RLS policies.
-- ============================================================================
DROP POLICY IF EXISTS project_teams_update ON paliad.project_teams;
CREATE POLICY project_teams_update
ON paliad.project_teams FOR UPDATE
USING (paliad.can_see_project(project_id))
WITH CHECK (paliad.can_see_project(project_id));
DROP POLICY IF EXISTS project_teams_insert ON paliad.project_teams;
CREATE POLICY project_teams_insert
ON paliad.project_teams FOR INSERT
WITH CHECK (
user_id = auth.uid()
OR paliad.can_see_project(project_id)
);
DROP POLICY IF EXISTS project_teams_delete ON paliad.project_teams;
CREATE POLICY project_teams_delete
ON paliad.project_teams FOR DELETE
USING (
paliad.can_see_project(project_id)
AND (
user_id = auth.uid()
OR EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid()
AND u.global_role = 'global_admin'
)
)
);
-- ============================================================================
-- 4. Drop the predicate function.
-- ============================================================================
DROP FUNCTION IF EXISTS paliad.effective_project_admin(uuid, uuid);