BEGIN; -- Serialize rewards per user so distinct, concurrently verified callbacks -- cannot both pass the cooldown/cap checks before either claim is visible. CREATE OR REPLACE FUNCTION public.grant_verified_ad_reward( p_user_id uuid, p_network text, p_placement text, p_ad_unit_id text, p_transaction_id text, p_reward_tokens integer ) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, pg_temp AS $$ DECLARE v_claim_id uuid; v_balance integer; BEGIN IF p_user_id IS NULL OR NOT EXISTS (SELECT 1 FROM auth.users WHERE id = p_user_id) THEN RAISE EXCEPTION 'unknown_user'; END IF; IF p_transaction_id IS NULL OR length(trim(p_transaction_id)) < 8 OR length(trim(p_transaction_id)) > 128 THEN RAISE EXCEPTION 'invalid_transaction'; END IF; IF p_reward_tokens <> 50 THEN RAISE EXCEPTION 'invalid_reward_amount'; END IF; IF length(trim(coalesce(p_network, ''))) NOT BETWEEN 1 AND 80 OR length(trim(coalesce(p_placement, ''))) NOT BETWEEN 1 AND 80 OR length(trim(coalesce(p_ad_unit_id, ''))) NOT BETWEEN 1 AND 80 THEN RAISE EXCEPTION 'invalid_reward_metadata'; END IF; PERFORM pg_advisory_xact_lock( pg_catalog.hashtextextended('d3ro:ad-reward:' || p_user_id::text, 0) ); IF NOT EXISTS ( SELECT 1 FROM public.subscriptions WHERE user_id = p_user_id AND tier = 'free' AND coalesce(status, 'active') IN ('active', 'trialing') ) THEN RETURN jsonb_build_object('granted', false, 'reason', 'ineligible_tier'); END IF; IF EXISTS ( SELECT 1 FROM public.ad_reward_claims WHERE transaction_id = trim(p_transaction_id) ) THEN RETURN jsonb_build_object('granted', false, 'reason', 'duplicate'); END IF; IF ( SELECT count(*) FROM public.ad_reward_claims WHERE user_id = p_user_id AND verified_at >= date_trunc('day', now()) ) >= 20 THEN RETURN jsonb_build_object('granted', false, 'reason', 'daily_cap'); END IF; IF EXISTS ( SELECT 1 FROM public.ad_reward_claims WHERE user_id = p_user_id AND verified_at > now() - interval '15 seconds' ) THEN RETURN jsonb_build_object('granted', false, 'reason', 'cooldown'); END IF; INSERT INTO public.ad_reward_claims ( user_id, network, placement, ad_unit_id, transaction_id, reward_tokens ) VALUES ( p_user_id, trim(p_network), trim(p_placement), trim(p_ad_unit_id), trim(p_transaction_id), p_reward_tokens ) ON CONFLICT (transaction_id) DO NOTHING RETURNING id INTO v_claim_id; IF v_claim_id IS NULL THEN RETURN jsonb_build_object('granted', false, 'reason', 'duplicate'); END IF; INSERT INTO public.subscriptions (user_id, tier, overage_credits, provider) VALUES (p_user_id, 'free', p_reward_tokens, 'none') ON CONFLICT (user_id) DO UPDATE SET overage_credits = public.subscriptions.overage_credits + EXCLUDED.overage_credits, updated_at = now() RETURNING overage_credits INTO v_balance; RETURN jsonb_build_object( 'granted', true, 'claim_id', v_claim_id, 'tokens_added', p_reward_tokens, 'balance', v_balance ); END; $$; REVOKE ALL ON FUNCTION public.grant_verified_ad_reward( uuid, text, text, text, text, integer ) FROM PUBLIC, anon, authenticated; GRANT EXECUTE ON FUNCTION public.grant_verified_ad_reward( uuid, text, text, text, text, integer ) TO service_role; -- Exact dashboard aggregates shared by mobile and web. SECURITY INVOKER plus -- the explicit auth.uid predicate keeps this within the caller's RLS boundary. CREATE OR REPLACE FUNCTION public.mobile_dashboard_stats() RETURNS jsonb LANGUAGE sql STABLE SECURITY INVOKER SET search_path = public, pg_temp AS $$ WITH activity AS ( SELECT id, title, original_text, duration, word_count, mode, created_at FROM public.history WHERE user_id = auth.uid() AND status = 'completed' ), activity_days AS ( SELECT DISTINCT (created_at AT TIME ZONE 'UTC')::date AS activity_day FROM activity ), streak_anchor AS ( SELECT CASE WHEN EXISTS ( SELECT 1 FROM activity_days WHERE activity_day = (now() AT TIME ZONE 'UTC')::date ) THEN (now() AT TIME ZONE 'UTC')::date WHEN EXISTS ( SELECT 1 FROM activity_days WHERE activity_day = (now() AT TIME ZONE 'UTC')::date - 1 ) THEN (now() AT TIME ZONE 'UTC')::date - 1 ELSE NULL::date END AS anchor_day ), ordered_days AS ( SELECT activity_day, row_number() OVER (ORDER BY activity_day DESC) AS position, anchor_day FROM activity_days CROSS JOIN streak_anchor WHERE anchor_day IS NOT NULL AND activity_day <= anchor_day ), recent_rows AS ( SELECT id, title, original_text, duration, mode, created_at FROM activity ORDER BY created_at DESC, id DESC LIMIT 5 ) SELECT jsonb_build_object( 'total_sessions', (SELECT count(*) FROM activity), 'total_recording_seconds', coalesce((SELECT sum(greatest(duration, 0)) FROM activity), 0), 'total_word_count', coalesce((SELECT sum(greatest(word_count, 0)) FROM activity), 0), 'today_sessions', ( SELECT count(*) FROM activity WHERE (created_at AT TIME ZONE 'UTC')::date = (now() AT TIME ZONE 'UTC')::date ), 'today_recording_seconds', coalesce(( SELECT sum(greatest(duration, 0)) FROM activity WHERE (created_at AT TIME ZONE 'UTC')::date = (now() AT TIME ZONE 'UTC')::date ), 0), 'today_word_count', coalesce(( SELECT sum(greatest(word_count, 0)) FROM activity WHERE (created_at AT TIME ZONE 'UTC')::date = (now() AT TIME ZONE 'UTC')::date ), 0), 'streak_days', ( SELECT count(*) FROM ordered_days WHERE activity_day = anchor_day - ((position - 1)::integer) ), 'recent_history', coalesce(( SELECT jsonb_agg(jsonb_build_object( 'id', id, 'title', title, 'text', original_text, 'duration_seconds', duration, 'mode', mode, 'created_at', created_at ) ORDER BY created_at DESC, id DESC) FROM recent_rows ), '[]'::jsonb), 'generated_at', now() ); $$; REVOKE ALL ON FUNCTION public.mobile_dashboard_stats() FROM PUBLIC, anon; GRANT EXECUTE ON FUNCTION public.mobile_dashboard_stats() TO authenticated; COMMENT ON FUNCTION public.mobile_dashboard_stats() IS 'Exact authenticated history aggregates for mobile/web dashboards; UTC day boundary.'; COMMIT;