create table if not exists public.point_activity_tasks ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, legacy_id text, code citext not null, title text not null, description text, task_type text not null default 'manual' check (task_type in ( 'daily_check_in', 'feedback_submit', 'feedback_resolved', 'practice_complete', 'vocabulary_review', 'mock_exam_submit', 'manual' )), reward_points integer not null default 0 check (reward_points > 0 and reward_points <= 100000), claim_limit_per_user integer not null default 1 check (claim_limit_per_user > 0 and claim_limit_per_user <= 100000), period_type text not null default 'once' check (period_type in ('once', 'daily', 'weekly', 'monthly', 'unlimited')), valid_from timestamptz, valid_to timestamptz, status text not null default 'active' check (status in ('active', 'disabled', 'archived')), sort_order integer not null default 0, metadata jsonb not null default '{}'::jsonb, created_by uuid references public.platform_users(id) on delete set null, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (tenant_id, code), unique (tenant_id, legacy_id) ); comment on table public.point_activity_tasks is 'Tenant-scoped score activity tasks configured by marketing/operations teams.'; create table if not exists public.user_point_activity_claims ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, user_id uuid not null references public.platform_users(id) on delete cascade, task_id uuid not null references public.point_activity_tasks(id) on delete cascade, period_key text not null, score_event_id uuid references public.user_score_events(id) on delete set null, source_type text, source_id uuid, status text not null default 'claimed' check (status in ('claimed', 'cancelled')), metadata jsonb not null default '{}'::jsonb, claimed_at timestamptz not null default now(), created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (tenant_id, user_id, task_id, period_key) ); comment on table public.user_point_activity_claims is 'Idempotent student claim records for activity rewards.'; create table if not exists public.point_exchange_items ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, legacy_id text, code citext not null, title text not null, description text, cost_points integer not null check (cost_points > 0 and cost_points <= 10000000), item_type text not null default 'manual' check (item_type in ('coupon', 'manual', 'asset', 'custom')), coupon_id uuid references public.coupons(id) on delete set null, asset_id uuid references public.content_assets(id) on delete set null, stock_total integer check (stock_total is null or stock_total >= 0), stock_used integer not null default 0 check (stock_used >= 0), per_user_limit integer not null default 1 check (per_user_limit > 0 and per_user_limit <= 100000), valid_from timestamptz, valid_to timestamptz, status text not null default 'active' check (status in ('active', 'disabled', 'archived')), sort_order integer not null default 0, metadata jsonb not null default '{}'::jsonb, created_by uuid references public.platform_users(id) on delete set null, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (tenant_id, code), unique (tenant_id, legacy_id), check (stock_total is null or stock_used <= stock_total) ); comment on table public.point_exchange_items is 'Tenant-scoped score exchange catalog. Coupon fulfillment is supported, activation-code fulfillment is intentionally kept out of the first contract.'; create table if not exists public.user_point_exchange_orders ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, user_id uuid not null references public.platform_users(id) on delete cascade, item_id uuid not null references public.point_exchange_items(id) on delete restrict, status text not null default 'completed' check (status in ('completed', 'pending_fulfillment', 'cancelled')), cost_points integer not null check (cost_points > 0), score_event_id uuid references public.user_score_events(id) on delete set null, coupon_redemption_id uuid references public.coupon_redemptions(id) on delete set null, asset_id uuid references public.content_assets(id) on delete set null, idempotency_key text, metadata jsonb not null default '{}'::jsonb, exchanged_at timestamptz not null default now(), created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (tenant_id, user_id, idempotency_key) ); comment on table public.user_point_exchange_orders is 'Student score exchange orders. Each completed order has a negative redeem_cost ledger event.'; create index if not exists idx_point_activity_tasks_tenant_status on public.point_activity_tasks(tenant_id, status, sort_order, created_at desc); create index if not exists idx_point_activity_claims_user on public.user_point_activity_claims(tenant_id, user_id, claimed_at desc); create index if not exists idx_point_activity_claims_task on public.user_point_activity_claims(tenant_id, task_id, claimed_at desc); create index if not exists idx_point_exchange_items_tenant_status on public.point_exchange_items(tenant_id, status, sort_order, created_at desc); create index if not exists idx_point_exchange_orders_user on public.user_point_exchange_orders(tenant_id, user_id, exchanged_at desc); create index if not exists idx_point_exchange_orders_item on public.user_point_exchange_orders(tenant_id, item_id, exchanged_at desc); alter table public.point_activity_tasks enable row level security; alter table public.user_point_activity_claims enable row level security; alter table public.point_exchange_items enable row level security; alter table public.user_point_exchange_orders enable row level security; drop policy if exists tenant_isolation on public.point_activity_tasks; create policy tenant_isolation on public.point_activity_tasks for all using (tenant_id = app.current_tenant_id() or app.is_platform_admin()) with check (tenant_id = app.current_tenant_id() or app.is_platform_admin()); drop policy if exists tenant_isolation on public.user_point_activity_claims; create policy tenant_isolation on public.user_point_activity_claims for all using (tenant_id = app.current_tenant_id() or app.is_platform_admin()) with check (tenant_id = app.current_tenant_id() or app.is_platform_admin()); drop policy if exists tenant_isolation on public.point_exchange_items; create policy tenant_isolation on public.point_exchange_items for all using (tenant_id = app.current_tenant_id() or app.is_platform_admin()) with check (tenant_id = app.current_tenant_id() or app.is_platform_admin()); drop policy if exists tenant_isolation on public.user_point_exchange_orders; create policy tenant_isolation on public.user_point_exchange_orders for all using (tenant_id = app.current_tenant_id() or app.is_platform_admin()) with check (tenant_id = app.current_tenant_id() or app.is_platform_admin()); drop trigger if exists set_updated_at on public.point_activity_tasks; create trigger set_updated_at before update on public.point_activity_tasks for each row execute function app.touch_updated_at(); drop trigger if exists set_updated_at on public.user_point_activity_claims; create trigger set_updated_at before update on public.user_point_activity_claims for each row execute function app.touch_updated_at(); drop trigger if exists set_updated_at on public.point_exchange_items; create trigger set_updated_at before update on public.point_exchange_items for each row execute function app.touch_updated_at(); drop trigger if exists set_updated_at on public.user_point_exchange_orders; create trigger set_updated_at before update on public.user_point_exchange_orders for each row execute function app.touch_updated_at();