532 lines
17 KiB
PL/PgSQL
532 lines
17 KiB
PL/PgSQL
-- PolyWeather minimal commerce/auth schema (P0)
|
|
-- Run in Supabase SQL editor.
|
|
|
|
create extension if not exists pgcrypto;
|
|
|
|
create table if not exists public.profiles (
|
|
id uuid primary key references auth.users(id) on delete cascade,
|
|
email text not null default '',
|
|
telegram_user_id bigint,
|
|
telegram_username text,
|
|
created_at timestamptz not null default now(),
|
|
updated_at timestamptz not null default now()
|
|
);
|
|
|
|
create index if not exists idx_profiles_email
|
|
on public.profiles(email)
|
|
include (id);
|
|
|
|
create index if not exists idx_profiles_id_lookup
|
|
on public.profiles(id)
|
|
include (email, created_at);
|
|
|
|
create table if not exists public.subscriptions (
|
|
id bigserial primary key,
|
|
user_id uuid not null references auth.users(id) on delete cascade,
|
|
plan_code text not null,
|
|
status text not null check (status in ('active', 'paused', 'expired', 'cancelled')),
|
|
starts_at timestamptz not null default now(),
|
|
expires_at timestamptz not null,
|
|
source text not null default 'manual',
|
|
created_at timestamptz not null default now(),
|
|
updated_at timestamptz not null default now()
|
|
);
|
|
|
|
create index if not exists idx_subscriptions_user_status_expiry
|
|
on public.subscriptions(user_id, expires_at desc)
|
|
include (id, starts_at, plan_code, source)
|
|
where status = 'active';
|
|
|
|
create index if not exists idx_subscriptions_status_expiry
|
|
on public.subscriptions(expires_at asc)
|
|
include (user_id, starts_at, plan_code)
|
|
where status = 'active';
|
|
|
|
create index if not exists idx_subscriptions_user_created
|
|
on public.subscriptions(user_id, created_at desc)
|
|
include (id, status, plan_code, source, starts_at, expires_at, updated_at);
|
|
|
|
create unique index if not exists uq_subscriptions_signup_trial_user
|
|
on public.subscriptions(user_id)
|
|
where plan_code = 'signup_trial_3d'
|
|
and source = 'signup_trial';
|
|
|
|
create table if not exists public.payments (
|
|
id bigserial primary key,
|
|
user_id uuid not null references auth.users(id) on delete cascade,
|
|
amount numeric(18, 6) not null,
|
|
currency text not null default 'USDC',
|
|
chain text not null default 'polygon',
|
|
tx_hash text unique,
|
|
status text not null check (status in ('pending', 'confirmed', 'failed', 'refunded')),
|
|
raw_payload jsonb not null default '{}'::jsonb,
|
|
created_at timestamptz not null default now(),
|
|
updated_at timestamptz not null default now()
|
|
);
|
|
|
|
create index if not exists idx_payments_created_at
|
|
on public.payments(created_at desc)
|
|
include (id, user_id, amount, currency, chain, tx_hash, status);
|
|
|
|
create table if not exists public.entitlement_events (
|
|
id bigserial primary key,
|
|
user_id uuid not null references auth.users(id) on delete cascade,
|
|
action text not null,
|
|
reason text not null default '',
|
|
actor text not null default 'system',
|
|
payload jsonb not null default '{}'::jsonb,
|
|
created_at timestamptz not null default now()
|
|
);
|
|
|
|
create table if not exists public.user_wallets (
|
|
id bigserial primary key,
|
|
user_id uuid not null references auth.users(id) on delete cascade,
|
|
chain_id integer not null default 137,
|
|
address text not null,
|
|
status text not null default 'active' check (status in ('active', 'revoked')),
|
|
is_primary boolean not null default false,
|
|
verified_at timestamptz,
|
|
last_used_at timestamptz,
|
|
created_at timestamptz not null default now(),
|
|
updated_at timestamptz not null default now(),
|
|
unique(chain_id, address)
|
|
);
|
|
|
|
create index if not exists idx_user_wallets_user_chain
|
|
on public.user_wallets(user_id, chain_id, is_primary desc, verified_at desc)
|
|
include (id, address)
|
|
where status = 'active';
|
|
|
|
create index if not exists idx_user_wallets_chain_address_owner
|
|
on public.user_wallets(chain_id, address)
|
|
include (user_id, status);
|
|
|
|
create table if not exists public.trial_claims (
|
|
id bigserial primary key,
|
|
user_id uuid not null references auth.users(id) on delete cascade,
|
|
email text not null default '',
|
|
telegram_user_id bigint,
|
|
primary_wallet_address text,
|
|
metadata jsonb not null default '{}'::jsonb,
|
|
claimed_at timestamptz not null default now(),
|
|
created_at timestamptz not null default now()
|
|
);
|
|
|
|
create unique index if not exists uq_trial_claims_user
|
|
on public.trial_claims(user_id);
|
|
|
|
create unique index if not exists uq_trial_claims_email
|
|
on public.trial_claims(lower(email))
|
|
where email <> '';
|
|
|
|
create unique index if not exists uq_trial_claims_telegram
|
|
on public.trial_claims(telegram_user_id)
|
|
where telegram_user_id is not null;
|
|
|
|
create table if not exists public.trial_claim_wallets (
|
|
id bigserial primary key,
|
|
trial_claim_id bigint not null references public.trial_claims(id) on delete cascade,
|
|
wallet_address text not null,
|
|
created_at timestamptz not null default now()
|
|
);
|
|
|
|
create unique index if not exists uq_trial_claim_wallets_address
|
|
on public.trial_claim_wallets(lower(wallet_address));
|
|
|
|
create or replace function public.claim_signup_trial(
|
|
p_user_id uuid,
|
|
p_email text default '',
|
|
p_telegram_user_id bigint default null,
|
|
p_wallet_addresses text[] default array[]::text[]
|
|
)
|
|
returns jsonb
|
|
language plpgsql
|
|
security definer
|
|
set search_path = public, auth
|
|
as $$
|
|
declare
|
|
v_email text := lower(trim(coalesce(p_email, '')));
|
|
v_wallets text[] := (
|
|
select coalesce(array_agg(distinct lower(trim(input_wallet.value))), array[]::text[])
|
|
from unnest(coalesce(p_wallet_addresses, array[]::text[])) as input_wallet(value)
|
|
where trim(input_wallet.value) <> ''
|
|
);
|
|
v_now timestamptz := now();
|
|
v_expires timestamptz := v_now + interval '3 days';
|
|
v_claim public.trial_claims%rowtype;
|
|
v_claim_id bigint;
|
|
v_rows integer := 0;
|
|
begin
|
|
if p_user_id is null then
|
|
return jsonb_build_object('created', false, 'reason', 'missing_user_id');
|
|
end if;
|
|
|
|
select tc.*
|
|
into v_claim
|
|
from public.trial_claims tc
|
|
where tc.user_id = p_user_id
|
|
or (v_email <> '' and lower(tc.email) = v_email)
|
|
or (p_telegram_user_id is not null and tc.telegram_user_id = p_telegram_user_id)
|
|
or exists (
|
|
select 1
|
|
from public.trial_claim_wallets tcw
|
|
where tcw.trial_claim_id = tc.id
|
|
and lower(tcw.wallet_address) = any(v_wallets)
|
|
)
|
|
order by
|
|
case when tc.user_id = p_user_id then 0 else 1 end,
|
|
tc.created_at asc,
|
|
tc.id asc
|
|
limit 1;
|
|
|
|
if found then
|
|
if v_claim.user_id <> p_user_id then
|
|
return jsonb_build_object('created', false, 'reason', 'already_claimed');
|
|
end if;
|
|
|
|
insert into public.subscriptions (
|
|
user_id,
|
|
plan_code,
|
|
status,
|
|
starts_at,
|
|
expires_at,
|
|
source,
|
|
created_at,
|
|
updated_at
|
|
)
|
|
values (
|
|
p_user_id,
|
|
'signup_trial_3d',
|
|
'active',
|
|
coalesce(v_claim.claimed_at, v_now),
|
|
coalesce(v_claim.claimed_at, v_now) + interval '3 days',
|
|
'signup_trial',
|
|
v_now,
|
|
v_now
|
|
)
|
|
on conflict (user_id)
|
|
where plan_code = 'signup_trial_3d'
|
|
and source = 'signup_trial'
|
|
do nothing;
|
|
get diagnostics v_rows = row_count;
|
|
|
|
if v_rows > 0 then
|
|
insert into public.entitlement_events (
|
|
user_id,
|
|
action,
|
|
reason,
|
|
actor,
|
|
payload,
|
|
created_at
|
|
)
|
|
values (
|
|
p_user_id,
|
|
'signup_trial_granted',
|
|
'claim_repaired',
|
|
'supabase_auth',
|
|
jsonb_build_object(
|
|
'plan_code', 'signup_trial_3d',
|
|
'expires_at', coalesce(v_claim.claimed_at, v_now) + interval '3 days'
|
|
),
|
|
v_now
|
|
);
|
|
return jsonb_build_object(
|
|
'created', true,
|
|
'repaired', true,
|
|
'plan_code', 'signup_trial_3d',
|
|
'expires_at', coalesce(v_claim.claimed_at, v_now) + interval '3 days'
|
|
);
|
|
end if;
|
|
|
|
return jsonb_build_object('created', false, 'reason', 'already_claimed');
|
|
end if;
|
|
|
|
insert into public.trial_claims (
|
|
user_id,
|
|
email,
|
|
telegram_user_id,
|
|
primary_wallet_address,
|
|
metadata,
|
|
claimed_at,
|
|
created_at
|
|
)
|
|
values (
|
|
p_user_id,
|
|
v_email,
|
|
p_telegram_user_id,
|
|
nullif(v_wallets[1], ''),
|
|
jsonb_build_object('wallet_addresses', v_wallets),
|
|
v_now,
|
|
v_now
|
|
)
|
|
returning id into v_claim_id;
|
|
|
|
insert into public.trial_claim_wallets (trial_claim_id, wallet_address, created_at)
|
|
select v_claim_id, wallet.value, v_now
|
|
from unnest(v_wallets) as wallet(value)
|
|
on conflict do nothing;
|
|
|
|
insert into public.subscriptions (
|
|
user_id,
|
|
plan_code,
|
|
status,
|
|
starts_at,
|
|
expires_at,
|
|
source,
|
|
created_at,
|
|
updated_at
|
|
)
|
|
values (
|
|
p_user_id,
|
|
'signup_trial_3d',
|
|
'active',
|
|
v_now,
|
|
v_expires,
|
|
'signup_trial',
|
|
v_now,
|
|
v_now
|
|
)
|
|
on conflict (user_id)
|
|
where plan_code = 'signup_trial_3d'
|
|
and source = 'signup_trial'
|
|
do nothing;
|
|
get diagnostics v_rows = row_count;
|
|
|
|
insert into public.entitlement_events (
|
|
user_id,
|
|
action,
|
|
reason,
|
|
actor,
|
|
payload,
|
|
created_at
|
|
)
|
|
values
|
|
(
|
|
p_user_id,
|
|
'signup_trial_claimed',
|
|
'trial_dedupe',
|
|
'supabase_auth',
|
|
jsonb_build_object(
|
|
'user_id', p_user_id,
|
|
'email', v_email,
|
|
'telegram_user_id', p_telegram_user_id,
|
|
'wallet_addresses', v_wallets,
|
|
'claimed_at', v_now,
|
|
'storage', 'trial_claims'
|
|
),
|
|
v_now
|
|
),
|
|
(
|
|
p_user_id,
|
|
'signup_trial_granted',
|
|
'first_auth',
|
|
'supabase_auth',
|
|
jsonb_build_object(
|
|
'plan_code', 'signup_trial_3d',
|
|
'expires_at', v_expires
|
|
),
|
|
v_now
|
|
);
|
|
|
|
if v_rows = 0 then
|
|
return jsonb_build_object('created', false, 'reason', 'already_claimed');
|
|
end if;
|
|
|
|
return jsonb_build_object(
|
|
'created', true,
|
|
'plan_code', 'signup_trial_3d',
|
|
'expires_at', v_expires
|
|
);
|
|
exception
|
|
when unique_violation then
|
|
return jsonb_build_object('created', false, 'reason', 'already_claimed');
|
|
end;
|
|
$$;
|
|
|
|
revoke all on function public.claim_signup_trial(uuid, text, bigint, text[]) from public;
|
|
revoke all on function public.claim_signup_trial(uuid, text, bigint, text[]) from anon;
|
|
revoke all on function public.claim_signup_trial(uuid, text, bigint, text[]) from authenticated;
|
|
grant execute on function public.claim_signup_trial(uuid, text, bigint, text[]) to service_role;
|
|
|
|
create table if not exists public.referral_codes (
|
|
id bigserial primary key,
|
|
user_id uuid not null references auth.users(id) on delete cascade,
|
|
code text not null,
|
|
status text not null default 'active' check (status in ('active', 'revoked')),
|
|
created_at timestamptz not null default now(),
|
|
updated_at timestamptz not null default now()
|
|
);
|
|
|
|
create unique index if not exists uq_referral_codes_user
|
|
on public.referral_codes(user_id);
|
|
|
|
create unique index if not exists uq_referral_codes_code
|
|
on public.referral_codes(upper(code));
|
|
|
|
create table if not exists public.referral_attributions (
|
|
id bigserial primary key,
|
|
referrer_user_id uuid not null references auth.users(id) on delete cascade,
|
|
referred_user_id uuid not null references auth.users(id) on delete cascade,
|
|
code text not null,
|
|
status text not null default 'pending' check (status in ('pending', 'converted', 'capped', 'cancelled')),
|
|
converted_payment_intent_id text,
|
|
converted_tx_hash text,
|
|
converted_at timestamptz,
|
|
created_at timestamptz not null default now(),
|
|
updated_at timestamptz not null default now(),
|
|
check (referrer_user_id <> referred_user_id)
|
|
);
|
|
|
|
create unique index if not exists uq_referral_attributions_referred
|
|
on public.referral_attributions(referred_user_id);
|
|
|
|
create index if not exists idx_referral_attributions_pending
|
|
on public.referral_attributions(referred_user_id, created_at desc)
|
|
include (id, code, referrer_user_id)
|
|
where status = 'pending';
|
|
|
|
create table if not exists public.referral_rewards (
|
|
id bigserial primary key,
|
|
referral_attribution_id bigint not null references public.referral_attributions(id) on delete cascade,
|
|
referrer_user_id uuid not null references auth.users(id) on delete cascade,
|
|
referred_user_id uuid not null references auth.users(id) on delete cascade,
|
|
payment_intent_id text not null,
|
|
tx_hash text,
|
|
reward_days integer not null default 0 check (reward_days >= 0 and reward_days <= 30),
|
|
reward_points integer not null default 0 check (reward_points >= 0 and reward_points <= 100000),
|
|
created_at timestamptz not null default now()
|
|
);
|
|
|
|
create unique index if not exists uq_referral_rewards_attribution
|
|
on public.referral_rewards(referral_attribution_id);
|
|
|
|
create index if not exists idx_referral_rewards_referrer_month
|
|
on public.referral_rewards(referrer_user_id, created_at desc)
|
|
include (id, reward_days, reward_points);
|
|
|
|
create table if not exists public.points_ledger (
|
|
id bigserial primary key,
|
|
user_id uuid not null references auth.users(id) on delete cascade,
|
|
delta integer not null check (delta <> 0),
|
|
source text not null,
|
|
reason text not null,
|
|
payment_intent_id text,
|
|
referral_attribution_id bigint references public.referral_attributions(id) on delete set null,
|
|
metadata jsonb not null default '{}'::jsonb,
|
|
created_at timestamptz not null default now()
|
|
);
|
|
|
|
create index if not exists idx_points_ledger_user_created
|
|
on public.points_ledger(user_id, created_at desc);
|
|
|
|
create index if not exists idx_points_ledger_referral
|
|
on public.points_ledger(referral_attribution_id)
|
|
where referral_attribution_id is not null;
|
|
|
|
alter table public.points_ledger enable row level security;
|
|
|
|
drop policy if exists points_ledger_select_own on public.points_ledger;
|
|
create policy points_ledger_select_own
|
|
on public.points_ledger
|
|
for select
|
|
to authenticated
|
|
using (auth.uid() = user_id);
|
|
|
|
create table if not exists public.wallet_link_challenges (
|
|
id bigserial primary key,
|
|
user_id uuid not null references auth.users(id) on delete cascade,
|
|
chain_id integer not null default 137,
|
|
address text not null,
|
|
nonce text not null unique,
|
|
message text not null,
|
|
expires_at timestamptz not null,
|
|
consumed_at timestamptz,
|
|
created_at timestamptz not null default now()
|
|
);
|
|
|
|
create table if not exists public.payment_intents (
|
|
id uuid primary key default gen_random_uuid(),
|
|
user_id uuid not null references auth.users(id) on delete cascade,
|
|
plan_code text not null,
|
|
plan_id bigint not null,
|
|
chain_id integer not null default 137,
|
|
token_address text not null,
|
|
receiver_address text not null,
|
|
amount_units numeric(78,0) not null,
|
|
payment_mode text not null default 'strict' check (payment_mode in ('strict', 'flex', 'direct')),
|
|
allowed_wallet text,
|
|
order_id_hex text not null unique,
|
|
status text not null default 'created' check (status in ('created', 'submitted', 'confirmed', 'expired', 'failed', 'cancelled')),
|
|
tx_hash text,
|
|
expires_at timestamptz not null,
|
|
confirmed_at timestamptz,
|
|
metadata jsonb not null default '{}'::jsonb,
|
|
created_at timestamptz not null default now(),
|
|
updated_at timestamptz not null default now()
|
|
);
|
|
|
|
create index if not exists idx_payment_intents_status_updated
|
|
on public.payment_intents(status, updated_at desc)
|
|
include (user_id)
|
|
where status in ('submitted', 'confirmed');
|
|
|
|
create index if not exists idx_payment_intents_user_status_updated
|
|
on public.payment_intents(user_id, status, updated_at desc);
|
|
|
|
create index if not exists idx_payment_intents_submitted_tx_updated
|
|
on public.payment_intents(updated_at asc)
|
|
include (id, user_id, tx_hash, chain_id)
|
|
where status = 'submitted' and tx_hash is not null;
|
|
|
|
create index if not exists idx_payment_intents_user_created
|
|
on public.payment_intents(user_id, created_at desc);
|
|
|
|
create index if not exists idx_payment_intents_tx_hash
|
|
on public.payment_intents(tx_hash)
|
|
include (id, user_id)
|
|
where tx_hash is not null;
|
|
|
|
create table if not exists public.payment_transactions (
|
|
id bigserial primary key,
|
|
intent_id uuid not null references public.payment_intents(id) on delete cascade,
|
|
tx_hash text not null unique,
|
|
chain_id integer not null default 137,
|
|
from_address text,
|
|
to_address text not null,
|
|
block_number bigint,
|
|
payment_method text not null default 'wallet' check (payment_method in ('wallet', 'manual', 'direct')),
|
|
status text not null default 'submitted' check (status in ('submitted', 'confirmed', 'failed', 'duplicate', 'refund_required')),
|
|
raw_receipt jsonb not null default '{}'::jsonb,
|
|
raw_tx jsonb not null default '{}'::jsonb,
|
|
created_at timestamptz not null default now(),
|
|
updated_at timestamptz not null default now()
|
|
);
|
|
|
|
create index if not exists idx_payment_transactions_intent
|
|
on public.payment_transactions(intent_id, created_at desc);
|
|
|
|
create index if not exists idx_payment_transactions_tx_hash_intent
|
|
on public.payment_transactions(tx_hash)
|
|
include (intent_id);
|
|
|
|
create or replace function public.sync_profile_from_auth()
|
|
returns trigger
|
|
language plpgsql
|
|
security definer
|
|
set search_path = public
|
|
as $$
|
|
begin
|
|
insert into public.profiles (id, email)
|
|
values (new.id, coalesce(new.email, ''))
|
|
on conflict (id) do update
|
|
set email = excluded.email,
|
|
updated_at = now();
|
|
return new;
|
|
end;
|
|
$$;
|
|
|
|
drop trigger if exists on_auth_user_created_polyweather on auth.users;
|
|
create trigger on_auth_user_created_polyweather
|
|
after insert on auth.users
|
|
for each row execute function public.sync_profile_from_auth();
|