This commit is contained in:
jjbrunton committed 2026-01-01 13:29:25 +00:00
1 parent 75509b0e45
commit ef7bc8c90d
16 files changed
+886 -32

No files matched your search

@@ -0,0 +1,135 @@
-- App Configuration table for mobile app settings
-- Stores app behavior settings configurable by super admins
CREATE TABLE IF NOT EXISTS app_config (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-- Answer Spam Prevention
-- Maximum questions a user can answer ahead of their partner (0 = disabled)
answer_gap_threshold integer DEFAULT 10,
-- Metadata
updated_at timestamptz DEFAULT now(),
updated_by uuid REFERENCES auth.users(id)
);
-- Only allow one row in this table (singleton pattern)
CREATE UNIQUE INDEX IF NOT EXISTS app_config_singleton ON app_config ((true));
-- Insert default config row
INSERT INTO app_config (id) VALUES (gen_random_uuid())
ON CONFLICT DO NOTHING;
-- Enable RLS
ALTER TABLE app_config ENABLE ROW LEVEL SECURITY;
-- All authenticated users can read (needed for mobile app)
CREATE POLICY "Authenticated users can read app_config"
ON app_config FOR SELECT
USING (auth.role() = 'authenticated');
-- Only super admins can update
CREATE POLICY "Super admins can update app_config"
ON app_config FOR UPDATE
USING (is_super_admin());
-- Create trigger to auto-update timestamp (reuses existing function)
CREATE TRIGGER app_config_updated_at
BEFORE UPDATE ON app_config
FOR EACH ROW
EXECUTE FUNCTION update_updated_at();
-- Add comment for documentation
COMMENT ON TABLE app_config IS 'Singleton table storing mobile app configuration. Readable by all authenticated users, writable by super admins only.';
COMMENT ON COLUMN app_config.answer_gap_threshold IS 'Maximum number of questions a user can answer ahead of their partner. Set to 0 to disable answer spam prevention.';
-- ============================================================================
-- RPC FUNCTION: get_answer_gap_status
-- Returns the number of questions user has answered that partner hasn't,
-- the configured threshold, and whether the user is blocked.
-- ============================================================================
CREATE OR REPLACE FUNCTION get_answer_gap_status()
RETURNS TABLE(
unanswered_by_partner integer,
threshold integer,
is_blocked boolean
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = 'public'
AS $$
DECLARE
v_user_id UUID;
v_couple_id UUID;
v_partner_id UUID;
v_gap_count integer;
v_threshold integer;
BEGIN
v_user_id := auth.uid();
-- Get user's couple_id
SELECT p.couple_id INTO v_couple_id
FROM profiles p
WHERE p.id = v_user_id;
-- No couple = no blocking needed
IF v_couple_id IS NULL THEN
RETURN QUERY SELECT 0, 0, false;
RETURN;
END IF;
-- Get partner's ID
SELECT p.id INTO v_partner_id
FROM profiles p
WHERE p.couple_id = v_couple_id AND p.id != v_user_id
LIMIT 1;
-- No partner = no blocking needed
IF v_partner_id IS NULL THEN
RETURN QUERY SELECT 0, 0, false;
RETURN;
END IF;
-- Get threshold from app_config
SELECT COALESCE(ac.answer_gap_threshold, 10) INTO v_threshold
FROM app_config ac
LIMIT 1;
-- If threshold is 0, feature is disabled
IF v_threshold = 0 THEN
RETURN QUERY SELECT 0, 0, false;
RETURN;
END IF;
-- Count questions in ENABLED packs where:
-- - Current user HAS responded
-- - Partner has NOT responded
WITH enabled_packs AS (
-- Get packs that the couple has explicitly enabled
SELECT cp.pack_id
FROM couple_packs cp
WHERE cp.couple_id = v_couple_id AND cp.enabled = true
)
SELECT COUNT(*)::integer INTO v_gap_count
FROM responses r
INNER JOIN questions q ON q.id = r.question_id
WHERE r.user_id = v_user_id
AND r.couple_id = v_couple_id
AND (
-- Only count if couple has enabled packs AND question is in one of them
-- If no packs enabled, don't block (couple hasn't set up yet)
EXISTS (SELECT 1 FROM enabled_packs)
AND q.pack_id IN (SELECT pack_id FROM enabled_packs)
)
AND NOT EXISTS (
SELECT 1 FROM responses pr
WHERE pr.question_id = r.question_id
AND pr.user_id = v_partner_id
);
RETURN QUERY SELECT v_gap_count, v_threshold, (v_gap_count >= v_threshold);
END;
$$;
COMMENT ON FUNCTION get_answer_gap_status() IS 'Returns answer gap status for the current user. Used by mobile app to prevent answer spam.';
@@ -0,0 +1,167 @@
-- Add answer gap blocking to get_recommended_questions
-- When user is over the threshold, returns empty (old apps see "All caught up!")
-- New apps call get_answer_gap_status to determine blocking reason
CREATE OR REPLACE FUNCTION get_recommended_questions(target_pack_id UUID DEFAULT NULL)
RETURNS TABLE(
id UUID,
text TEXT,
partner_text TEXT,
is_two_part BOOLEAN,
pack_id UUID,
intensity INTEGER,
partner_answered BOOLEAN,
allowed_couple_genders TEXT[],
target_user_genders TEXT[]
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = 'public'
AS $$
DECLARE
v_user_id UUID;
v_couple_id UUID;
v_partner_id UUID;
v_user_gender TEXT;
v_partner_gender TEXT;
v_couple_type TEXT;
v_has_premium BOOLEAN;
v_gap_threshold INTEGER;
v_gap_count INTEGER;
BEGIN
v_user_id := auth.uid();
SELECT p.couple_id, p.gender INTO v_couple_id, v_user_gender
FROM public.profiles p
WHERE p.id = v_user_id;
IF v_couple_id IS NULL THEN
RETURN;
END IF;
SELECT p.id, p.gender INTO v_partner_id, v_partner_gender
FROM public.profiles p
WHERE p.couple_id = v_couple_id AND p.id != v_user_id
LIMIT 1;
-- Check answer gap threshold (only when not browsing a specific pack)
IF v_partner_id IS NOT NULL AND target_pack_id IS NULL THEN
-- Get threshold from app_config
SELECT COALESCE(ac.answer_gap_threshold, 10) INTO v_gap_threshold
FROM app_config ac
LIMIT 1;
-- If threshold > 0, check the gap
IF v_gap_threshold > 0 THEN
-- Count questions in ENABLED packs where user answered but partner hasn't
WITH enabled_packs AS (
SELECT cp.pack_id
FROM couple_packs cp
WHERE cp.couple_id = v_couple_id AND cp.enabled = true
)
SELECT COUNT(*)::integer INTO v_gap_count
FROM responses r
INNER JOIN questions q ON q.id = r.question_id
WHERE r.user_id = v_user_id
AND r.couple_id = v_couple_id
AND EXISTS (SELECT 1 FROM enabled_packs)
AND q.pack_id IN (SELECT ep.pack_id FROM enabled_packs ep)
AND NOT EXISTS (
SELECT 1 FROM responses pr
WHERE pr.question_id = r.question_id
AND pr.user_id = v_partner_id
);
-- If over threshold, return empty (old apps see "All caught up!")
IF v_gap_count >= v_gap_threshold THEN
RETURN;
END IF;
END IF;
END IF;
IF v_user_gender IS NOT NULL AND v_partner_gender IS NOT NULL THEN
IF v_user_gender < v_partner_gender THEN
v_couple_type := v_user_gender || '+' || v_partner_gender;
ELSE
v_couple_type := v_partner_gender || '+' || v_user_gender;
END IF;
END IF;
v_has_premium := public.has_premium_access(v_user_id);
RETURN QUERY
WITH user_config_exists AS (
SELECT 1 FROM public.couple_packs WHERE couple_id = v_couple_id LIMIT 1
),
active_packs AS (
SELECT target_pack_id AS pack_id
WHERE target_pack_id IS NOT NULL
AND EXISTS (
SELECT 1 FROM public.question_packs qp
WHERE qp.id = target_pack_id
AND (qp.is_premium = FALSE OR v_has_premium = TRUE)
)
UNION ALL
SELECT cp.pack_id FROM public.couple_packs cp
INNER JOIN public.question_packs qp ON qp.id = cp.pack_id
WHERE target_pack_id IS NULL
AND cp.couple_id = v_couple_id
AND cp.enabled = TRUE
AND (qp.is_premium = FALSE OR v_has_premium = TRUE)
AND EXISTS (SELECT 1 FROM user_config_exists)
UNION ALL
SELECT p.id AS pack_id FROM public.question_packs p
WHERE target_pack_id IS NULL
AND p.is_public = TRUE
AND (p.is_premium = FALSE OR v_has_premium = TRUE)
AND NOT EXISTS (SELECT 1 FROM user_config_exists)
)
SELECT
q.id,
CASE
WHEN q.partner_text IS NOT NULL AND EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.couple_id = v_couple_id
AND r.user_id != v_user_id
) THEN q.partner_text
ELSE q.text
END as text,
q.partner_text,
q.partner_text IS NOT NULL as is_two_part,
q.pack_id,
q.intensity,
EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.couple_id = v_couple_id
AND r.user_id != v_user_id
) AS partner_answered,
q.allowed_couple_genders,
q.target_user_genders
FROM public.questions q
WHERE q.pack_id IN (SELECT ap.pack_id FROM active_packs ap)
AND NOT EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.user_id = v_user_id
)
AND (
q.allowed_couple_genders IS NULL
OR v_couple_type IS NULL
OR v_couple_type = ANY(q.allowed_couple_genders)
)
AND (
q.target_user_genders IS NULL
OR v_user_gender = ANY(q.target_user_genders)
OR EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.couple_id = v_couple_id
AND r.user_id != v_user_id
)
);
END;
$$;
COMMENT ON FUNCTION get_recommended_questions(UUID) IS 'Returns recommended questions for user. When user is too far ahead of partner (answer gap threshold exceeded), returns empty. Old apps see "All caught up!", new apps can call get_answer_gap_status to determine blocking reason.';