[
  {
    "esquema": "kynda",
    "nombre": "action_brand_consistency",
    "definicion": "CREATE OR REPLACE FUNCTION kynda.action_brand_consistency()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$\r\ndeclare cb uuid;\r\nbegin\r\n  select brand_id into cb from campaign where id = new.campaign_id;\r\n  if cb is null then raise exception 'action: campaign % not found', new.campaign_id; end if;\r\n  if new.brand_id is null then new.brand_id := cb;\r\n  elsif new.brand_id <> cb then\r\n    raise exception 'action.brand_id (%) does not match campaign.brand_id (%)', new.brand_id, cb;\r\n  end if;\r\n  return new;\r\nend;\r\n$function$\n"
  },
  {
    "esquema": "kynda",
    "nombre": "action_brand_immutable",
    "definicion": "CREATE OR REPLACE FUNCTION kynda.action_brand_immutable()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$\r\nbegin\r\n  if new.brand_id <> old.brand_id then raise exception 'action.brand_id is immutable'; end if;\r\n  return new;\r\nend;\r\n$function$\n"
  },
  {
    "esquema": "kynda",
    "nombre": "audit_log_immutable",
    "definicion": "CREATE OR REPLACE FUNCTION kynda.audit_log_immutable()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$\r\nbegin\r\n  raise exception 'audit_log is immutable';\r\nend;\r\n$function$\n"
  },
  {
    "esquema": "kynda",
    "nombre": "brand_default",
    "definicion": "CREATE OR REPLACE FUNCTION kynda.brand_default()\n RETURNS uuid\n LANGUAGE sql\n STABLE\nAS $function$\r\n  select id from public.brand where slug = coalesce(\r\n    nullif(current_setting('app.default_brand_slug', true), ''),\r\n    'easyfairs'\r\n  ) limit 1\r\n$function$\n"
  },
  {
    "esquema": "kynda",
    "nombre": "chain_brand_consistency",
    "definicion": "CREATE OR REPLACE FUNCTION kynda.chain_brand_consistency()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$\r\ndeclare t record;\r\nbegin\r\n  select brand_id, campaign_id into t from chain_token where id = new.token_id;\r\n  if t is null then raise exception 'chain: token % not found', new.token_id; end if;\r\n  if new.campaign_id is null then new.campaign_id := t.campaign_id;\r\n  elsif new.campaign_id <> t.campaign_id then\r\n    raise exception 'chain.campaign_id does not match chain_token.campaign_id';\r\n  end if;\r\n  if new.brand_id is null then new.brand_id := t.brand_id;\r\n  elsif new.brand_id <> t.brand_id then\r\n    raise exception 'chain.brand_id does not match chain_token.brand_id';\r\n  end if;\r\n  return new;\r\nend;\r\n$function$\n"
  },
  {
    "esquema": "kynda",
    "nombre": "chain_link_insert_guard",
    "definicion": "CREATE OR REPLACE FUNCTION kynda.chain_link_insert_guard()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$\r\ndeclare c record; prev_status text;\r\nbegin\r\n  select brand_id, campaign_id, current_position, status into c from chain where id = new.chain_id;\r\n  if c is null then raise exception 'chain_link: chain % not found', new.chain_id; end if;\r\n  if new.brand_id is null then new.brand_id := c.brand_id;\r\n  elsif new.brand_id <> c.brand_id then\r\n    raise exception 'chain_link.brand_id does not match chain.brand_id';\r\n  end if;\r\n  if c.status <> 'ACTIVE' then\r\n    raise exception 'chain % is not ACTIVE (status %)', new.chain_id, c.status;\r\n  end if;\r\n  if new.position <> c.current_position then\r\n    raise exception 'chain_link.position (%) must equal chain.current_position (%) — BR-3 sequential progression', new.position, c.current_position;\r\n  end if;\r\n  if new.position > 1 then\r\n    select status into prev_status from chain_link where chain_id = new.chain_id and position = new.position - 1;\r\n    if prev_status is distinct from 'COMPLETED' then\r\n      raise exception 'chain_link % cannot open: previous link not COMPLETED (BR-3)', new.position;\r\n    end if;\r\n  end if;\r\n  return new;\r\nend;\r\n$function$\n"
  },
  {
    "esquema": "kynda",
    "nombre": "chain_link_status_guard",
    "definicion": "CREATE OR REPLACE FUNCTION kynda.chain_link_status_guard()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$\r\nbegin\r\n  if new.brand_id <> old.brand_id then raise exception 'chain_link.brand_id is immutable'; end if;\r\n  if new.chain_id <> old.chain_id then raise exception 'chain_link.chain_id is immutable'; end if;\r\n  if new.position <> old.position then raise exception 'chain_link.position is immutable'; end if;\r\n  if new.user_id <> old.user_id then raise exception 'chain_link.user_id is immutable'; end if;\r\n  if new.status <> old.status then\r\n    if not (old.status = 'ACTIVE' and new.status = 'COMPLETED') then\r\n      raise exception 'illegal chain_link.status transition: % -> %', old.status, new.status;\r\n    end if;\r\n    if new.completed_at is null then new.completed_at := now(); end if;\r\n  end if;\r\n  return new;\r\nend;\r\n$function$\n"
  },
  {
    "esquema": "kynda",
    "nombre": "chain_status_guard",
    "definicion": "CREATE OR REPLACE FUNCTION kynda.chain_status_guard()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$\r\ndeclare cl smallint; ok boolean := false;\r\nbegin\r\n  if new.brand_id <> old.brand_id then raise exception 'chain.brand_id is immutable'; end if;\r\n  if new.campaign_id <> old.campaign_id then raise exception 'chain.campaign_id is immutable'; end if;\r\n  if new.token_id <> old.token_id then raise exception 'chain.token_id is immutable'; end if;\r\n  -- current_position solo avanza de 1 en 1 y nunca más allá de chain_length (BR-2/BR-3).\r\n  if new.current_position <> old.current_position then\r\n    if new.current_position <> old.current_position + 1 then\r\n      raise exception 'chain.current_position must advance one step at a time (% -> %)', old.current_position, new.current_position;\r\n    end if;\r\n    select chain_length into cl from campaign where id = new.campaign_id;\r\n    if cl is not null and new.current_position > cl then\r\n      raise exception 'chain.current_position (%) exceeds campaign.chain_length (%)', new.current_position, cl;\r\n    end if;\r\n  end if;\r\n  if new.status <> old.status then\r\n    ok := case old.status\r\n      when 'ACTIVE' then new.status in ('COMPLETED','ABANDONED')\r\n      else false -- COMPLETED / ABANDONED terminales\r\n    end;\r\n    if not ok then\r\n      raise exception 'illegal chain.status transition: % -> %', old.status, new.status;\r\n    end if;\r\n    if new.status = 'COMPLETED' and new.completed_at is null then\r\n      new.completed_at := now();\r\n    end if;\r\n  end if;\r\n  return new;\r\nend;\r\n$function$\n"
  },
  {
    "esquema": "kynda",
    "nombre": "chain_token_brand_consistency",
    "definicion": "CREATE OR REPLACE FUNCTION kynda.chain_token_brand_consistency()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$\r\ndeclare cb uuid;\r\nbegin\r\n  select brand_id into cb from campaign where id = new.campaign_id;\r\n  if cb is null then raise exception 'chain_token: campaign % not found', new.campaign_id; end if;\r\n  if new.brand_id is null then new.brand_id := cb;\r\n  elsif new.brand_id <> cb then\r\n    raise exception 'chain_token.brand_id (%) does not match campaign.brand_id (%)', new.brand_id, cb;\r\n  end if;\r\n  return new;\r\nend;\r\n$function$\n"
  },
  {
    "esquema": "kynda",
    "nombre": "chain_token_status_guard",
    "definicion": "CREATE OR REPLACE FUNCTION kynda.chain_token_status_guard()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$\r\ndeclare ok boolean := false;\r\nbegin\r\n  if new.brand_id <> old.brand_id then raise exception 'chain_token.brand_id is immutable'; end if;\r\n  if new.campaign_id <> old.campaign_id then raise exception 'chain_token.campaign_id is immutable'; end if;\r\n  if new.status = old.status then return new; end if;\r\n  ok := case old.status\r\n    when 'UNUSED' then new.status in ('ACTIVE','REVOKED')\r\n    when 'ACTIVE' then new.status in ('CONSUMED')\r\n    else false -- CONSUMED / REVOKED terminales\r\n  end;\r\n  if not ok then\r\n    raise exception 'illegal chain_token.status transition: % -> %', old.status, new.status;\r\n  end if;\r\n  if new.status = 'REVOKED' and new.revoked_at is null then\r\n    new.revoked_at := now();\r\n  end if;\r\n  return new;\r\nend;\r\n$function$\n"
  },
  {
    "esquema": "kynda",
    "nombre": "current_brand_id",
    "definicion": "CREATE OR REPLACE FUNCTION kynda.current_brand_id()\n RETURNS uuid\n LANGUAGE sql\n STABLE\nAS $function$\r\n  select nullif(\r\n    coalesce(\r\n      auth.jwt() -> 'app_metadata' ->> 'current_brand_id',\r\n      auth.jwt() -> 'user_metadata' ->> 'current_brand_id'\r\n    ),\r\n    ''\r\n  )::uuid\r\n$function$\n"
  },
  {
    "esquema": "kynda",
    "nombre": "enrollment_brand_consistency",
    "definicion": "CREATE OR REPLACE FUNCTION kynda.enrollment_brand_consistency()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$\r\ndeclare ab uuid;\r\nbegin\r\n  select brand_id into ab from action where id = new.action_id;\r\n  if ab is null then raise exception 'enrollment: action % not found', new.action_id; end if;\r\n  if new.brand_id is null then new.brand_id := ab;\r\n  elsif new.brand_id <> ab then\r\n    raise exception 'enrollment.brand_id (%) does not match action.brand_id (%)', new.brand_id, ab;\r\n  end if;\r\n  return new;\r\nend;\r\n$function$\n"
  },
  {
    "esquema": "kynda",
    "nombre": "evidence_brand_consistency",
    "definicion": "CREATE OR REPLACE FUNCTION kynda.evidence_brand_consistency()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$\r\ndeclare ab uuid;\r\nbegin\r\n  select brand_id into ab from action_execution where id = new.action_execution_id;\r\n  if ab is null then raise exception 'evidence: execution % not found', new.action_execution_id; end if;\r\n  if new.brand_id is null then new.brand_id := ab;\r\n  elsif new.brand_id <> ab then\r\n    raise exception 'evidence.brand_id mismatch';\r\n  end if;\r\n  return new;\r\nend;\r\n$function$\n"
  },
  {
    "esquema": "kynda",
    "nombre": "evidence_media_brand_consistency",
    "definicion": "CREATE OR REPLACE FUNCTION kynda.evidence_media_brand_consistency()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$\r\ndeclare eb uuid;\r\nbegin\r\n  select brand_id into eb from evidence where id = new.evidence_id;\r\n  if eb is null then raise exception 'evidence_media: evidence % not found', new.evidence_id; end if;\r\n  if new.brand_id is null then new.brand_id := eb;\r\n  elsif new.brand_id <> eb then\r\n    raise exception 'evidence_media.brand_id mismatch';\r\n  end if;\r\n  return new;\r\nend;\r\n$function$\n"
  },
  {
    "esquema": "kynda",
    "nombre": "execution_brand_consistency",
    "definicion": "CREATE OR REPLACE FUNCTION kynda.execution_brand_consistency()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$\r\ndeclare ab uuid;\r\nbegin\r\n  select brand_id into ab from action where id = new.action_id;\r\n  if ab is null then raise exception 'action_execution: action % not found', new.action_id; end if;\r\n  if new.brand_id is null then new.brand_id := ab;\r\n  elsif new.brand_id <> ab then\r\n    raise exception 'action_execution.brand_id mismatch';\r\n  end if;\r\n  return new;\r\nend;\r\n$function$\n"
  },
  {
    "esquema": "kynda",
    "nombre": "execution_chain_advance",
    "definicion": "CREATE OR REPLACE FUNCTION kynda.execution_chain_advance()\n RETURNS trigger\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO 'public', 'kynda'\nAS $function$\r\ndeclare\r\n  v_link record;\r\n  v_chain record;\r\n  v_len smallint;\r\nbegin\r\n  if new.status <> 'APPROVED' or new.chain_link_id is null then return null; end if;\r\n  if old.status = 'APPROVED' then return null; end if;\r\n\r\n  select * into v_link from chain_link where id = new.chain_link_id for update;\r\n  if not found or v_link.status = 'COMPLETED' then return null; end if;\r\n\r\n  update chain_link set status = 'COMPLETED', completed_at = now() where id = v_link.id;\r\n\r\n  insert into audit_log (actor_type, brand_id, campaign_id, action, entity_type, entity_id, after_value)\r\n  select 'SYSTEM', v_link.brand_id, c.campaign_id, 'CHAIN_LINK_COMPLETED', 'CHAIN_LINK', v_link.id,\r\n         jsonb_build_object('position', v_link.position, 'user_id', v_link.user_id, 'chain_id', v_link.chain_id)\r\n  from chain c where c.id = v_link.chain_id;\r\n\r\n  select * into v_chain from chain where id = v_link.chain_id for update;\r\n  select chain_length into v_len from campaign where id = v_chain.campaign_id;\r\n\r\n  if v_len is not null and v_link.position >= v_len then\r\n    -- Último eslabón aprobado → cadena COMPLETADA, token CONSUMIDO (BR-2, CA-5.4).\r\n    update chain set status = 'COMPLETED', completed_at = now() where id = v_chain.id;\r\n    update chain_token set status = 'CONSUMED' where id = v_chain.token_id and status = 'ACTIVE';\r\n    insert into audit_log (actor_type, brand_id, campaign_id, action, entity_type, entity_id, after_value)\r\n    values ('SYSTEM', v_chain.brand_id, v_chain.campaign_id, 'CHAIN_COMPLETED', 'CHAIN', v_chain.id,\r\n            jsonb_build_object('links_completed', v_link.position)),\r\n           ('SYSTEM', v_chain.brand_id, v_chain.campaign_id, 'CHAIN_TOKEN_CONSUMED', 'CHAIN_TOKEN', v_chain.token_id,\r\n            jsonb_build_object('chain_id', v_chain.id));\r\n  else\r\n    -- Abrir el siguiente eslabón: el holder puede transferir la pulsera (US-5).\r\n    update chain set current_position = v_link.position + 1 where id = v_chain.id;\r\n    insert into audit_log (actor_type, brand_id, campaign_id, action, entity_type, entity_id, after_value)\r\n    values ('SYSTEM', v_chain.brand_id, v_chain.campaign_id, 'CHAIN_ADVANCED', 'CHAIN', v_chain.id,\r\n            jsonb_build_object('current_position', v_link.position + 1));\r\n  end if;\r\n\r\n  return null;\r\nend;\r\n$function$\n"
  },
  {
    "esquema": "kynda",
    "nombre": "execution_status_guard",
    "definicion": "CREATE OR REPLACE FUNCTION kynda.execution_status_guard()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$\r\ndeclare ok boolean := false;\r\nbegin\r\n  if new.status = old.status then return new; end if;\r\n  ok := case old.status\r\n    when 'STARTED' then new.status in ('EVIDENCE_SUBMITTED','CANCELLED')\r\n    when 'EVIDENCE_SUBMITTED' then new.status in ('IN_VALIDATION','CANCELLED')\r\n    when 'IN_VALIDATION' then new.status in ('CLARIFICATION_REQUESTED','APPROVED','REJECTED','FRAUD_BLOCKED')\r\n    when 'CLARIFICATION_REQUESTED' then new.status in ('EVIDENCE_SUBMITTED','CANCELLED','REJECTED')\r\n    when 'REJECTED' then new.status in ('EVIDENCE_SUBMITTED') -- allow resubmission\r\n    when 'APPROVED' then false\r\n    when 'FRAUD_BLOCKED' then false\r\n    when 'CANCELLED' then false\r\n    else false\r\n  end;\r\n  if not ok then\r\n    raise exception 'illegal action_execution.status transition: % -> %', old.status, new.status;\r\n  end if;\r\n  if new.status = 'APPROVED' and new.completed_at is null then\r\n    new.completed_at := now();\r\n  end if;\r\n  return new;\r\nend;\r\n$function$\n"
  },
  {
    "esquema": "kynda",
    "nombre": "generated_content_brand_consistency",
    "definicion": "CREATE OR REPLACE FUNCTION kynda.generated_content_brand_consistency()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$\r\ndeclare ab uuid;\r\nbegin\r\n  select brand_id into ab from action_execution where id = new.action_execution_id;\r\n  if ab is null then raise exception 'generated_content: execution % not found', new.action_execution_id; end if;\r\n  if new.brand_id is null then new.brand_id := ab;\r\n  elsif new.brand_id <> ab then\r\n    raise exception 'generated_content.brand_id mismatch';\r\n  end if;\r\n  return new;\r\nend;\r\n$function$\n"
  },
  {
    "esquema": "kynda",
    "nombre": "guard_campaign_visibility",
    "definicion": "CREATE OR REPLACE FUNCTION kynda.guard_campaign_visibility()\n RETURNS trigger\n LANGUAGE plpgsql\n SECURITY DEFINER\nAS $function$\r\nbegin\r\n  -- Bypass for non-authenticated contexts (migrations as postgres user, service\r\n  -- role with no JWT). auth.uid() returns null in those cases. RLS still gates\r\n  -- who can reach this trigger at all from the client side.\r\n  if auth.uid() is null then\r\n    return new;\r\n  end if;\r\n  if tg_op = 'INSERT' and new.visibility = 'CATALOG' and not kynda.is_staff() then\r\n    raise exception 'only Kynda Ops can publish campaigns to the catalog';\r\n  end if;\r\n  if tg_op = 'UPDATE' and new.visibility is distinct from old.visibility and not kynda.is_staff() then\r\n    raise exception 'only Kynda Ops can change campaign visibility';\r\n  end if;\r\n  return new;\r\nend;\r\n$function$\n"
  },
  {
    "esquema": "kynda",
    "nombre": "handle_new_auth_user",
    "definicion": "CREATE OR REPLACE FUNCTION kynda.handle_new_auth_user()\n RETURNS trigger\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO 'public', 'kynda', 'auth'\nAS $function$\r\ndeclare\r\n  v_invited_brand uuid := nullif(new.raw_user_meta_data->>'invited_brand_id', '')::uuid;\r\n  v_default_brand uuid;\r\n  v_user_role     uuid;\r\n  v_first_name    text;\r\n  v_last_name     text;\r\n  v_admin         record;\r\n  v_admin_brand   uuid;\r\n  v_admin_role    uuid;\r\nbegin\r\n  v_first_name := coalesce(new.raw_user_meta_data->>'first_name', '');\r\n  v_last_name  := coalesce(new.raw_user_meta_data->>'last_name', '');\r\n\r\n  insert into public.app_user (id, email, first_name, last_name)\r\n  values (new.id, new.email, v_first_name, v_last_name)\r\n  on conflict (id) do update\r\n    set email = excluded.email,\r\n        first_name = coalesce(nullif(excluded.first_name, ''), public.app_user.first_name),\r\n        last_name  = coalesce(nullif(excluded.last_name, ''),  public.app_user.last_name);\r\n\r\n  if new.email_confirmed_at is null then\r\n    return new;\r\n  end if;\r\n\r\n  v_default_brand := coalesce(v_invited_brand, kynda.brand_default());\r\n\r\n  for v_admin in\r\n    select s.brand_slug, s.role_code\r\n      from kynda.admin_email_seed s\r\n     where s.email = new.email\r\n  loop\r\n    select id into v_admin_brand from public.brand where slug = v_admin.brand_slug;\r\n    select id into v_admin_role  from public.role  where code = v_admin.role_code;\r\n    if v_admin_brand is not null and v_admin_role is not null then\r\n      insert into public.brand_membership (brand_id, user_id, role_id, status)\r\n      values (v_admin_brand, new.id, v_admin_role, 'ACTIVE')\r\n      on conflict (brand_id, user_id) do update\r\n        set role_id = excluded.role_id, status = 'ACTIVE', revoked_at = null;\r\n    end if;\r\n  end loop;\r\n\r\n  if not exists (\r\n    select 1 from public.brand_membership where user_id = new.id and status = 'ACTIVE'\r\n  ) and v_default_brand is not null then\r\n    select id into v_user_role from public.role where code = 'USER';\r\n    if v_user_role is not null then\r\n      insert into public.brand_membership (brand_id, user_id, role_id, status)\r\n      values (v_default_brand, new.id, v_user_role, 'ACTIVE')\r\n      on conflict (brand_id, user_id) do nothing;\r\n    end if;\r\n  end if;\r\n\r\n  update public.invitation\r\n     set accepted_at = now(), accepted_user_id = new.id\r\n   where email = new.email\r\n     and brand_id = v_default_brand\r\n     and accepted_at is null;\r\n\r\n  return new;\r\nend;\r\n$function$\n"
  },
  {
    "esquema": "kynda",
    "nombre": "handle_new_auth_user_backfill",
    "definicion": "CREATE OR REPLACE FUNCTION kynda.handle_new_auth_user_backfill(p_id uuid, p_email text, p_meta jsonb)\n RETURNS void\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO 'public', 'kynda'\nAS $function$\r\ndeclare\r\n  v_default_brand uuid := kynda.brand_default();\r\n  v_user_role     uuid;\r\n  v_first_name    text := coalesce(p_meta->>'first_name', '');\r\n  v_last_name     text := coalesce(p_meta->>'last_name', '');\r\n  v_admin record;\r\n  v_admin_role    uuid;\r\n  v_admin_brand   uuid;\r\nbegin\r\n  insert into public.app_user (id, email, first_name, last_name)\r\n  values (p_id, p_email, v_first_name, v_last_name)\r\n  on conflict (id) do nothing;\r\n\r\n  for v_admin in select brand_slug, role_code from kynda.admin_email_seed where email = p_email loop\r\n    select id into v_admin_brand from public.brand where slug = v_admin.brand_slug;\r\n    select id into v_admin_role  from public.role  where code = v_admin.role_code;\r\n    if v_admin_brand is not null and v_admin_role is not null then\r\n      insert into public.brand_membership (brand_id, user_id, role_id, status)\r\n      values (v_admin_brand, p_id, v_admin_role, 'ACTIVE')\r\n      on conflict (brand_id, user_id) do update\r\n        set role_id = excluded.role_id, status='ACTIVE', revoked_at=null;\r\n    end if;\r\n  end loop;\r\n\r\n  if not exists (select 1 from public.brand_membership where user_id = p_id and status='ACTIVE')\r\n     and v_default_brand is not null then\r\n    select id into v_user_role from public.role where code='USER';\r\n    if v_user_role is not null then\r\n      insert into public.brand_membership (brand_id, user_id, role_id, status)\r\n      values (v_default_brand, p_id, v_user_role, 'ACTIVE')\r\n      on conflict (brand_id, user_id) do nothing;\r\n    end if;\r\n  end if;\r\nend;\r\n$function$\n"
  },
  {
    "esquema": "kynda",
    "nombre": "handle_user_confirmed",
    "definicion": "CREATE OR REPLACE FUNCTION kynda.handle_user_confirmed()\n RETURNS trigger\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO 'public', 'kynda', 'auth'\nAS $function$\ndeclare\n  v_invited_brand uuid := nullif(new.raw_user_meta_data->>'invited_brand_id', '')::uuid;\n  v_default_brand uuid;\n  v_user_role     uuid;\n  v_admin         record;\n  v_admin_brand   uuid;\n  v_admin_role    uuid;\nbegin\n  -- Only act on the transition to confirmed.\n  if new.email_confirmed_at is null then return new; end if;\n  if old.email_confirmed_at is not null then return new; end if;\n\n  v_default_brand := coalesce(v_invited_brand, kynda.brand_default());\n\n  -- 1. Seeded admin assignment(s) — so admins who confirm later still get their role.\n  for v_admin in\n    select s.brand_slug, s.role_code from kynda.admin_email_seed s where s.email = new.email\n  loop\n    select id into v_admin_brand from public.brand where slug = v_admin.brand_slug;\n    select id into v_admin_role  from public.role  where code = v_admin.role_code;\n    if v_admin_brand is not null and v_admin_role is not null then\n      insert into public.brand_membership (brand_id, user_id, role_id, status)\n      values (v_admin_brand, new.id, v_admin_role, 'ACTIVE')\n      on conflict (brand_id, user_id) do update\n        set role_id = excluded.role_id, status = 'ACTIVE', revoked_at = null;\n    end if;\n  end loop;\n\n  -- 2. USER fallback only if no active membership exists yet.\n  if not exists (\n    select 1 from public.brand_membership where user_id = new.id and status = 'ACTIVE'\n  ) and v_default_brand is not null then\n    select id into v_user_role from public.role where code = 'USER';\n    if v_user_role is not null then\n      insert into public.brand_membership (brand_id, user_id, role_id, status)\n      values (v_default_brand, new.id, v_user_role, 'ACTIVE')\n      on conflict (brand_id, user_id) do nothing;\n    end if;\n  end if;\n\n  -- 3. Accept any matching pending invitation.\n  update public.invitation\n     set accepted_at = now(), accepted_user_id = new.id\n   where email = new.email\n     and brand_id = v_default_brand\n     and accepted_at is null;\n\n  return new;\nend;\n$function$\n"
  },
  {
    "esquema": "kynda",
    "nombre": "is_brand_admin",
    "definicion": "CREATE OR REPLACE FUNCTION kynda.is_brand_admin()\n RETURNS boolean\n LANGUAGE sql\n STABLE\nAS $function$\r\n  select kynda.is_role('BRAND_ADMIN')\r\n$function$\n"
  },
  {
    "esquema": "kynda",
    "nombre": "is_chain_participant",
    "definicion": "CREATE OR REPLACE FUNCTION kynda.is_chain_participant(c uuid)\n RETURNS boolean\n LANGUAGE sql\n STABLE SECURITY DEFINER\n SET search_path TO 'public'\nAS $function$\r\n  select exists (\r\n    select 1 from public.chain_link cl where cl.chain_id = c and cl.user_id = auth.uid()\r\n  )\r\n$function$\n"
  },
  {
    "esquema": "kynda",
    "nombre": "is_member_of",
    "definicion": "CREATE OR REPLACE FUNCTION kynda.is_member_of(b uuid)\n RETURNS boolean\n LANGUAGE sql\n STABLE SECURITY DEFINER\n SET search_path TO 'public'\nAS $function$\r\n  select exists (\r\n    select 1 from public.brand_membership bm\r\n    where bm.brand_id = b and bm.user_id = auth.uid() and bm.status = 'ACTIVE'\r\n  )\r\n$function$\n"
  },
  {
    "esquema": "kynda",
    "nombre": "is_role",
    "definicion": "CREATE OR REPLACE FUNCTION kynda.is_role(role_code text)\n RETURNS boolean\n LANGUAGE sql\n STABLE\nAS $function$\r\n  select role_code = any(kynda.role_codes())\r\n$function$\n"
  },
  {
    "esquema": "kynda",
    "nombre": "is_staff",
    "definicion": "CREATE OR REPLACE FUNCTION kynda.is_staff()\n RETURNS boolean\n LANGUAGE sql\n STABLE\nAS $function$\r\n  select kynda.is_role('OPS') or kynda.is_role('SUPERADMIN')\r\n$function$\n"
  },
  {
    "esquema": "kynda",
    "nombre": "role_codes",
    "definicion": "CREATE OR REPLACE FUNCTION kynda.role_codes()\n RETURNS text[]\n LANGUAGE sql\n STABLE\nAS $function$\r\n  select coalesce(\r\n    array(select jsonb_array_elements_text(auth.jwt() -> 'app_metadata' -> 'role_codes')),\r\n    array[]::text[]\r\n  )\r\n$function$\n"
  },
  {
    "esquema": "kynda",
    "nombre": "scoped_campaign_id",
    "definicion": "CREATE OR REPLACE FUNCTION kynda.scoped_campaign_id()\n RETURNS uuid\n LANGUAGE sql\n STABLE\nAS $function$\r\n  select nullif(\r\n    coalesce(\r\n      auth.jwt() -> 'app_metadata' ->> 'scoped_campaign_id',\r\n      ''\r\n    ),\r\n    ''\r\n  )::uuid\r\n$function$\n"
  },
  {
    "esquema": "kynda",
    "nombre": "validation_brand_consistency",
    "definicion": "CREATE OR REPLACE FUNCTION kynda.validation_brand_consistency()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$\r\ndeclare eb uuid;\r\nbegin\r\n  select brand_id into eb from evidence where id = new.evidence_id;\r\n  if eb is null then raise exception 'validation: evidence % not found', new.evidence_id; end if;\r\n  if new.brand_id is null then new.brand_id := eb;\r\n  elsif new.brand_id <> eb then\r\n    raise exception 'validation.brand_id mismatch';\r\n  end if;\r\n  return new;\r\nend;\r\n$function$\n"
  },
  {
    "esquema": "public",
    "nombre": "activate_chain_token",
    "definicion": "CREATE OR REPLACE FUNCTION public.activate_chain_token(p_campaign_id uuid, p_display_code text, p_user_id uuid)\n RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO 'public', 'kynda'\nAS $function$\r\ndeclare\r\n  v_campaign record;\r\n  v_action record;\r\n  v_token record;\r\n  v_chain record;\r\n  v_link record;\r\n  v_existing record;\r\n  v_position smallint;\r\n  v_chain_id uuid;\r\n  v_link_id uuid;\r\n  v_exec_id uuid;\r\n  v_code text;\r\n  v_tries int := 0;\r\nbegin\r\n  -- 1. Campaña en modo cadena y activa.\r\n  select id, brand_id, status, implementation_mode, chain_length, bracelet_id_required\r\n    into v_campaign from campaign where id = p_campaign_id;\r\n  if not found then\r\n    return jsonb_build_object('result', 'CAMPAIGN_CLOSED');\r\n  end if;\r\n  if v_campaign.implementation_mode <> 'CHAIN' or v_campaign.chain_length is null\r\n     or v_campaign.status <> 'ACTIVE' then\r\n    return jsonb_build_object('result', 'CAMPAIGN_CLOSED');\r\n  end if;\r\n\r\n  -- 2. La acción de la campaña (modelo: todos los eslabones ejecutan la misma acción).\r\n  select id into v_action from action\r\n   where campaign_id = v_campaign.id and status = 'ACTIVE'\r\n   order by sort_order, created_at limit 1;\r\n  if not found then\r\n    return jsonb_build_object('result', 'CAMPAIGN_CLOSED');\r\n  end if;\r\n\r\n  -- 3. Idempotencia (NFR-4) + BR-6 a nivel campaña: si el usuario ya tiene un\r\n  --    eslabón en cualquier cadena de esta campaña, devolvemos su estado.\r\n  select cl.id as link_id, cl.chain_id, cl.position, cl.status as link_status,\r\n         c.status as chain_status, c.current_position, t.display_code\r\n    into v_existing\r\n    from chain_link cl\r\n    join chain c on c.id = cl.chain_id\r\n    join chain_token t on t.id = c.token_id\r\n   where c.campaign_id = v_campaign.id and cl.user_id = p_user_id\r\n   limit 1;\r\n  if found then\r\n    return jsonb_build_object(\r\n      'result', 'ALREADY_PARTICIPANT',\r\n      'chain_id', v_existing.chain_id,\r\n      'link_id', v_existing.link_id,\r\n      'position', v_existing.position,\r\n      'link_status', v_existing.link_status,\r\n      'chain_status', v_existing.chain_status,\r\n      'chain_length', v_campaign.chain_length,\r\n      'action_id', v_action.id,\r\n      'display_code', v_existing.display_code\r\n    );\r\n  end if;\r\n\r\n  -- 4. Resolver el token (pulsera física por id lateral, o digital autogenerado).\r\n  if p_display_code is not null and btrim(p_display_code) <> '' then\r\n    select * into v_token\r\n      from chain_token\r\n     where brand_id = v_campaign.brand_id and display_code = btrim(p_display_code)\r\n       for update;\r\n    if not found then\r\n      return jsonb_build_object('result', 'CODE_NOT_FOUND');\r\n    end if;\r\n    if v_token.campaign_id <> v_campaign.id then\r\n      return jsonb_build_object('result', 'CODE_NOT_FOUND');\r\n    end if;\r\n  elsif v_campaign.bracelet_id_required then\r\n    return jsonb_build_object('result', 'CODE_REQUIRED');\r\n  else\r\n    -- Token digital: cada activación sin código arranca una cadena nueva.\r\n    loop\r\n      v_tries := v_tries + 1;\r\n      if v_tries > 50 then\r\n        raise exception 'could not allocate a unique display_code';\r\n      end if;\r\n      v_code := lpad(floor(random() * 100000000)::bigint::text, 8, '0');\r\n      begin\r\n        insert into chain_token (brand_id, campaign_id, display_code, kind, status)\r\n        values (v_campaign.brand_id, v_campaign.id, v_code, 'DIGITAL', 'UNUSED')\r\n        returning * into v_token;\r\n        exit;\r\n      exception when unique_violation then\r\n        -- collision on (brand_id, display_code) → retry\r\n      end;\r\n    end loop;\r\n  end if;\r\n\r\n  if v_token.status = 'REVOKED' then\r\n    return jsonb_build_object('result', 'TOKEN_REVOKED');\r\n  end if;\r\n  if v_token.status = 'CONSUMED' then\r\n    return jsonb_build_object('result', 'TOKEN_CONSUMED');\r\n  end if;\r\n\r\n  -- 5. Cadena del token (si existe).\r\n  select * into v_chain from chain where token_id = v_token.id for update;\r\n  if found then\r\n    if v_chain.status = 'COMPLETED' then\r\n      return jsonb_build_object('result', 'TOKEN_CONSUMED');\r\n    end if;\r\n    if v_chain.status = 'ABANDONED' then\r\n      return jsonb_build_object('result', 'TOKEN_REVOKED');\r\n    end if;\r\n    select * into v_link from chain_link\r\n     where chain_id = v_chain.id and position = v_chain.current_position;\r\n    if found and v_link.status = 'ACTIVE' then\r\n      -- BR-4: el eslabón actual está en manos de otra persona.\r\n      return jsonb_build_object('result', 'LINK_OCCUPIED', 'position', v_chain.current_position,\r\n                                'chain_length', v_campaign.chain_length);\r\n    end if;\r\n    v_chain_id := v_chain.id;\r\n    v_position := v_chain.current_position;\r\n  else\r\n    -- Primera activación del token → nueva cadena, eslabón 1.\r\n    insert into chain (brand_id, campaign_id, token_id)\r\n    values (v_campaign.brand_id, v_campaign.id, v_token.id)\r\n    returning id into v_chain_id;\r\n    v_position := 1;\r\n    update chain_token set status = 'ACTIVE' where id = v_token.id and status = 'UNUSED';\r\n    insert into audit_log (actor_type, actor_user_id, brand_id, campaign_id, action, entity_type, entity_id, after_value)\r\n    values ('USER', p_user_id, v_campaign.brand_id, v_campaign.id, 'CHAIN_TOKEN_ACTIVATED', 'CHAIN_TOKEN', v_token.id,\r\n            jsonb_build_object('display_code', v_token.display_code, 'kind', v_token.kind)),\r\n           ('USER', p_user_id, v_campaign.brand_id, v_campaign.id, 'CHAIN_CREATED', 'CHAIN', v_chain_id,\r\n            jsonb_build_object('token_id', v_token.id));\r\n  end if;\r\n\r\n  -- 6. Crear eslabón + ejecución + inscripción.\r\n  begin\r\n    insert into chain_link (brand_id, chain_id, position, user_id)\r\n    values (v_campaign.brand_id, v_chain_id, v_position, p_user_id)\r\n    returning id into v_link_id;\r\n  exception when unique_violation then\r\n    -- Carrera perdida: otro usuario acaba de activar este eslabón (BR-4 backstop).\r\n    return jsonb_build_object('result', 'LINK_OCCUPIED', 'position', v_position,\r\n                              'chain_length', v_campaign.chain_length);\r\n  end;\r\n\r\n  begin\r\n    insert into action_execution (brand_id, action_id, user_id, status, chain_link_id)\r\n    values (v_campaign.brand_id, v_action.id, p_user_id, 'STARTED', v_link_id)\r\n    returning id into v_exec_id;\r\n  exception when unique_violation then\r\n    -- El usuario ya ejecutó esta acción fuera de una cadena → no puede ocupar eslabón.\r\n    raise exception 'ALREADY_PARTICIPATED' using errcode = 'P0001';\r\n  end;\r\n\r\n  insert into action_enrollment (brand_id, action_id, user_id, status)\r\n  values (v_campaign.brand_id, v_action.id, p_user_id, 'ENROLLED')\r\n  on conflict (action_id, user_id) do update set status = 'ENROLLED', cancelled_at = null;\r\n\r\n  -- 7. Transferencia consumada: el eslabón anterior pasó la pulsera (US-5/CA-5.5).\r\n  if v_position > 1 then\r\n    update chain_link set transferred_at = now()\r\n     where chain_id = v_chain_id and position = v_position - 1 and transferred_at is null;\r\n    insert into audit_log (actor_type, actor_user_id, brand_id, campaign_id, action, entity_type, entity_id, after_value)\r\n    select 'USER', p_user_id, v_campaign.brand_id, v_campaign.id, 'CHAIN_LINK_TRANSFERRED', 'CHAIN_LINK', cl.id,\r\n           jsonb_build_object('from_user_id', cl.user_id, 'to_user_id', p_user_id, 'position', cl.position)\r\n      from chain_link cl where cl.chain_id = v_chain_id and cl.position = v_position - 1;\r\n  end if;\r\n\r\n  insert into audit_log (actor_type, actor_user_id, brand_id, campaign_id, action, entity_type, entity_id, after_value)\r\n  values ('USER', p_user_id, v_campaign.brand_id, v_campaign.id, 'CHAIN_LINK_ACTIVATED', 'CHAIN_LINK', v_link_id,\r\n          jsonb_build_object('position', v_position, 'chain_id', v_chain_id, 'token_id', v_token.id));\r\n\r\n  return jsonb_build_object(\r\n    'result', 'ACTIVATED',\r\n    'chain_id', v_chain_id,\r\n    'link_id', v_link_id,\r\n    'position', v_position,\r\n    'chain_length', v_campaign.chain_length,\r\n    'action_id', v_action.id,\r\n    'execution_id', v_exec_id,\r\n    'display_code', v_token.display_code\r\n  );\r\nexception\r\n  when others then\r\n    if sqlerrm = 'ALREADY_PARTICIPATED' then\r\n      return jsonb_build_object('result', 'ALREADY_PARTICIPATED');\r\n    end if;\r\n    raise;\r\nend;\r\n$function$\n"
  },
  {
    "esquema": "public",
    "nombre": "expired_evidence_media",
    "definicion": "CREATE OR REPLACE FUNCTION public.expired_evidence_media(p_now timestamp with time zone DEFAULT now())\n RETURNS TABLE(media_id uuid, evidence_id uuid, storage_path text, brand_id uuid, campaign_id uuid)\n LANGUAGE sql\n STABLE SECURITY DEFINER\n SET search_path TO 'public', 'kynda'\nAS $function$\r\n  select em.id, em.evidence_id, em.storage_path, em.brand_id, c.id\r\n  from evidence_media em\r\n  join evidence e          on e.id  = em.evidence_id\r\n  join action_execution ax on ax.id = e.action_execution_id\r\n  join action a            on a.id  = ax.action_id\r\n  join campaign c          on c.id  = a.campaign_id\r\n  where c.retention_days is not null\r\n    and em.tombstoned_at is null\r\n    and e.submitted_at + make_interval(days => c.retention_days) < p_now\r\n$function$\n"
  },
  {
    "esquema": "public",
    "nombre": "set_updated_at",
    "definicion": "CREATE OR REPLACE FUNCTION public.set_updated_at()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$\r\nbegin\r\n  new.updated_at = now();\r\n  return new;\r\nend;\r\n$function$\n"
  }
]