feat: implement atomic signup trial claims and referral discount logic with Supabase database schema updates
This commit is contained in:
@@ -1,7 +1,10 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { BACKEND_ENTITLEMENT_HEADER } from "@/lib/backend-auth";
|
||||||
import { createSupabaseRouteClient, hasSupabaseServerEnv } from "@/lib/supabase/server";
|
import { createSupabaseRouteClient, hasSupabaseServerEnv } from "@/lib/supabase/server";
|
||||||
import { getConfiguredSiteUrl } from "@/lib/site-url";
|
import { getConfiguredSiteUrl } from "@/lib/site-url";
|
||||||
|
|
||||||
|
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||||
|
|
||||||
function normalizeNextPath(input: string | null) {
|
function normalizeNextPath(input: string | null) {
|
||||||
const fallback = "/";
|
const fallback = "/";
|
||||||
const raw = String(input || "").trim();
|
const raw = String(input || "").trim();
|
||||||
@@ -11,6 +14,34 @@ function normalizeNextPath(input: string | null) {
|
|||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function warmSignupTrial(accessToken: string) {
|
||||||
|
const token = String(accessToken || "").trim();
|
||||||
|
if (!API_BASE || !token) return;
|
||||||
|
|
||||||
|
const headers = new Headers({
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
});
|
||||||
|
const backendToken = process.env.POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN?.trim();
|
||||||
|
if (backendToken) {
|
||||||
|
headers.set(BACKEND_ENTITLEMENT_HEADER, backendToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 2500);
|
||||||
|
try {
|
||||||
|
await fetch(`${API_BASE}/api/auth/me`, {
|
||||||
|
cache: "no-store",
|
||||||
|
headers,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// The account/terminal bootstrap will retry. Callback must not strand login.
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const siteUrl = getConfiguredSiteUrl();
|
const siteUrl = getConfiguredSiteUrl();
|
||||||
if (siteUrl) {
|
if (siteUrl) {
|
||||||
@@ -38,7 +69,10 @@ export async function GET(request: NextRequest) {
|
|||||||
const supabase = createSupabaseRouteClient(request, response);
|
const supabase = createSupabaseRouteClient(request, response);
|
||||||
const code = request.nextUrl.searchParams.get("code");
|
const code = request.nextUrl.searchParams.get("code");
|
||||||
if (code) {
|
if (code) {
|
||||||
await supabase.auth.exchangeCodeForSession(code);
|
const {
|
||||||
|
data: { session },
|
||||||
|
} = await supabase.auth.exchangeCodeForSession(code);
|
||||||
|
await warmSignupTrial(session?.access_token || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ export function runTests() {
|
|||||||
"auth",
|
"auth",
|
||||||
"ResetPasswordClient.tsx",
|
"ResetPasswordClient.tsx",
|
||||||
);
|
);
|
||||||
|
const authCallbackPath = path.join(
|
||||||
|
projectRoot,
|
||||||
|
"app",
|
||||||
|
"auth",
|
||||||
|
"callback",
|
||||||
|
"route.ts",
|
||||||
|
);
|
||||||
|
|
||||||
const loginClientSource = fs.readFileSync(loginClientPath, "utf8");
|
const loginClientSource = fs.readFileSync(loginClientPath, "utf8");
|
||||||
|
|
||||||
@@ -55,4 +62,14 @@ export function runTests() {
|
|||||||
resetClientSource.includes("expired"),
|
resetClientSource.includes("expired"),
|
||||||
"password reset client must detect an expired or invalid recovery session",
|
"password reset client must detect an expired or invalid recovery session",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const authCallbackSource = fs.readFileSync(authCallbackPath, "utf8");
|
||||||
|
assert(
|
||||||
|
authCallbackSource.includes("warmSignupTrial") &&
|
||||||
|
authCallbackSource.includes("exchangeCodeForSession") &&
|
||||||
|
authCallbackSource.includes("/api/auth/me") &&
|
||||||
|
authCallbackSource.includes("Authorization") &&
|
||||||
|
authCallbackSource.includes("Bearer"),
|
||||||
|
"auth callback must warm /api/auth/me with the exchanged Supabase session so new users receive the signup trial immediately",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ create index if not exists idx_subscriptions_user_created
|
|||||||
on public.subscriptions(user_id, created_at desc)
|
on public.subscriptions(user_id, created_at desc)
|
||||||
include (id, status, plan_code, source, starts_at, expires_at, updated_at);
|
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 (
|
create table if not exists public.payments (
|
||||||
id bigserial primary key,
|
id bigserial primary key,
|
||||||
user_id uuid not null references auth.users(id) on delete cascade,
|
user_id uuid not null references auth.users(id) on delete cascade,
|
||||||
@@ -128,6 +133,221 @@ create table if not exists public.trial_claim_wallets (
|
|||||||
create unique index if not exists uq_trial_claim_wallets_address
|
create unique index if not exists uq_trial_claim_wallets_address
|
||||||
on public.trial_claim_wallets(lower(wallet_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 (
|
create table if not exists public.referral_codes (
|
||||||
id bigserial primary key,
|
id bigserial primary key,
|
||||||
user_id uuid not null references auth.users(id) on delete cascade,
|
user_id uuid not null references auth.users(id) on delete cascade,
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
-- Atomic signup-trial claim and subscription grant.
|
||||||
|
-- Run after subscription_referral_20260529.sql.
|
||||||
|
|
||||||
|
-- Keep the first real signup trial row per user and neutralize duplicate rows
|
||||||
|
-- before adding the one-trial-per-user index.
|
||||||
|
with ranked_signup_trials as (
|
||||||
|
select
|
||||||
|
id,
|
||||||
|
row_number() over (
|
||||||
|
partition by user_id
|
||||||
|
order by created_at asc, id asc
|
||||||
|
) as rn
|
||||||
|
from public.subscriptions
|
||||||
|
where plan_code = 'signup_trial_3d'
|
||||||
|
and source = 'signup_trial'
|
||||||
|
)
|
||||||
|
update public.subscriptions s
|
||||||
|
set
|
||||||
|
status = case when s.status = 'active' then 'expired' else s.status end,
|
||||||
|
source = 'signup_trial_duplicate',
|
||||||
|
updated_at = now()
|
||||||
|
from ranked_signup_trials r
|
||||||
|
where s.id = r.id
|
||||||
|
and r.rn > 1;
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
|
||||||
|
analyze public.trial_claims;
|
||||||
|
analyze public.trial_claim_wallets;
|
||||||
|
analyze public.subscriptions;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Restrict atomic signup-trial RPC to backend service-role calls only.
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
select
|
||||||
|
has_function_privilege('anon', 'public.claim_signup_trial(uuid,text,bigint,text[])', 'execute') as anon_can_execute,
|
||||||
|
has_function_privilege('authenticated', 'public.claim_signup_trial(uuid,text,bigint,text[])', 'execute') as authenticated_can_execute,
|
||||||
|
has_function_privilege('service_role', 'public.claim_signup_trial(uuid,text,bigint,text[])', 'execute') as service_role_can_execute;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
-- Read-only verification for signup_trial_atomic_20260530.sql.
|
||||||
|
|
||||||
|
select
|
||||||
|
exists (
|
||||||
|
select 1
|
||||||
|
from pg_indexes
|
||||||
|
where schemaname = 'public'
|
||||||
|
and tablename = 'subscriptions'
|
||||||
|
and indexname = 'uq_subscriptions_signup_trial_user'
|
||||||
|
) as has_trial_unique_index,
|
||||||
|
exists (
|
||||||
|
select 1
|
||||||
|
from pg_proc p
|
||||||
|
join pg_namespace n on n.oid = p.pronamespace
|
||||||
|
where n.nspname = 'public'
|
||||||
|
and p.proname = 'claim_signup_trial'
|
||||||
|
) as has_claim_signup_trial_rpc;
|
||||||
|
|
||||||
|
select
|
||||||
|
count(*) as users_with_duplicate_signup_trials,
|
||||||
|
coalesce(sum(trial_count), 0) as total_trial_rows_in_duplicate_users
|
||||||
|
from (
|
||||||
|
select user_id, count(*) as trial_count
|
||||||
|
from public.subscriptions
|
||||||
|
where plan_code = 'signup_trial_3d'
|
||||||
|
and source = 'signup_trial'
|
||||||
|
group by user_id
|
||||||
|
having count(*) > 1
|
||||||
|
) d;
|
||||||
|
|
||||||
|
select
|
||||||
|
count(*) filter (where s.id is null) as trial_claims_without_trial_subscription,
|
||||||
|
count(*) as total_trial_claims
|
||||||
|
from public.trial_claims tc
|
||||||
|
left join public.subscriptions s
|
||||||
|
on s.user_id = tc.user_id
|
||||||
|
and s.plan_code = 'signup_trial_3d'
|
||||||
|
and s.source = 'signup_trial';
|
||||||
@@ -176,6 +176,31 @@ class SupabaseEntitlementService:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _rpc(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
payload: Optional[Any] = None,
|
||||||
|
*,
|
||||||
|
allowed_status: Optional[List[int]] = None,
|
||||||
|
) -> Any:
|
||||||
|
return self._rest(
|
||||||
|
"POST",
|
||||||
|
f"rpc/{name}",
|
||||||
|
payload=payload or {},
|
||||||
|
allowed_status=allowed_status or [200],
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _looks_like_missing_rpc(exc: Exception) -> bool:
|
||||||
|
text = str(exc).lower()
|
||||||
|
return (
|
||||||
|
"pgrst202" in text
|
||||||
|
or "could not find the function" in text
|
||||||
|
or "function public.claim_signup_trial" in text
|
||||||
|
or "schema cache" in text
|
||||||
|
or "404" in text
|
||||||
|
)
|
||||||
|
|
||||||
def _admin_user_endpoint(self, user_id: str) -> str:
|
def _admin_user_endpoint(self, user_id: str) -> str:
|
||||||
return f"{self.supabase_url}/auth/v1/admin/users/{user_id}"
|
return f"{self.supabase_url}/auth/v1/admin/users/{user_id}"
|
||||||
|
|
||||||
@@ -407,6 +432,31 @@ class SupabaseEntitlementService:
|
|||||||
try:
|
try:
|
||||||
telegram_user_id = self._telegram_user_id_for(user_key)
|
telegram_user_id = self._telegram_user_id_for(user_key)
|
||||||
wallet_addresses = self._active_wallet_addresses_for(user_key)
|
wallet_addresses = self._active_wallet_addresses_for(user_key)
|
||||||
|
try:
|
||||||
|
result = self._rpc(
|
||||||
|
"claim_signup_trial",
|
||||||
|
{
|
||||||
|
"p_user_id": user_key,
|
||||||
|
"p_email": normalized_email,
|
||||||
|
"p_telegram_user_id": telegram_user_id,
|
||||||
|
"p_wallet_addresses": wallet_addresses,
|
||||||
|
},
|
||||||
|
allowed_status=[200],
|
||||||
|
)
|
||||||
|
if isinstance(result, dict):
|
||||||
|
self.invalidate_subscription_cache(user_key)
|
||||||
|
return result
|
||||||
|
if isinstance(result, list) and result and isinstance(result[0], dict):
|
||||||
|
self.invalidate_subscription_cache(user_key)
|
||||||
|
return result[0]
|
||||||
|
except Exception as rpc_exc:
|
||||||
|
if not self._looks_like_missing_rpc(rpc_exc):
|
||||||
|
raise
|
||||||
|
logger.warning(
|
||||||
|
"signup trial rpc missing; falling back to legacy grant user_id={}: {}",
|
||||||
|
user_key,
|
||||||
|
rpc_exc,
|
||||||
|
)
|
||||||
if self._trial_claim_exists(
|
if self._trial_claim_exists(
|
||||||
user_id=user_key,
|
user_id=user_key,
|
||||||
email=normalized_email,
|
email=normalized_email,
|
||||||
@@ -437,6 +487,13 @@ class SupabaseEntitlementService:
|
|||||||
if isinstance(claim_rows, list) and claim_rows and isinstance(claim_rows[0], dict):
|
if isinstance(claim_rows, list) and claim_rows and isinstance(claim_rows[0], dict):
|
||||||
claim_id = claim_rows[0].get("id")
|
claim_id = claim_rows[0].get("id")
|
||||||
except Exception:
|
except Exception:
|
||||||
|
if self._trial_claim_exists(
|
||||||
|
user_id=user_key,
|
||||||
|
email=normalized_email,
|
||||||
|
telegram_user_id=telegram_user_id,
|
||||||
|
wallet_addresses=wallet_addresses,
|
||||||
|
):
|
||||||
|
return {"created": False, "reason": "already_claimed"}
|
||||||
self._record_signup_trial_claim_event(
|
self._record_signup_trial_claim_event(
|
||||||
user_id=user_key,
|
user_id=user_key,
|
||||||
email=normalized_email,
|
email=normalized_email,
|
||||||
|
|||||||
@@ -78,6 +78,14 @@ def test_signup_trial_claim_creates_three_day_trial_once(monkeypatch):
|
|||||||
service = SupabaseEntitlementService()
|
service = SupabaseEntitlementService()
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
service,
|
||||||
|
"_rpc",
|
||||||
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
||||||
|
RuntimeError("PGRST202 function not found")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def fake_rest(method, table, **kwargs):
|
def fake_rest(method, table, **kwargs):
|
||||||
calls.append({"method": method, "table": table, **kwargs})
|
calls.append({"method": method, "table": table, **kwargs})
|
||||||
if method == "GET" and table in {"trial_claims", "user_wallets"}:
|
if method == "GET" and table in {"trial_claims", "user_wallets"}:
|
||||||
@@ -99,6 +107,49 @@ def test_signup_trial_claim_creates_three_day_trial_once(monkeypatch):
|
|||||||
assert payload["user_id"] == "user-1"
|
assert payload["user_id"] == "user-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_signup_trial_uses_atomic_database_claim_rpc(monkeypatch):
|
||||||
|
monkeypatch.setenv("POLYWEATHER_AUTH_ENABLED", "true")
|
||||||
|
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||||
|
monkeypatch.setenv("SUPABASE_ANON_KEY", "anon")
|
||||||
|
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
||||||
|
service = SupabaseEntitlementService()
|
||||||
|
rpc_calls = []
|
||||||
|
|
||||||
|
def fake_rpc(name, payload, **kwargs):
|
||||||
|
rpc_calls.append({"name": name, "payload": payload, **kwargs})
|
||||||
|
return {
|
||||||
|
"created": True,
|
||||||
|
"plan_code": "signup_trial_3d",
|
||||||
|
"expires_at": "2026-06-02T00:00:00+00:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
def fake_rest(method, table, **kwargs):
|
||||||
|
if method == "GET" and table == "user_wallets":
|
||||||
|
return [{"address": "0xabc"}]
|
||||||
|
raise AssertionError(
|
||||||
|
f"signup trial must use atomic RPC instead of legacy {method} {table}"
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(service, "_rpc", fake_rpc, raising=False)
|
||||||
|
monkeypatch.setattr(service, "_rest", fake_rest)
|
||||||
|
|
||||||
|
result = service.ensure_signup_trial("user-1", "USER@example.com")
|
||||||
|
|
||||||
|
assert result["created"] is True
|
||||||
|
assert rpc_calls == [
|
||||||
|
{
|
||||||
|
"name": "claim_signup_trial",
|
||||||
|
"payload": {
|
||||||
|
"p_user_id": "user-1",
|
||||||
|
"p_email": "user@example.com",
|
||||||
|
"p_telegram_user_id": None,
|
||||||
|
"p_wallet_addresses": ["0xabc"],
|
||||||
|
},
|
||||||
|
"allowed_status": [200],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_referral_discount_applies_to_first_monthly_payment(monkeypatch, tmp_path):
|
def test_referral_discount_applies_to_first_monthly_payment(monkeypatch, tmp_path):
|
||||||
_payment_env(monkeypatch, tmp_path)
|
_payment_env(monkeypatch, tmp_path)
|
||||||
service = PaymentContractCheckoutService()
|
service = PaymentContractCheckoutService()
|
||||||
@@ -187,6 +238,14 @@ def test_signup_trial_falls_back_to_entitlement_events_without_trial_tables(monk
|
|||||||
service = SupabaseEntitlementService()
|
service = SupabaseEntitlementService()
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
service,
|
||||||
|
"_rpc",
|
||||||
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
||||||
|
RuntimeError("PGRST202 function not found")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def fake_rest(method, table, **kwargs):
|
def fake_rest(method, table, **kwargs):
|
||||||
calls.append({"method": method, "table": table, **kwargs})
|
calls.append({"method": method, "table": table, **kwargs})
|
||||||
if method == "GET" and table == "user_wallets":
|
if method == "GET" and table == "user_wallets":
|
||||||
|
|||||||
Reference in New Issue
Block a user