feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
|
|
@ -0,0 +1,308 @@
|
|||
-- Atomic, idempotent back-office mutations invoked only by the server-side admin app.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.admin_operation_requests (
|
||||
actor_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT,
|
||||
idempotency_key uuid NOT NULL,
|
||||
operation text NOT NULL CHECK (char_length(operation) BETWEEN 1 AND 80),
|
||||
request_hash text NOT NULL CHECK (char_length(request_hash) = 64),
|
||||
response_data jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
completed_at timestamptz,
|
||||
PRIMARY KEY (actor_id, idempotency_key)
|
||||
);
|
||||
|
||||
ALTER TABLE public.admin_operation_requests ENABLE ROW LEVEL SECURITY;
|
||||
REVOKE ALL ON TABLE public.admin_operation_requests FROM PUBLIC, anon, authenticated;
|
||||
GRANT SELECT, INSERT, UPDATE ON TABLE public.admin_operation_requests TO service_role;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.resolve_external_admin_actor_v1(
|
||||
p_email text,
|
||||
p_minimum_role text DEFAULT 'manager'
|
||||
)
|
||||
RETURNS TABLE(actor_id uuid, actor_role text)
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, public, auth
|
||||
AS $$
|
||||
DECLARE
|
||||
minimum_level integer;
|
||||
actual_level integer;
|
||||
BEGIN
|
||||
IF p_email IS NULL OR char_length(trim(p_email)) NOT BETWEEN 3 AND 150 THEN
|
||||
RAISE EXCEPTION 'invalid_actor_email' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
minimum_level := CASE p_minimum_role
|
||||
WHEN 'manager' THEN 1
|
||||
WHEN 'admin' THEN 2
|
||||
WHEN 'super_admin' THEN 3
|
||||
ELSE NULL
|
||||
END;
|
||||
IF minimum_level IS NULL THEN
|
||||
RAISE EXCEPTION 'invalid_minimum_role' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
SELECT profile.id, profile.role
|
||||
INTO actor_id, actor_role
|
||||
FROM auth.users AS account
|
||||
JOIN public.profiles AS profile ON profile.id = account.id
|
||||
WHERE lower(account.email) = lower(trim(p_email))
|
||||
LIMIT 1;
|
||||
|
||||
IF actor_id IS NULL THEN
|
||||
RAISE EXCEPTION 'admin_identity_not_linked' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
actual_level := CASE actor_role
|
||||
WHEN 'manager' THEN 1
|
||||
WHEN 'admin' THEN 2
|
||||
WHEN 'super_admin' THEN 3
|
||||
ELSE 0
|
||||
END;
|
||||
IF actual_level < minimum_level THEN
|
||||
RAISE EXCEPTION 'insufficient_admin_role' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
RETURN NEXT;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.resolve_external_admin_actor_v1(text, text) FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.resolve_external_admin_actor_v1(text, text) TO service_role;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.admin_change_user_role_v1(
|
||||
p_actor_email text,
|
||||
p_idempotency_key uuid,
|
||||
p_target_user_id uuid,
|
||||
p_new_role text,
|
||||
p_memo text
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, public, auth, extensions
|
||||
AS $$
|
||||
DECLARE
|
||||
actor record;
|
||||
before_role text;
|
||||
request_payload jsonb;
|
||||
request_digest text;
|
||||
existing_request public.admin_operation_requests%ROWTYPE;
|
||||
inserted boolean;
|
||||
result jsonb;
|
||||
BEGIN
|
||||
SELECT * INTO actor FROM public.resolve_external_admin_actor_v1(p_actor_email, 'admin');
|
||||
IF p_idempotency_key IS NULL OR p_target_user_id IS NULL THEN
|
||||
RAISE EXCEPTION 'idempotency_key_and_target_required' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
IF p_new_role NOT IN ('user', 'manager', 'admin', 'super_admin') THEN
|
||||
RAISE EXCEPTION 'invalid_role' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
IF p_memo IS NULL OR char_length(trim(p_memo)) NOT BETWEEN 3 AND 1000 THEN
|
||||
RAISE EXCEPTION 'memo_must_be_3_to_1000_characters' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
request_payload := jsonb_build_object('target', p_target_user_id, 'role', p_new_role, 'memo', trim(p_memo));
|
||||
request_digest := encode(extensions.digest(request_payload::text, 'sha256'), 'hex');
|
||||
INSERT INTO public.admin_operation_requests(actor_id, idempotency_key, operation, request_hash)
|
||||
VALUES (actor.actor_id, p_idempotency_key, 'user.role_change', request_digest)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING true INTO inserted;
|
||||
IF NOT coalesce(inserted, false) THEN
|
||||
SELECT * INTO existing_request FROM public.admin_operation_requests
|
||||
WHERE actor_id = actor.actor_id AND idempotency_key = p_idempotency_key;
|
||||
IF existing_request.operation <> 'user.role_change' OR existing_request.request_hash <> request_digest THEN
|
||||
RAISE EXCEPTION 'idempotency_key_reused_with_different_request' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
IF existing_request.response_data IS NULL THEN
|
||||
RAISE EXCEPTION 'operation_in_progress' USING ERRCODE = '55P03';
|
||||
END IF;
|
||||
RETURN existing_request.response_data;
|
||||
END IF;
|
||||
|
||||
SELECT role INTO before_role FROM public.profiles WHERE id = p_target_user_id FOR UPDATE;
|
||||
IF before_role IS NULL THEN
|
||||
RAISE EXCEPTION 'target_user_not_found' USING ERRCODE = 'P0002';
|
||||
END IF;
|
||||
IF actor.actor_role <> 'super_admin'
|
||||
AND (before_role IN ('admin', 'super_admin') OR p_new_role IN ('admin', 'super_admin')) THEN
|
||||
RAISE EXCEPTION 'super_admin_required_for_privileged_role' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
IF before_role = 'super_admin' AND p_new_role <> 'super_admin'
|
||||
AND (SELECT count(*) FROM public.profiles WHERE role = 'super_admin') <= 1 THEN
|
||||
RAISE EXCEPTION 'cannot_demote_last_super_admin' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
UPDATE public.profiles SET role = p_new_role, updated_at = now() WHERE id = p_target_user_id;
|
||||
UPDATE auth.users
|
||||
SET raw_app_meta_data = jsonb_set(coalesce(raw_app_meta_data, '{}'::jsonb), '{role}', to_jsonb(p_new_role), true),
|
||||
updated_at = now()
|
||||
WHERE id = p_target_user_id;
|
||||
|
||||
INSERT INTO public.audit_log(admin_id, action, target_type, target_id, before_data, after_data, memo)
|
||||
VALUES (actor.actor_id, 'user.role_change', 'profile', p_target_user_id,
|
||||
jsonb_build_object('role', before_role), jsonb_build_object('role', p_new_role), trim(p_memo));
|
||||
|
||||
result := jsonb_build_object('success', true, 'userId', p_target_user_id, 'newRole', p_new_role);
|
||||
UPDATE public.admin_operation_requests SET response_data = result, completed_at = now()
|
||||
WHERE actor_id = actor.actor_id AND idempotency_key = p_idempotency_key;
|
||||
RETURN result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.admin_change_user_role_v1(text, uuid, uuid, text, text) FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.admin_change_user_role_v1(text, uuid, uuid, text, text) TO service_role;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.admin_mutate_subscription_v1(
|
||||
p_actor_email text,
|
||||
p_idempotency_key uuid,
|
||||
p_action text,
|
||||
p_user_id uuid,
|
||||
p_tier text DEFAULT NULL,
|
||||
p_status text DEFAULT NULL,
|
||||
p_current_period_end timestamptz DEFAULT NULL,
|
||||
p_overage_credits integer DEFAULT NULL,
|
||||
p_admin_note text DEFAULT NULL,
|
||||
p_memo text DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, public, auth, extensions
|
||||
AS $$
|
||||
DECLARE
|
||||
actor record;
|
||||
before_subscription public.subscriptions%ROWTYPE;
|
||||
after_subscription public.subscriptions%ROWTYPE;
|
||||
request_payload jsonb;
|
||||
request_digest text;
|
||||
existing_request public.admin_operation_requests%ROWTYPE;
|
||||
inserted boolean;
|
||||
result jsonb;
|
||||
before_snapshot jsonb;
|
||||
after_snapshot jsonb;
|
||||
BEGIN
|
||||
SELECT * INTO actor FROM public.resolve_external_admin_actor_v1(p_actor_email, 'manager');
|
||||
IF p_action NOT IN ('create', 'update', 'delete') OR p_idempotency_key IS NULL OR p_user_id IS NULL THEN
|
||||
RAISE EXCEPTION 'invalid_subscription_operation' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
IF p_action IN ('create', 'delete') AND actor.actor_role = 'manager' THEN
|
||||
RAISE EXCEPTION 'admin_role_required' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
IF p_memo IS NULL OR char_length(trim(p_memo)) NOT BETWEEN 3 AND 1000 THEN
|
||||
RAISE EXCEPTION 'memo_must_be_3_to_1000_characters' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
IF p_tier IS NOT NULL AND p_tier NOT IN ('free', 'pro', 'pro_plus') THEN
|
||||
RAISE EXCEPTION 'invalid_subscription_tier' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
IF p_status IS NOT NULL AND p_status NOT IN ('active', 'canceled', 'past_due', 'expired') THEN
|
||||
RAISE EXCEPTION 'invalid_subscription_status' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
IF p_overage_credits IS NOT NULL AND (p_overage_credits < 0 OR p_overage_credits > 1000000) THEN
|
||||
RAISE EXCEPTION 'invalid_overage_credits' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
IF p_admin_note IS NOT NULL AND char_length(p_admin_note) > 2000 THEN
|
||||
RAISE EXCEPTION 'admin_note_too_long' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM public.profiles WHERE id = p_user_id) THEN
|
||||
RAISE EXCEPTION 'target_user_not_found' USING ERRCODE = 'P0002';
|
||||
END IF;
|
||||
|
||||
request_payload := jsonb_build_object(
|
||||
'action', p_action, 'user', p_user_id, 'tier', p_tier, 'status', p_status,
|
||||
'periodEnd', p_current_period_end, 'credits', p_overage_credits,
|
||||
'note', p_admin_note, 'memo', trim(p_memo)
|
||||
);
|
||||
request_digest := encode(extensions.digest(request_payload::text, 'sha256'), 'hex');
|
||||
INSERT INTO public.admin_operation_requests(actor_id, idempotency_key, operation, request_hash)
|
||||
VALUES (actor.actor_id, p_idempotency_key, 'subscription.' || p_action, request_digest)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING true INTO inserted;
|
||||
IF NOT coalesce(inserted, false) THEN
|
||||
SELECT * INTO existing_request FROM public.admin_operation_requests
|
||||
WHERE actor_id = actor.actor_id AND idempotency_key = p_idempotency_key;
|
||||
IF existing_request.operation <> ('subscription.' || p_action) OR existing_request.request_hash <> request_digest THEN
|
||||
RAISE EXCEPTION 'idempotency_key_reused_with_different_request' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
IF existing_request.response_data IS NULL THEN
|
||||
RAISE EXCEPTION 'operation_in_progress' USING ERRCODE = '55P03';
|
||||
END IF;
|
||||
RETURN existing_request.response_data;
|
||||
END IF;
|
||||
|
||||
SELECT * INTO before_subscription FROM public.subscriptions WHERE user_id = p_user_id FOR UPDATE;
|
||||
IF p_action = 'create' THEN
|
||||
IF before_subscription.id IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'subscription_already_exists' USING ERRCODE = '23505';
|
||||
END IF;
|
||||
IF p_tier IS NULL THEN RAISE EXCEPTION 'tier_required' USING ERRCODE = '22023'; END IF;
|
||||
INSERT INTO public.subscriptions(user_id, tier, status, provider, payment_provider,
|
||||
current_period_start, current_period_end, overage_credits, admin_note, created_at, updated_at)
|
||||
VALUES (p_user_id, p_tier, coalesce(p_status, 'active'), 'none', 'none', now(),
|
||||
p_current_period_end, coalesce(p_overage_credits, 0), p_admin_note, now(), now())
|
||||
RETURNING * INTO after_subscription;
|
||||
ELSIF p_action = 'update' THEN
|
||||
IF before_subscription.id IS NULL THEN RAISE EXCEPTION 'subscription_not_found' USING ERRCODE = 'P0002'; END IF;
|
||||
UPDATE public.subscriptions SET
|
||||
tier = coalesce(p_tier, tier),
|
||||
status = coalesce(p_status, status),
|
||||
current_period_end = CASE WHEN p_current_period_end IS NULL THEN current_period_end ELSE p_current_period_end END,
|
||||
overage_credits = coalesce(p_overage_credits, overage_credits),
|
||||
admin_note = coalesce(p_admin_note, admin_note),
|
||||
updated_at = now()
|
||||
WHERE user_id = p_user_id RETURNING * INTO after_subscription;
|
||||
ELSE
|
||||
IF before_subscription.id IS NULL THEN RAISE EXCEPTION 'subscription_not_found' USING ERRCODE = 'P0002'; END IF;
|
||||
UPDATE public.subscriptions SET tier = 'free', status = 'expired', cancel_at = now(),
|
||||
admin_note = '[DELETED] ' || trim(p_memo), updated_at = now()
|
||||
WHERE user_id = p_user_id RETURNING * INTO after_subscription;
|
||||
END IF;
|
||||
|
||||
UPDATE public.profiles SET tier = after_subscription.tier, updated_at = now() WHERE id = p_user_id;
|
||||
before_snapshot := CASE WHEN before_subscription.id IS NULL THEN NULL ELSE jsonb_build_object(
|
||||
'id', before_subscription.id,
|
||||
'user_id', before_subscription.user_id,
|
||||
'tier', before_subscription.tier,
|
||||
'status', before_subscription.status,
|
||||
'provider', before_subscription.provider,
|
||||
'payment_provider', before_subscription.payment_provider,
|
||||
'current_period_start', before_subscription.current_period_start,
|
||||
'current_period_end', before_subscription.current_period_end,
|
||||
'overage_credits', before_subscription.overage_credits,
|
||||
'admin_note', before_subscription.admin_note,
|
||||
'cancel_at', before_subscription.cancel_at,
|
||||
'created_at', before_subscription.created_at,
|
||||
'updated_at', before_subscription.updated_at
|
||||
) END;
|
||||
after_snapshot := jsonb_build_object(
|
||||
'id', after_subscription.id,
|
||||
'user_id', after_subscription.user_id,
|
||||
'tier', after_subscription.tier,
|
||||
'status', after_subscription.status,
|
||||
'provider', after_subscription.provider,
|
||||
'payment_provider', after_subscription.payment_provider,
|
||||
'current_period_start', after_subscription.current_period_start,
|
||||
'current_period_end', after_subscription.current_period_end,
|
||||
'overage_credits', after_subscription.overage_credits,
|
||||
'admin_note', after_subscription.admin_note,
|
||||
'cancel_at', after_subscription.cancel_at,
|
||||
'created_at', after_subscription.created_at,
|
||||
'updated_at', after_subscription.updated_at
|
||||
);
|
||||
INSERT INTO public.audit_log(admin_id, action, target_type, target_id, before_data, after_data, memo)
|
||||
VALUES (actor.actor_id, 'subscription.' || p_action, 'subscription', p_user_id,
|
||||
before_snapshot, after_snapshot, trim(p_memo));
|
||||
|
||||
result := jsonb_build_object('success', true, 'subscription', after_snapshot);
|
||||
UPDATE public.admin_operation_requests SET response_data = result, completed_at = now()
|
||||
WHERE actor_id = actor.actor_id AND idempotency_key = p_idempotency_key;
|
||||
RETURN result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.admin_mutate_subscription_v1(text, uuid, text, uuid, text, text, timestamptz, integer, text, text) FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.admin_mutate_subscription_v1(text, uuid, text, uuid, text, text, timestamptz, integer, text, text) TO service_role;
|
||||
|
||||
COMMIT;
|
||||
Loading…
Add table
Add a link
Reference in a new issue