diff --git a/.gemini/settings.json b/.gemini/settings.json deleted file mode 100644 index b9cfd51..0000000 --- a/.gemini/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "general": { - "previewFeatures": true - } -} \ No newline at end of file diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index b5e8cfd..861796c 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -3,12 +3,15 @@ name: Claude Code Review on: pull_request: types: [opened, synchronize, ready_for_review, reopened] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" + paths: + - "the-trash-rn/**" + - "supabase/migrations/**" + - "scripts/**" + - "Makefile" + - "README.md" + - ".github/workflows/**" + paths-ignore: + - "legacy/**" jobs: claude-review: @@ -41,4 +44,3 @@ jobs: prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options - diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index d300267..8747c54 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -5,18 +5,15 @@ on: types: [created] pull_request_review_comment: types: [created] - issues: - types: [opened, assigned] pull_request_review: types: [submitted] jobs: claude: if: | - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, '@claude')) || (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || - (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) runs-on: ubuntu-latest permissions: contents: read @@ -47,4 +44,3 @@ jobs: # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options # claude_args: '--allowed-tools Bash(gh pr:*)' - diff --git a/.github/workflows/rn-ci.yml b/.github/workflows/rn-ci.yml new file mode 100644 index 0000000..099753b --- /dev/null +++ b/.github/workflows/rn-ci.yml @@ -0,0 +1,46 @@ +name: RN CI + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - "the-trash-rn/**" + - "supabase/migrations/**" + - "scripts/**" + - "Makefile" + - "README.md" + - ".github/workflows/rn-ci.yml" + paths-ignore: + - "legacy/**" + workflow_dispatch: + +jobs: + rn-quality: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: "10" + + - name: Install Dependencies + run: pnpm --dir the-trash-rn install --frozen-lockfile + + - name: Lint RN + run: pnpm --dir the-trash-rn lint + + - name: Check Backend Contracts + run: bash scripts/check_backend_contracts.sh --strict + + - name: Check Migration Source + run: bash scripts/check_migration_mirror.sh --strict diff --git a/Makefile b/Makefile index 50c45d8..4edbff1 100644 --- a/Makefile +++ b/Makefile @@ -1,22 +1,27 @@ -PROJECT := The Trash.xcodeproj -SCHEME := The Trash -SIMULATOR ?= iPhone 16 -DEST_SIM := platform=iOS Simulator,name=$(SIMULATOR) -DEST_DEVICE := generic/platform=iOS +RN_DIR := the-trash-rn -.PHONY: open build build-device test contracts contracts-strict migrations-check migrations-check-strict migrations-sync doctor +.PHONY: install start ios android pods lint format contracts contracts-strict migrations-check migrations-check-strict migrations-sync doctor legacy-open -open: - open "$(PROJECT)" +install: + pnpm --dir "$(RN_DIR)" install -build: - xcodebuild -project "$(PROJECT)" -scheme "$(SCHEME)" -destination '$(DEST_SIM)' build +start: + pnpm --dir "$(RN_DIR)" expo start --dev-client --tunnel --clear -build-device: - xcodebuild -project "$(PROJECT)" -scheme "$(SCHEME)" -destination '$(DEST_DEVICE)' build +ios: + pnpm --dir "$(RN_DIR)" expo run:ios -test: - xcodebuild -project "$(PROJECT)" -scheme "$(SCHEME)" -destination '$(DEST_SIM)' test +android: + pnpm --dir "$(RN_DIR)" expo run:android + +pods: + pnpm --dir "$(RN_DIR)" pods:install + +lint: + pnpm --dir "$(RN_DIR)" lint + +format: + pnpm --dir "$(RN_DIR)" format contracts: bash scripts/check_backend_contracts.sh @@ -36,3 +41,6 @@ migrations-sync: doctor: bash scripts/check_backend_contracts.sh --strict bash scripts/check_migration_mirror.sh --strict + +legacy-open: + open "legacy/swift-ios/The Trash.xcodeproj" diff --git a/README.md b/README.md index 43a56e9..adc2322 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,56 @@ # The Trash -The Trash 是一个垃圾分类与环保互动产品仓库,包含两个客户端实现: +The Trash 是一个垃圾分类与环保互动产品仓库。 -- `The Trash/`:SwiftUI 原生 iOS 版本(主工程) -- `the-trash-rn/`:Expo + React Native 版本(跨平台) +当前主线客户端: + +- `the-trash-rn/`:Expo + React Native(主线) + +已归档客户端(Legacy): + +- `legacy/swift-ios/The Trash/`:SwiftUI iOS 版本(不再主线维护) ## 仓库结构 ```text . -├── The Trash/ # SwiftUI iOS 代码 -├── The Trash.xcodeproj # Xcode 工程 -├── the-trash-rn/ # Expo / RN 代码 -├── supabase/migrations/ # Supabase 迁移源 -├── The Trash/migrations/ # App 侧 SQL 镜像 -├── scripts/ # 契约检查与迁移同步脚本 +├── the-trash-rn/ # Expo / RN 代码(主线) +├── legacy/swift-ios/ # 归档 Swift 工程 +├── supabase/migrations/ # Supabase 迁移唯一来源 +├── scripts/ # 契约与迁移检查脚本 ├── Makefile └── docs/ ``` -## 1) Swift iOS 开发 +## 1) React Native 开发(主线) + +详细说明见 `the-trash-rn/README.md`。 ### 环境 -- Xcode 16+ -- iOS Simulator 或真机 -- `MobileCLIPImage.mlpackage` 放在 `The Trash/` 下 -- 本地私密配置 `The Trash/Secrets.swift`(不要提交) +- Node.js 20+ +- pnpm 10+ +- Xcode / Android Studio(按平台需要) ### 常用命令 ```bash -make open -make build -make build-device -make test -``` - -也可直接打开工程: - -```bash -open "The Trash.xcodeproj" -``` - -## 2) React Native 开发(推荐先走这条) - -详细说明见 `the-trash-rn/README.md`。 - -快速启动: - -```bash -cd the-trash-rn -pnpm install -pnpm expo start --dev-client --tunnel --clear +make install +make start +make ios +make android +make lint ``` -### RN iOS 真机最短路径 +也可直接在 RN 目录执行: ```bash cd the-trash-rn pnpm install -pnpm pods:install -pnpm expo run:ios --device pnpm expo start --dev-client --tunnel --clear ``` -## 3) 数据库迁移(Supabase) +## 2) 数据库迁移(Supabase) ### 迁移执行 @@ -75,28 +60,46 @@ supabase db push --project-ref `project-ref` 就是 Supabase 项目短 ID(Dashboard URL 里 `project/` 这段)。 -### 镜像同步与契约检查 +### 契约与迁移检查 ```bash -make migrations-sync -make migrations-check make contracts +make migrations-check make doctor ``` +说明: + +- `supabase/migrations/` 是唯一真相源。 +- `make migrations-sync` 仅保留兼容入口,现为 no-op。 + 建议流程: 1. 新增 `supabase/migrations/*.sql` 2. `supabase db push --project-ref ` -3. `make migrations-sync` -4. `make doctor` -5. 提交 `supabase/migrations` 和 `The Trash/migrations` +3. `make contracts` +4. `make migrations-check` +5. 提交 `supabase/migrations` + +## 3) Swift iOS(Legacy 归档) + +Swift 工程已归档,仅在需要回溯时使用: + +```bash +make legacy-open +``` + +或直接: + +```bash +open "legacy/swift-ios/The Trash.xcodeproj" +``` ## 4) 常见问题 -- `Could not find table ... in schema cache`: - - 说明远端 schema 还没应用完整迁移,先执行 `supabase db push`。 -- Expo iOS 出现 ATS 明文连接报错: +- `Could not find table ... in schema cache` + - 远端 schema 未应用完整迁移,先执行 `supabase db push`。 +- Expo iOS 出现 ATS 明文连接报错 - 使用 `pnpm expo start --dev-client --tunnel --clear`。 -- `Authentication with Apple Developer Portal failed / no team`: - - 不能走 EAS iOS 云构建;可用本地 Xcode 真机安装,或先用 Android 开发机。 +- `Authentication with Apple Developer Portal failed / no team` + - iOS 云构建不可用时,可先走本地 Xcode 真机安装或 Android 开发流程。 diff --git a/The Trash/migrations/001_arena_quiz_mode.sql b/The Trash/migrations/001_arena_quiz_mode.sql deleted file mode 100644 index f8e4e51..0000000 --- a/The Trash/migrations/001_arena_quiz_mode.sql +++ /dev/null @@ -1,112 +0,0 @@ --- ============================================================ --- Migration: Arena Quiz Mode --- Date: 2026-02-06 --- Description: --- - Remove crowdsource voting system (correction_tasks, correction_votes) --- - Remove settlement/punishment triggers --- - Create new quiz_questions table with correct answers --- - Create get_quiz_questions RPC function --- ============================================================ - --- ============================================================ --- PART 1: CLEANUP - Drop old tables and triggers --- ============================================================ - --- Drop triggers first (they depend on functions) -DROP TRIGGER IF EXISTS on_vote_added_settlement ON public.correction_votes; -DROP TRIGGER IF EXISTS on_feedback_submitted ON public.feedback_logs; - --- Drop functions -DROP FUNCTION IF EXISTS public.process_arena_settlement(); -DROP FUNCTION IF EXISTS public.convert_feedback_to_arena_task(); -DROP FUNCTION IF EXISTS public.get_arena_tasks(); - --- Drop old tables (correction_votes first due to foreign key) -DROP TABLE IF EXISTS public.correction_votes; -DROP TABLE IF EXISTS public.correction_tasks; - --- ============================================================ --- PART 2: CREATE NEW QUIZ SYSTEM --- ============================================================ - --- Create quiz_questions table -CREATE TABLE IF NOT EXISTS public.quiz_questions ( - id UUID DEFAULT gen_random_uuid() PRIMARY KEY, - image_url TEXT NOT NULL, - correct_category TEXT NOT NULL, - item_name TEXT, - created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc', now()), - is_active BOOLEAN DEFAULT true -); - --- Set ownership -ALTER TABLE public.quiz_questions OWNER TO postgres; - --- Enable RLS -ALTER TABLE public.quiz_questions ENABLE ROW LEVEL SECURITY; - --- Allow authenticated users to read quiz questions -CREATE POLICY "Quiz questions are readable by authenticated users" - ON public.quiz_questions - FOR SELECT - TO authenticated - USING (is_active = true); - --- ============================================================ --- PART 3: CREATE RPC FUNCTION --- ============================================================ - --- Function to get random quiz questions (10 per session) -CREATE OR REPLACE FUNCTION public.get_quiz_questions() -RETURNS SETOF public.quiz_questions -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - RETURN QUERY - SELECT * - FROM public.quiz_questions - WHERE is_active = true - ORDER BY random() - LIMIT 10; -END; -$$; - -ALTER FUNCTION public.get_quiz_questions() OWNER TO postgres; - --- ============================================================ --- PART 4: OPTIONAL - Remove punishment columns from profiles --- Uncomment if you want to clean up the profiles table --- ============================================================ - --- ALTER TABLE public.profiles DROP COLUMN IF EXISTS status; --- ALTER TABLE public.profiles DROP COLUMN IF EXISTS banned_until; - --- Drop the protection trigger if removing status fields --- DROP TRIGGER IF EXISTS ensure_profile_security ON public.profiles; --- DROP FUNCTION IF EXISTS public.protect_sensitive_profile_fields(); - --- ============================================================ --- PART 5: SEED DATA (Example quiz questions) --- Replace with your actual quiz data --- ============================================================ - --- INSERT INTO public.quiz_questions (image_url, correct_category, item_name) VALUES --- ('https://example.com/images/plastic_bottle.jpg', 'Recyclable', 'Plastic Bottle'), --- ('https://example.com/images/banana_peel.jpg', 'Compostable', 'Banana Peel'), --- ('https://example.com/images/battery.jpg', 'Hazardous', 'Battery'), --- ('https://example.com/images/chip_bag.jpg', 'Landfill', 'Chip Bag'); - --- ============================================================ --- VERIFICATION QUERIES (Run after migration to verify) --- ============================================================ - --- Check new table exists: --- SELECT * FROM public.quiz_questions LIMIT 5; - --- Test RPC function: --- SELECT * FROM public.get_quiz_questions(); - --- Verify old tables are gone: --- SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'correction_tasks'); --- SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'correction_votes'); diff --git a/The Trash/migrations/002_community_feature.sql b/The Trash/migrations/002_community_feature.sql deleted file mode 100644 index a660c54..0000000 --- a/The Trash/migrations/002_community_feature.sql +++ /dev/null @@ -1,653 +0,0 @@ --- ===================================================== --- Migration: 002_community_feature.sql --- Description: Add community & events support with location-based filtering --- Author: Albert Huang --- Date: 2026-02-06 --- Version: 2.0 (支持多社区加入、位置筛选、距离排序) --- ===================================================== - --- ===================================================== --- 1. COMMUNITIES TABLE (社区/组织表) --- ===================================================== - -CREATE TABLE IF NOT EXISTS public.communities ( - id TEXT PRIMARY KEY, -- 社区唯一标识 (如 'san-diego-green') - name TEXT NOT NULL, -- 社区名称 - city TEXT NOT NULL, -- 城市 - state TEXT, -- 州/省 - country TEXT DEFAULT 'US', -- 国家 - description TEXT, -- 社区描述 - logo_url TEXT, -- Logo URL - latitude DECIMAL(10, 8), -- 纬度 - longitude DECIMAL(11, 8), -- 经度 - member_count INTEGER DEFAULT 0, -- 成员数量 (缓存值) - is_active BOOLEAN DEFAULT true, -- 是否激活 - created_at TIMESTAMPTZ DEFAULT timezone('utc', now()), - updated_at TIMESTAMPTZ DEFAULT timezone('utc', now()) -); - --- 创建索引 -CREATE INDEX IF NOT EXISTS idx_communities_city ON public.communities(city); -CREATE INDEX IF NOT EXISTS idx_communities_state ON public.communities(state); -CREATE INDEX IF NOT EXISTS idx_communities_location ON public.communities(latitude, longitude); -CREATE INDEX IF NOT EXISTS idx_communities_is_active ON public.communities(is_active); - --- ===================================================== --- 2. USER COMMUNITY MEMBERSHIPS (用户社区关联表 - 多对多) --- ===================================================== - -CREATE TABLE IF NOT EXISTS public.user_community_memberships ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - community_id TEXT NOT NULL REFERENCES public.communities(id) ON DELETE CASCADE, - status TEXT DEFAULT 'member' CHECK (status IN ('pending', 'member', 'admin', 'banned')), - joined_at TIMESTAMPTZ DEFAULT timezone('utc', now()), - UNIQUE(user_id, community_id) -- 每个用户每个社区只能有一条记录 -); - --- 创建索引 -CREATE INDEX IF NOT EXISTS idx_memberships_user ON public.user_community_memberships(user_id); -CREATE INDEX IF NOT EXISTS idx_memberships_community ON public.user_community_memberships(community_id); -CREATE INDEX IF NOT EXISTS idx_memberships_status ON public.user_community_memberships(status); - --- ===================================================== --- 3. USER LOCATIONS TABLE (用户位置表) --- ===================================================== - --- 修改 profiles 表,添加位置字段 -ALTER TABLE public.profiles -ADD COLUMN IF NOT EXISTS location_city TEXT, -ADD COLUMN IF NOT EXISTS location_state TEXT, -ADD COLUMN IF NOT EXISTS location_latitude DECIMAL(10, 8), -ADD COLUMN IF NOT EXISTS location_longitude DECIMAL(11, 8); - --- 创建位置索引 -CREATE INDEX IF NOT EXISTS idx_profiles_location ON public.profiles(location_city, location_state); -CREATE INDEX IF NOT EXISTS idx_profiles_coordinates ON public.profiles(location_latitude, location_longitude); - --- ===================================================== --- 4. COMMUNITY EVENTS TABLE (社区活动表) --- ===================================================== - -CREATE TABLE IF NOT EXISTS public.community_events ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - community_id TEXT NOT NULL REFERENCES public.communities(id) ON DELETE CASCADE, - title TEXT NOT NULL, -- 活动标题 - description TEXT, -- 活动描述 - organizer TEXT NOT NULL, -- 组织者名称 - category TEXT NOT NULL CHECK (category IN ('cleanup', 'workshop', 'competition', 'education', 'other')), - event_date TIMESTAMPTZ NOT NULL, -- 活动时间 - location TEXT NOT NULL, -- 活动地点文字描述 - latitude DECIMAL(10, 8) NOT NULL, -- 纬度 - longitude DECIMAL(11, 8) NOT NULL, -- 经度 - image_url TEXT, -- 活动封面图 - icon_name TEXT DEFAULT 'calendar', -- SF Symbol 图标名 - max_participants INTEGER DEFAULT 100, -- 最大参与人数 - participant_count INTEGER DEFAULT 0, -- 当前参与人数 (缓存值) - credits_reward INTEGER DEFAULT 10, -- 参与可获得积分 - status TEXT DEFAULT 'upcoming' CHECK (status IN ('upcoming', 'ongoing', 'completed', 'cancelled')), - created_at TIMESTAMPTZ DEFAULT timezone('utc', now()), - updated_at TIMESTAMPTZ DEFAULT timezone('utc', now()) -); - --- 创建索引 -CREATE INDEX IF NOT EXISTS idx_events_community ON public.community_events(community_id); -CREATE INDEX IF NOT EXISTS idx_events_date ON public.community_events(event_date); -CREATE INDEX IF NOT EXISTS idx_events_category ON public.community_events(category); -CREATE INDEX IF NOT EXISTS idx_events_status ON public.community_events(status); -CREATE INDEX IF NOT EXISTS idx_events_location ON public.community_events(latitude, longitude); - --- ===================================================== --- 5. EVENT REGISTRATIONS TABLE (活动报名表) --- ===================================================== - -CREATE TABLE IF NOT EXISTS public.event_registrations ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - event_id UUID NOT NULL REFERENCES public.community_events(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - status TEXT DEFAULT 'registered' CHECK (status IN ('registered', 'attended', 'cancelled', 'no_show')), - registered_at TIMESTAMPTZ DEFAULT timezone('utc', now()), - attended_at TIMESTAMPTZ, -- 签到时间 - credits_earned INTEGER DEFAULT 0, -- 获得的积分 - UNIQUE(event_id, user_id) -- 每个用户每个活动只能报名一次 -); - --- 创建索引 -CREATE INDEX IF NOT EXISTS idx_registrations_event ON public.event_registrations(event_id); -CREATE INDEX IF NOT EXISTS idx_registrations_user ON public.event_registrations(user_id); -CREATE INDEX IF NOT EXISTS idx_registrations_status ON public.event_registrations(status); - --- ===================================================== --- 6. HELPER FUNCTIONS (辅助函数) --- ===================================================== - --- 6.1 计算两点之间的距离(公里)- Haversine 公式 -CREATE OR REPLACE FUNCTION public.calculate_distance_km( - lat1 DECIMAL, lon1 DECIMAL, - lat2 DECIMAL, lon2 DECIMAL -) RETURNS DECIMAL -LANGUAGE plpgsql IMMUTABLE -AS $$ -DECLARE - R CONSTANT DECIMAL := 6371; -- 地球半径(公里) - dlat DECIMAL; - dlon DECIMAL; - a DECIMAL; - c DECIMAL; -BEGIN - dlat := radians(lat2 - lat1); - dlon := radians(lon2 - lon1); - a := sin(dlat/2) * sin(dlat/2) + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon/2) * sin(dlon/2); - c := 2 * atan2(sqrt(a), sqrt(1-a)); - RETURN R * c; -END; -$$; - --- ===================================================== --- 7. COMMUNITY FUNCTIONS (社区函数) --- ===================================================== - --- 7.1 获取指定城市的社区列表 -CREATE OR REPLACE FUNCTION public.get_communities_by_city(p_city TEXT) -RETURNS TABLE ( - id TEXT, - name TEXT, - city TEXT, - state TEXT, - description TEXT, - member_count INTEGER, - latitude DECIMAL, - longitude DECIMAL, - is_member BOOLEAN -) -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -BEGIN - RETURN QUERY - SELECT - c.id, - c.name, - c.city, - c.state, - c.description, - c.member_count, - c.latitude, - c.longitude, - EXISTS ( - SELECT 1 FROM public.user_community_memberships m - WHERE m.community_id = c.id - AND m.user_id = auth.uid() - AND m.status = 'member' - ) as is_member - FROM public.communities c - WHERE c.city = p_city AND c.is_active = true - ORDER BY c.member_count DESC; -END; -$$; - --- 7.2 获取用户已加入的社区列表 -CREATE OR REPLACE FUNCTION public.get_my_communities() -RETURNS TABLE ( - id TEXT, - name TEXT, - city TEXT, - state TEXT, - description TEXT, - member_count INTEGER, - joined_at TIMESTAMPTZ -) -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -BEGIN - RETURN QUERY - SELECT - c.id, - c.name, - c.city, - c.state, - c.description, - c.member_count, - m.joined_at - FROM public.user_community_memberships m - JOIN public.communities c ON m.community_id = c.id - WHERE m.user_id = auth.uid() AND m.status = 'member' - ORDER BY m.joined_at DESC; -END; -$$; - --- 7.3 加入社区 -CREATE OR REPLACE FUNCTION public.join_community(p_community_id TEXT) -RETURNS JSON -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID := auth.uid(); - v_existing RECORD; -BEGIN - IF v_user_id IS NULL THEN - RETURN json_build_object('success', false, 'message', 'Not authenticated'); - END IF; - - -- 检查社区是否存在 - IF NOT EXISTS (SELECT 1 FROM public.communities WHERE id = p_community_id AND is_active = true) THEN - RETURN json_build_object('success', false, 'message', 'Community not found'); - END IF; - - -- 检查是否已加入 - SELECT * INTO v_existing FROM public.user_community_memberships - WHERE user_id = v_user_id AND community_id = p_community_id; - - IF FOUND THEN - IF v_existing.status = 'member' THEN - RETURN json_build_object('success', false, 'message', 'Already a member'); - ELSIF v_existing.status = 'banned' THEN - RETURN json_build_object('success', false, 'message', 'You are banned from this community'); - ELSE - -- 重新激活 - UPDATE public.user_community_memberships - SET status = 'member', joined_at = NOW() - WHERE id = v_existing.id; - END IF; - ELSE - -- 新加入 - INSERT INTO public.user_community_memberships (user_id, community_id, status) - VALUES (v_user_id, p_community_id, 'member'); - END IF; - - -- 更新社区成员数 - UPDATE public.communities - SET member_count = member_count + 1, updated_at = NOW() - WHERE id = p_community_id; - - RETURN json_build_object('success', true, 'message', 'Joined community successfully'); -END; -$$; - --- 7.4 离开社区 -CREATE OR REPLACE FUNCTION public.leave_community(p_community_id TEXT) -RETURNS JSON -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID := auth.uid(); -BEGIN - IF v_user_id IS NULL THEN - RETURN json_build_object('success', false, 'message', 'Not authenticated'); - END IF; - - -- 检查是否是成员 - IF NOT EXISTS ( - SELECT 1 FROM public.user_community_memberships - WHERE user_id = v_user_id AND community_id = p_community_id AND status = 'member' - ) THEN - RETURN json_build_object('success', false, 'message', 'Not a member of this community'); - END IF; - - -- 删除成员记录 - DELETE FROM public.user_community_memberships - WHERE user_id = v_user_id AND community_id = p_community_id; - - -- 更新社区成员数 - UPDATE public.communities - SET member_count = GREATEST(0, member_count - 1), updated_at = NOW() - WHERE id = p_community_id; - - RETURN json_build_object('success', true, 'message', 'Left community successfully'); -END; -$$; - --- ===================================================== --- 8. EVENT FUNCTIONS (活动函数) --- ===================================================== - --- 8.1 获取附近活动(基于位置和距离) -CREATE OR REPLACE FUNCTION public.get_nearby_events( - p_latitude DECIMAL, - p_longitude DECIMAL, - p_max_distance_km DECIMAL DEFAULT 50, - p_category TEXT DEFAULT NULL, - p_only_joined_communities BOOLEAN DEFAULT false, - p_sort_by TEXT DEFAULT 'date' -- 'date', 'distance', 'popularity' -) -RETURNS TABLE ( - id UUID, - title TEXT, - description TEXT, - organizer TEXT, - category TEXT, - event_date TIMESTAMPTZ, - location TEXT, - latitude DECIMAL, - longitude DECIMAL, - icon_name TEXT, - max_participants INTEGER, - participant_count INTEGER, - community_id TEXT, - community_name TEXT, - distance_km DECIMAL, - is_registered BOOLEAN -) -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -BEGIN - RETURN QUERY - SELECT - e.id, - e.title, - e.description, - e.organizer, - e.category, - e.event_date, - e.location, - e.latitude, - e.longitude, - e.icon_name, - e.max_participants, - e.participant_count, - e.community_id, - c.name as community_name, - public.calculate_distance_km(p_latitude, p_longitude, e.latitude, e.longitude) as distance_km, - EXISTS ( - SELECT 1 FROM public.event_registrations r - WHERE r.event_id = e.id AND r.user_id = auth.uid() AND r.status = 'registered' - ) as is_registered - FROM public.community_events e - JOIN public.communities c ON e.community_id = c.id - WHERE - e.status IN ('upcoming', 'ongoing') - AND e.event_date > NOW() - AND public.calculate_distance_km(p_latitude, p_longitude, e.latitude, e.longitude) <= p_max_distance_km - AND (p_category IS NULL OR e.category = p_category) - AND ( - NOT p_only_joined_communities - OR EXISTS ( - SELECT 1 FROM public.user_community_memberships m - WHERE m.community_id = e.community_id - AND m.user_id = auth.uid() - AND m.status = 'member' - ) - ) - ORDER BY - CASE WHEN p_sort_by = 'date' THEN e.event_date END ASC, - CASE WHEN p_sort_by = 'distance' THEN public.calculate_distance_km(p_latitude, p_longitude, e.latitude, e.longitude) END ASC, - CASE WHEN p_sort_by = 'popularity' THEN e.participant_count END DESC, - e.event_date ASC; -END; -$$; - --- 8.2 报名活动 -CREATE OR REPLACE FUNCTION public.register_for_event(p_event_id UUID) -RETURNS JSON -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID := auth.uid(); - v_event RECORD; - v_existing RECORD; -BEGIN - IF v_user_id IS NULL THEN - RETURN json_build_object('success', false, 'message', 'Not authenticated'); - END IF; - - -- 获取活动信息 - SELECT * INTO v_event FROM public.community_events WHERE id = p_event_id; - IF NOT FOUND THEN - RETURN json_build_object('success', false, 'message', 'Event not found'); - END IF; - - -- 检查活动状态 - IF v_event.status NOT IN ('upcoming', 'ongoing') THEN - RETURN json_build_object('success', false, 'message', 'Event is not open for registration'); - END IF; - - -- 检查名额 - IF v_event.participant_count >= v_event.max_participants THEN - RETURN json_build_object('success', false, 'message', 'Event is full'); - END IF; - - -- 检查是否已报名 - SELECT * INTO v_existing FROM public.event_registrations - WHERE event_id = p_event_id AND user_id = v_user_id; - - IF FOUND THEN - IF v_existing.status = 'registered' THEN - RETURN json_build_object('success', false, 'message', 'Already registered'); - ELSIF v_existing.status = 'cancelled' THEN - -- 重新报名 - UPDATE public.event_registrations - SET status = 'registered', registered_at = NOW() - WHERE id = v_existing.id; - ELSE - RETURN json_build_object('success', false, 'message', 'Cannot register for this event'); - END IF; - ELSE - -- 新报名 - INSERT INTO public.event_registrations (event_id, user_id) - VALUES (p_event_id, v_user_id); - END IF; - - -- 更新参与人数 - UPDATE public.community_events - SET participant_count = participant_count + 1, updated_at = NOW() - WHERE id = p_event_id; - - RETURN json_build_object('success', true, 'message', 'Registration successful'); -END; -$$; - --- 8.3 取消报名 -CREATE OR REPLACE FUNCTION public.cancel_event_registration(p_event_id UUID) -RETURNS JSON -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID := auth.uid(); - v_registration RECORD; -BEGIN - IF v_user_id IS NULL THEN - RETURN json_build_object('success', false, 'message', 'Not authenticated'); - END IF; - - SELECT * INTO v_registration - FROM public.event_registrations - WHERE event_id = p_event_id AND user_id = v_user_id AND status = 'registered'; - - IF NOT FOUND THEN - RETURN json_build_object('success', false, 'message', 'Registration not found'); - END IF; - - -- 取消报名 - UPDATE public.event_registrations - SET status = 'cancelled' - WHERE id = v_registration.id; - - -- 更新参与人数 - UPDATE public.community_events - SET participant_count = GREATEST(0, participant_count - 1), updated_at = NOW() - WHERE id = p_event_id; - - RETURN json_build_object('success', true, 'message', 'Registration cancelled'); -END; -$$; - --- 8.4 获取用户已报名的活动 -CREATE OR REPLACE FUNCTION public.get_my_registrations() -RETURNS TABLE ( - registration_id UUID, - event_id UUID, - event_title TEXT, - event_date TIMESTAMPTZ, - event_location TEXT, - event_category TEXT, - community_name TEXT, - registration_status TEXT, - registered_at TIMESTAMPTZ -) -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -BEGIN - RETURN QUERY - SELECT - r.id as registration_id, - e.id as event_id, - e.title as event_title, - e.event_date, - e.location as event_location, - e.category as event_category, - c.name as community_name, - r.status as registration_status, - r.registered_at - FROM public.event_registrations r - JOIN public.community_events e ON r.event_id = e.id - JOIN public.communities c ON e.community_id = c.id - WHERE r.user_id = auth.uid() - ORDER BY e.event_date DESC; -END; -$$; - --- ===================================================== --- 9. USER LOCATION FUNCTIONS (用户位置函数) --- ===================================================== - --- 9.1 更新用户位置 -CREATE OR REPLACE FUNCTION public.update_user_location( - p_city TEXT, - p_state TEXT, - p_latitude DECIMAL, - p_longitude DECIMAL -) -RETURNS JSON -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID := auth.uid(); -BEGIN - IF v_user_id IS NULL THEN - RETURN json_build_object('success', false, 'message', 'Not authenticated'); - END IF; - - UPDATE public.profiles - SET - location_city = p_city, - location_state = p_state, - location_latitude = p_latitude, - location_longitude = p_longitude - WHERE id = v_user_id; - - RETURN json_build_object('success', true, 'message', 'Location updated'); -END; -$$; - --- ===================================================== --- 10. ROW LEVEL SECURITY (RLS) --- ===================================================== - -ALTER TABLE public.communities ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.user_community_memberships ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.community_events ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.event_registrations ENABLE ROW LEVEL SECURITY; - --- Communities: 所有人可读 -DROP POLICY IF EXISTS "Communities are viewable by everyone" ON public.communities; -CREATE POLICY "Communities are viewable by everyone" -ON public.communities FOR SELECT USING (true); - --- Memberships: 用户可以看所有成员关系,但只能管理自己的 -DROP POLICY IF EXISTS "Users can view all memberships" ON public.user_community_memberships; -CREATE POLICY "Users can view all memberships" -ON public.user_community_memberships FOR SELECT USING (true); - -DROP POLICY IF EXISTS "Users can manage own memberships" ON public.user_community_memberships; -CREATE POLICY "Users can manage own memberships" -ON public.user_community_memberships FOR ALL USING (auth.uid() = user_id); - --- Events: 所有人可读 -DROP POLICY IF EXISTS "Events are viewable by everyone" ON public.community_events; -CREATE POLICY "Events are viewable by everyone" -ON public.community_events FOR SELECT USING (true); - --- Registrations: 用户只能看/改自己的报名 -DROP POLICY IF EXISTS "Users can view own registrations" ON public.event_registrations; -CREATE POLICY "Users can view own registrations" -ON public.event_registrations FOR SELECT USING (auth.uid() = user_id); - -DROP POLICY IF EXISTS "Users can manage own registrations" ON public.event_registrations; -CREATE POLICY "Users can manage own registrations" -ON public.event_registrations FOR ALL USING (auth.uid() = user_id); - --- ===================================================== --- 11. SEED DATA (初始数据) --- ===================================================== - --- 插入社区数据 -INSERT INTO public.communities (id, name, city, state, description, member_count, latitude, longitude) VALUES --- San Diego -('san-diego-green', 'San Diego Green Initiative', 'San Diego', 'CA', 'Leading environmental community in San Diego', 1250, 32.7157, -117.1611), -('san-diego-beach', 'SD Beach Cleanup Crew', 'San Diego', 'CA', 'Weekly beach cleanup events', 890, 32.7502, -117.2542), --- Los Angeles -('la-eco', 'LA Eco Warriors', 'Los Angeles', 'CA', 'Los Angeles eco-conscious community', 3420, 34.0522, -118.2437), -('la-recycle', 'LA Recycling Network', 'Los Angeles', 'CA', 'Promoting recycling across LA', 2100, 34.0195, -118.4912), --- San Francisco -('sf-green', 'SF Bay Recyclers', 'San Francisco', 'CA', 'Bay Area sustainability hub', 2180, 37.7749, -122.4194), -('sf-zero-waste', 'SF Zero Waste Coalition', 'San Francisco', 'CA', 'Working towards zero waste SF', 1560, 37.7849, -122.4094), --- Seattle -('seattle-sustain', 'Seattle Sustainability', 'Seattle', 'WA', 'Seattle environmental advocacy', 1890, 47.6062, -122.3321), --- Portland -('portland-eco', 'Portland Eco Community', 'Portland', 'OR', 'Portland green living community', 1560, 45.5152, -122.6784), --- Denver -('denver-green', 'Denver Green Team', 'Denver', 'CO', 'Colorado environmental initiative', 980, 39.7392, -104.9903), --- Austin -('austin-recycle', 'Austin Recyclers', 'Austin', 'TX', 'Austin waste reduction community', 1340, 30.2672, -97.7431), --- New York -('nyc-sustain', 'NYC Sustainability Hub', 'New York', 'NY', 'New York City environmental community', 5200, 40.7128, -74.0060), -('nyc-green', 'NYC Green Initiative', 'New York', 'NY', 'Making NYC greener one block at a time', 3800, 40.7580, -73.9855), --- Boston -('boston-eco', 'Boston Eco Alliance', 'Boston', 'MA', 'Boston area green initiative', 1780, 42.3601, -71.0589), --- Chicago -('chicago-green', 'Chicago Green Initiative', 'Chicago', 'IL', 'Chicago sustainability community', 2340, 41.8781, -87.6298) -ON CONFLICT (id) DO UPDATE SET - name = EXCLUDED.name, - description = EXCLUDED.description, - latitude = EXCLUDED.latitude, - longitude = EXCLUDED.longitude; - --- 插入示例活动 -INSERT INTO public.community_events (community_id, title, organizer, description, category, event_date, location, latitude, longitude, icon_name, max_participants, participant_count) VALUES --- San Diego -('san-diego-green', 'Mission Beach Cleanup', 'San Diego Green Initiative', 'Join us for a community beach cleanup at Mission Beach! Help protect marine life.', 'cleanup', NOW() + INTERVAL '3 days', 'Mission Beach, San Diego', 32.7702, -117.2528, 'water.waves', 100, 45), -('san-diego-green', 'SD Recycling Workshop', 'San Diego Green Initiative', 'Learn how to recycle properly and reduce waste.', 'workshop', NOW() + INTERVAL '5 days', 'Balboa Park Community Center', 32.7341, -117.1446, 'scissors', 30, 18), -('san-diego-beach', 'La Jolla Cove Cleanup', 'SD Beach Cleanup Crew', 'Weekly cleanup at beautiful La Jolla Cove!', 'cleanup', NOW() + INTERVAL '2 days', 'La Jolla Cove', 32.8502, -117.2711, 'water.waves', 50, 32), -('san-diego-green', 'UCSD Sorting Challenge', 'UCSD Green Team', 'Compete with teams to sort waste correctly!', 'competition', NOW() + INTERVAL '7 days', 'UCSD Campus', 32.8801, -117.2340, 'flag.checkered', 80, 64), --- Los Angeles -('la-eco', 'Santa Monica Beach Day', 'LA Eco Warriors', 'Help keep Santa Monica Beach clean and beautiful!', 'cleanup', NOW() + INTERVAL '4 days', 'Santa Monica Beach', 34.0195, -118.4912, 'water.waves', 150, 85), -('la-eco', 'Hollywood Zero Waste Talk', 'LA Eco Warriors', 'Learn practical tips for zero waste living.', 'education', NOW() + INTERVAL '10 days', 'LA Public Library', 34.0522, -118.2437, 'person.wave.2.fill', 60, 42), -('la-recycle', 'Venice Beach Recycling Drive', 'LA Recycling Network', 'Collect recyclables along Venice Beach!', 'cleanup', NOW() + INTERVAL '6 days', 'Venice Beach', 33.9850, -118.4695, 'arrow.3.trianglepath', 80, 55), --- San Francisco -('sf-green', 'Golden Gate Park Cleanup', 'SF Bay Recyclers', 'Restore native plants and clean up Golden Gate Park.', 'cleanup', NOW() + INTERVAL '6 days', 'Golden Gate Park', 37.7694, -122.4862, 'tree.fill', 70, 38), -('sf-green', 'Bay Area Eco Competition', 'SF Bay Recyclers', 'Annual eco competition with teams from Bay Area!', 'competition', NOW() + INTERVAL '14 days', 'SF Civic Center', 37.7793, -122.4193, 'trophy.fill', 200, 120), --- Seattle -('seattle-sustain', 'Puget Sound Beach Cleanup', 'Seattle Sustainability', 'Protect Puget Sound marine life with cleanup!', 'cleanup', NOW() + INTERVAL '8 days', 'Alki Beach, Seattle', 47.5763, -122.4095, 'water.waves', 100, 55), --- Portland -('portland-eco', 'Portland Composting Workshop', 'Portland Eco Community', 'Learn the art of composting for your garden!', 'workshop', NOW() + INTERVAL '9 days', 'Portland Community Garden', 45.5231, -122.6765, 'leaf.fill', 40, 22) -ON CONFLICT DO NOTHING; - --- ===================================================== --- 12. GRANTS (权限) --- ===================================================== - -GRANT SELECT ON public.communities TO anon, authenticated; -GRANT SELECT ON public.user_community_memberships TO anon, authenticated; -GRANT SELECT, INSERT, UPDATE, DELETE ON public.user_community_memberships TO authenticated; -GRANT SELECT ON public.community_events TO anon, authenticated; -GRANT SELECT, INSERT, UPDATE ON public.event_registrations TO authenticated; - -GRANT EXECUTE ON FUNCTION public.calculate_distance_km TO anon, authenticated; -GRANT EXECUTE ON FUNCTION public.get_communities_by_city TO anon, authenticated; -GRANT EXECUTE ON FUNCTION public.get_my_communities TO authenticated; -GRANT EXECUTE ON FUNCTION public.join_community TO authenticated; -GRANT EXECUTE ON FUNCTION public.leave_community TO authenticated; -GRANT EXECUTE ON FUNCTION public.get_nearby_events TO anon, authenticated; -GRANT EXECUTE ON FUNCTION public.register_for_event TO authenticated; -GRANT EXECUTE ON FUNCTION public.cancel_event_registration TO authenticated; -GRANT EXECUTE ON FUNCTION public.get_my_registrations TO authenticated; -GRANT EXECUTE ON FUNCTION public.update_user_location TO authenticated; diff --git a/The Trash/migrations/003_community_leaderboard.sql b/The Trash/migrations/003_community_leaderboard.sql deleted file mode 100644 index 241bd57..0000000 --- a/The Trash/migrations/003_community_leaderboard.sql +++ /dev/null @@ -1,61 +0,0 @@ --- ===================================================== --- 003_community_leaderboard.sql --- 社区排行榜功能迁移 --- Created: 2026-02-06 --- ===================================================== - --- ===================================================== --- 1. GET COMMUNITY LEADERBOARD RPC --- 获取指定社区的成员排行榜 (按 credits 排序) --- ===================================================== - -CREATE OR REPLACE FUNCTION public.get_community_leaderboard( - p_community_id TEXT, - p_limit INTEGER DEFAULT 100 -) -RETURNS TABLE ( - id UUID, - username TEXT, - credits INTEGER, - community_name TEXT -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_community_name TEXT; -BEGIN - -- 获取社区名称 - SELECT c.name INTO v_community_name - FROM public.communities c - WHERE c.id = p_community_id; - - -- 返回该社区成员的排行榜 - RETURN QUERY - SELECT - p.id, - COALESCE(p.username, 'Anonymous')::TEXT AS username, - COALESCE(p.credits, 0) AS credits, - v_community_name AS community_name - FROM public.profiles p - INNER JOIN public.user_community_memberships m - ON p.id = m.user_id - WHERE m.community_id = p_community_id - AND m.status IN ('member', 'admin') -- 只包含正式成员和管理员 - ORDER BY p.credits DESC NULLS LAST - LIMIT p_limit; -END; -$$; - --- 添加函数注释 -COMMENT ON FUNCTION public.get_community_leaderboard(TEXT, INTEGER) IS -'获取指定社区的成员排行榜,按积分降序排列'; - --- ===================================================== --- 2. GRANT PERMISSIONS --- ===================================================== - -GRANT EXECUTE ON FUNCTION public.get_community_leaderboard(TEXT, INTEGER) TO anon; -GRANT EXECUTE ON FUNCTION public.get_community_leaderboard(TEXT, INTEGER) TO authenticated; -GRANT EXECUTE ON FUNCTION public.get_community_leaderboard(TEXT, INTEGER) TO service_role; diff --git a/The Trash/migrations/004_user_created_content.sql b/The Trash/migrations/004_user_created_content.sql deleted file mode 100644 index 17d1239..0000000 --- a/The Trash/migrations/004_user_created_content.sql +++ /dev/null @@ -1,354 +0,0 @@ --- ===================================================== --- Migration: 004_user_created_content.sql --- Description: Support user-created communities and events with limits --- Author: Albert Huang --- Date: 2026-02-06 --- Features: --- - Users can create up to 3 communities --- - Users can create up to 7 events per week --- - Events can be hosted by communities or individuals --- ===================================================== - --- ===================================================== --- 1. ADD CREATOR FIELD TO COMMUNITIES --- ===================================================== - -ALTER TABLE public.communities -ADD COLUMN IF NOT EXISTS created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL; - --- Index for counting user's communities -CREATE INDEX IF NOT EXISTS idx_communities_created_by ON public.communities(created_by); - --- ===================================================== --- 2. ADD CREATOR AND INDIVIDUAL HOSTING TO EVENTS --- ===================================================== - --- Make community_id optional (NULL = personal event) -ALTER TABLE public.community_events -ALTER COLUMN community_id DROP NOT NULL; - --- Add creator field -ALTER TABLE public.community_events -ADD COLUMN IF NOT EXISTS created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL; - --- Add is_personal flag -ALTER TABLE public.community_events -ADD COLUMN IF NOT EXISTS is_personal BOOLEAN DEFAULT false; - --- Index for counting user's events -CREATE INDEX IF NOT EXISTS idx_events_created_by ON public.community_events(created_by); -CREATE INDEX IF NOT EXISTS idx_events_is_personal ON public.community_events(is_personal); - --- ===================================================== --- 3. FUNCTION: Check if user can create community (max 3) --- ===================================================== - -CREATE OR REPLACE FUNCTION public.can_user_create_community() -RETURNS json -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID := auth.uid(); - v_count INTEGER; - v_max_allowed INTEGER := 3; -BEGIN - IF v_user_id IS NULL THEN - RETURN json_build_object('allowed', false, 'reason', 'Not authenticated', 'current_count', 0, 'max_allowed', v_max_allowed); - END IF; - - SELECT COUNT(*) INTO v_count - FROM public.communities - WHERE created_by = v_user_id; - - IF v_count >= v_max_allowed THEN - RETURN json_build_object('allowed', false, 'reason', 'Maximum community limit reached', 'current_count', v_count, 'max_allowed', v_max_allowed); - END IF; - - RETURN json_build_object('allowed', true, 'reason', NULL, 'current_count', v_count, 'max_allowed', v_max_allowed); -END; -$$; - --- ===================================================== --- 4. FUNCTION: Check if user can create event (max 7/week) --- ===================================================== - -CREATE OR REPLACE FUNCTION public.can_user_create_event() -RETURNS json -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID := auth.uid(); - v_count INTEGER; - v_max_allowed INTEGER := 7; - v_week_start TIMESTAMPTZ; -BEGIN - IF v_user_id IS NULL THEN - RETURN json_build_object('allowed', false, 'reason', 'Not authenticated', 'current_count', 0, 'max_allowed', v_max_allowed); - END IF; - - -- Calculate start of current week (Monday) - v_week_start := date_trunc('week', NOW()); - - SELECT COUNT(*) INTO v_count - FROM public.community_events - WHERE created_by = v_user_id - AND created_at >= v_week_start; - - IF v_count >= v_max_allowed THEN - RETURN json_build_object('allowed', false, 'reason', 'Weekly event limit reached', 'current_count', v_count, 'max_allowed', v_max_allowed); - END IF; - - RETURN json_build_object('allowed', true, 'reason', NULL, 'current_count', v_count, 'max_allowed', v_max_allowed); -END; -$$; - --- ===================================================== --- 5. FUNCTION: Create community --- ===================================================== - -CREATE OR REPLACE FUNCTION public.create_community( - p_id TEXT, - p_name TEXT, - p_city TEXT, - p_state TEXT, - p_description TEXT DEFAULT NULL, - p_latitude DECIMAL DEFAULT NULL, - p_longitude DECIMAL DEFAULT NULL -) -RETURNS json -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID := auth.uid(); - v_can_create json; - v_community_id TEXT; -BEGIN - IF v_user_id IS NULL THEN - RETURN json_build_object('success', false, 'message', 'Not authenticated'); - END IF; - - -- Check limit - v_can_create := public.can_user_create_community(); - IF NOT (v_can_create->>'allowed')::boolean THEN - RETURN json_build_object('success', false, 'message', v_can_create->>'reason'); - END IF; - - -- Check if ID already exists - IF EXISTS (SELECT 1 FROM public.communities WHERE id = p_id) THEN - RETURN json_build_object('success', false, 'message', 'Community ID already exists'); - END IF; - - -- Create community - INSERT INTO public.communities (id, name, city, state, description, latitude, longitude, created_by, member_count) - VALUES (p_id, p_name, p_city, p_state, p_description, p_latitude, p_longitude, v_user_id, 1) - RETURNING id INTO v_community_id; - - -- Auto-join creator as admin - INSERT INTO public.user_community_memberships (user_id, community_id, status) - VALUES (v_user_id, v_community_id, 'admin'); - - RETURN json_build_object('success', true, 'message', 'Community created', 'community_id', v_community_id); -END; -$$; - --- ===================================================== --- 6. FUNCTION: Create event (community or personal) --- ===================================================== - -CREATE OR REPLACE FUNCTION public.create_event( - p_title TEXT, - p_description TEXT, - p_category TEXT, - p_event_date TIMESTAMPTZ, - p_location TEXT, - p_latitude DECIMAL, - p_longitude DECIMAL, - p_max_participants INTEGER DEFAULT 50, - p_community_id TEXT DEFAULT NULL, -- NULL for personal event - p_icon_name TEXT DEFAULT 'calendar' -) -RETURNS json -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID := auth.uid(); - v_can_create json; - v_event_id UUID; - v_organizer TEXT; - v_is_personal BOOLEAN; -BEGIN - IF v_user_id IS NULL THEN - RETURN json_build_object('success', false, 'message', 'Not authenticated'); - END IF; - - -- Check limit - v_can_create := public.can_user_create_event(); - IF NOT (v_can_create->>'allowed')::boolean THEN - RETURN json_build_object('success', false, 'message', v_can_create->>'reason'); - END IF; - - -- Determine if personal or community event - v_is_personal := (p_community_id IS NULL); - - -- Get organizer name - IF v_is_personal THEN - SELECT COALESCE(username, email, 'Anonymous') INTO v_organizer - FROM public.profiles - WHERE id = v_user_id; - ELSE - -- Check if user is member of the community - IF NOT EXISTS ( - SELECT 1 FROM public.user_community_memberships - WHERE user_id = v_user_id AND community_id = p_community_id AND status IN ('member', 'admin') - ) THEN - RETURN json_build_object('success', false, 'message', 'You must be a member of this community to create events'); - END IF; - - SELECT name INTO v_organizer - FROM public.communities - WHERE id = p_community_id; - END IF; - - -- Create event - INSERT INTO public.community_events ( - community_id, title, description, organizer, category, event_date, - location, latitude, longitude, max_participants, icon_name, - created_by, is_personal - ) - VALUES ( - p_community_id, p_title, p_description, v_organizer, p_category, p_event_date, - p_location, p_latitude, p_longitude, p_max_participants, p_icon_name, - v_user_id, v_is_personal - ) - RETURNING id INTO v_event_id; - - RETURN json_build_object('success', true, 'message', 'Event created', 'event_id', v_event_id); -END; -$$; - --- ===================================================== --- 7. UPDATE get_nearby_events TO INCLUDE PERSONAL EVENTS --- ===================================================== - -CREATE OR REPLACE FUNCTION public.get_nearby_events( - p_latitude DECIMAL, - p_longitude DECIMAL, - p_max_distance_km DECIMAL DEFAULT 50, - p_category TEXT DEFAULT NULL, - p_only_joined_communities BOOLEAN DEFAULT false, - p_sort_by TEXT DEFAULT 'date' -) -RETURNS TABLE ( - id UUID, - title TEXT, - description TEXT, - organizer TEXT, - category TEXT, - event_date TIMESTAMPTZ, - location TEXT, - latitude DECIMAL, - longitude DECIMAL, - icon_name TEXT, - max_participants INTEGER, - participant_count INTEGER, - community_id TEXT, - community_name TEXT, - distance_km DECIMAL, - is_registered BOOLEAN, - is_personal BOOLEAN -) -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID := auth.uid(); -BEGIN - RETURN QUERY - SELECT - e.id, - e.title, - e.description, - e.organizer, - e.category, - e.event_date, - e.location, - e.latitude, - e.longitude, - e.icon_name, - e.max_participants, - e.participant_count, - e.community_id, - c.name AS community_name, - public.calculate_distance_km(p_latitude, p_longitude, e.latitude, e.longitude) AS distance_km, - EXISTS ( - SELECT 1 FROM public.event_registrations r - WHERE r.event_id = e.id AND r.user_id = v_user_id AND r.status = 'registered' - ) AS is_registered, - COALESCE(e.is_personal, false) AS is_personal - FROM public.community_events e - LEFT JOIN public.communities c ON e.community_id = c.id - WHERE e.status = 'upcoming' - AND e.event_date >= NOW() - AND public.calculate_distance_km(p_latitude, p_longitude, e.latitude, e.longitude) <= p_max_distance_km - AND (p_category IS NULL OR e.category = p_category) - AND ( - NOT p_only_joined_communities - OR e.is_personal = true - OR EXISTS ( - SELECT 1 FROM public.user_community_memberships m - WHERE m.community_id = e.community_id AND m.user_id = v_user_id AND m.status IN ('member', 'admin') - ) - ) - ORDER BY - CASE WHEN p_sort_by = 'date' THEN e.event_date END ASC, - CASE WHEN p_sort_by = 'distance' THEN public.calculate_distance_km(p_latitude, p_longitude, e.latitude, e.longitude) END ASC, - CASE WHEN p_sort_by = 'popularity' THEN e.participant_count END DESC; -END; -$$; - --- ===================================================== --- 8. GRANT PERMISSIONS --- ===================================================== - -GRANT EXECUTE ON FUNCTION public.can_user_create_community() TO authenticated; -GRANT EXECUTE ON FUNCTION public.can_user_create_event() TO authenticated; -GRANT EXECUTE ON FUNCTION public.create_community(TEXT, TEXT, TEXT, TEXT, TEXT, DECIMAL, DECIMAL) TO authenticated; -GRANT EXECUTE ON FUNCTION public.create_event(TEXT, TEXT, TEXT, TIMESTAMPTZ, TEXT, DECIMAL, DECIMAL, INTEGER, TEXT, TEXT) TO authenticated; - --- ===================================================== --- 9. RLS POLICIES FOR NEW FIELDS --- ===================================================== - --- Allow users to see communities they created -CREATE POLICY IF NOT EXISTS "Users can view their created communities" -ON public.communities FOR SELECT -USING (true); - --- Allow users to update their own communities -CREATE POLICY IF NOT EXISTS "Users can update their own communities" -ON public.communities FOR UPDATE -USING (auth.uid() = created_by); - --- Allow authenticated users to create communities -CREATE POLICY IF NOT EXISTS "Authenticated users can create communities" -ON public.communities FOR INSERT -WITH CHECK (auth.uid() IS NOT NULL); - --- Allow users to see all events -CREATE POLICY IF NOT EXISTS "Users can view all events" -ON public.community_events FOR SELECT -USING (true); - --- Allow authenticated users to create events -CREATE POLICY IF NOT EXISTS "Authenticated users can create events" -ON public.community_events FOR INSERT -WITH CHECK (auth.uid() IS NOT NULL); - --- Allow users to update their own events -CREATE POLICY IF NOT EXISTS "Users can update their own events" -ON public.community_events FOR UPDATE -USING (auth.uid() = created_by); diff --git a/The Trash/migrations/005_admin_permissions.sql b/The Trash/migrations/005_admin_permissions.sql deleted file mode 100644 index 523582b..0000000 --- a/The Trash/migrations/005_admin_permissions.sql +++ /dev/null @@ -1,570 +0,0 @@ --- ===================================================== --- 005_admin_permissions.sql --- Community admin permissions: applications, member management, --- credit grants, audit logs --- ===================================================== - --- ===================================================== --- 1. Join Applications Table --- ===================================================== - -CREATE TABLE IF NOT EXISTS public.community_join_applications ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - community_id TEXT NOT NULL REFERENCES public.communities(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')), - message TEXT, - rejection_reason TEXT, - reviewed_by UUID REFERENCES auth.users(id), - reviewed_at TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT timezone('utc', now()), - updated_at TIMESTAMPTZ DEFAULT timezone('utc', now()), - - UNIQUE(community_id, user_id) -); - -CREATE INDEX IF NOT EXISTS idx_applications_community ON public.community_join_applications(community_id, status); -CREATE INDEX IF NOT EXISTS idx_applications_user ON public.community_join_applications(user_id); -CREATE INDEX IF NOT EXISTS idx_applications_status ON public.community_join_applications(status); - --- ===================================================== --- 2. Community Settings Columns --- ===================================================== - -ALTER TABLE public.communities -ADD COLUMN IF NOT EXISTS requires_approval BOOLEAN DEFAULT false, -ADD COLUMN IF NOT EXISTS welcome_message TEXT, -ADD COLUMN IF NOT EXISTS rules TEXT, -ADD COLUMN IF NOT EXISTS tags TEXT[], -ADD COLUMN IF NOT EXISTS is_private BOOLEAN DEFAULT false; - --- ===================================================== --- 3. Admin Action Logs Table --- ===================================================== - -CREATE TABLE IF NOT EXISTS public.admin_action_logs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - community_id TEXT NOT NULL REFERENCES public.communities(id) ON DELETE CASCADE, - admin_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - action_type TEXT NOT NULL CHECK (action_type IN ( - 'approve_member', 'reject_member', 'remove_member', 'grant_credits', - 'edit_community', 'edit_event', 'delete_event', 'pin_post', 'delete_post' - )), - target_user_id UUID REFERENCES auth.users(id), - target_event_id UUID, - details JSONB, - created_at TIMESTAMPTZ DEFAULT timezone('utc', now()) -); - -CREATE INDEX IF NOT EXISTS idx_admin_logs_community ON public.admin_action_logs(community_id, created_at DESC); -CREATE INDEX IF NOT EXISTS idx_admin_logs_admin ON public.admin_action_logs(admin_id); - --- ===================================================== --- 4. Credit Grants Table --- ===================================================== - -CREATE TABLE IF NOT EXISTS public.credit_grants ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - granted_by UUID NOT NULL REFERENCES auth.users(id), - community_id TEXT REFERENCES public.communities(id) ON DELETE SET NULL, - event_id UUID, - amount INTEGER NOT NULL CHECK (amount > 0), - reason TEXT NOT NULL, - created_at TIMESTAMPTZ DEFAULT timezone('utc', now()) -); - -CREATE INDEX IF NOT EXISTS idx_credit_grants_user ON public.credit_grants(user_id); -CREATE INDEX IF NOT EXISTS idx_credit_grants_community ON public.credit_grants(community_id); -CREATE INDEX IF NOT EXISTS idx_credit_grants_event ON public.credit_grants(event_id); - --- ===================================================== --- 5. RPC: Check if user is community admin --- ===================================================== - -CREATE OR REPLACE FUNCTION public.is_community_admin( - p_community_id TEXT, - p_user_id UUID DEFAULT auth.uid() -) -RETURNS BOOLEAN -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -BEGIN - RETURN EXISTS ( - SELECT 1 FROM public.user_community_memberships - WHERE community_id = p_community_id - AND user_id = p_user_id - AND status = 'admin' - ); -END; -$$; - --- ===================================================== --- 6. RPC: Apply to join community (with approval support) --- ===================================================== - -CREATE OR REPLACE FUNCTION public.apply_to_join_community( - p_community_id TEXT, - p_message TEXT DEFAULT NULL -) -RETURNS JSON -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID := auth.uid(); - v_requires_approval BOOLEAN; - v_community_name TEXT; -BEGIN - IF v_user_id IS NULL THEN - RETURN json_build_object('success', false, 'message', 'Not authenticated', 'requires_approval', false); - END IF; - - SELECT requires_approval, name INTO v_requires_approval, v_community_name - FROM public.communities - WHERE id = p_community_id AND is_active = true; - - IF NOT FOUND THEN - RETURN json_build_object('success', false, 'message', 'Community not found', 'requires_approval', false); - END IF; - - IF EXISTS ( - SELECT 1 FROM public.user_community_memberships - WHERE user_id = v_user_id AND community_id = p_community_id - ) THEN - RETURN json_build_object('success', false, 'message', 'Already a member', 'requires_approval', false); - END IF; - - -- If no approval needed, join directly - IF NOT COALESCE(v_requires_approval, false) THEN - INSERT INTO public.user_community_memberships (user_id, community_id, status) - VALUES (v_user_id, p_community_id, 'member'); - - UPDATE public.communities - SET member_count = member_count + 1, updated_at = NOW() - WHERE id = p_community_id; - - RETURN json_build_object( - 'success', true, - 'message', 'Joined successfully', - 'requires_approval', false - ); - END IF; - - -- Approval required: create application - INSERT INTO public.community_join_applications (community_id, user_id, message) - VALUES (p_community_id, v_user_id, p_message) - ON CONFLICT (community_id, user_id) - DO UPDATE SET - status = 'pending', - message = EXCLUDED.message, - updated_at = NOW(); - - RETURN json_build_object( - 'success', true, - 'message', 'Application submitted', - 'requires_approval', true - ); -END; -$$; - --- ===================================================== --- 7. RPC: Get pending applications (admin only) --- ===================================================== - -CREATE OR REPLACE FUNCTION public.get_pending_applications( - p_community_id TEXT -) -RETURNS TABLE ( - id UUID, - user_id UUID, - username TEXT, - user_credits INTEGER, - message TEXT, - created_at TIMESTAMPTZ -) -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -BEGIN - IF NOT public.is_community_admin(p_community_id, auth.uid()) THEN - RAISE EXCEPTION 'Permission denied'; - END IF; - - RETURN QUERY - SELECT - a.id, - a.user_id, - COALESCE(p.username, 'Anonymous')::TEXT AS username, - COALESCE(p.credits, 0) AS user_credits, - a.message, - a.created_at - FROM public.community_join_applications a - LEFT JOIN public.profiles p ON a.user_id = p.id - WHERE a.community_id = p_community_id - AND a.status = 'pending' - ORDER BY a.created_at; -END; -$$; - --- ===================================================== --- 8. RPC: Review join application (admin only) --- ===================================================== - -CREATE OR REPLACE FUNCTION public.review_join_application( - p_application_id UUID, - p_approve BOOLEAN, - p_rejection_reason TEXT DEFAULT NULL -) -RETURNS JSON -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -DECLARE - v_admin_id UUID := auth.uid(); - v_community_id TEXT; - v_user_id UUID; - v_username TEXT; -BEGIN - SELECT community_id, user_id INTO v_community_id, v_user_id - FROM public.community_join_applications - WHERE id = p_application_id AND status = 'pending'; - - IF NOT FOUND THEN - RETURN json_build_object('success', false, 'message', 'Application not found'); - END IF; - - IF NOT public.is_community_admin(v_community_id, v_admin_id) THEN - RETURN json_build_object('success', false, 'message', 'Permission denied'); - END IF; - - SELECT username INTO v_username FROM public.profiles WHERE id = v_user_id; - - IF p_approve THEN - UPDATE public.community_join_applications - SET status = 'approved', reviewed_by = v_admin_id, reviewed_at = NOW(), updated_at = NOW() - WHERE id = p_application_id; - - INSERT INTO public.user_community_memberships (user_id, community_id, status) - VALUES (v_user_id, v_community_id, 'member') - ON CONFLICT (user_id, community_id) DO NOTHING; - - UPDATE public.communities - SET member_count = member_count + 1, updated_at = NOW() - WHERE id = v_community_id; - - INSERT INTO public.admin_action_logs (community_id, admin_id, action_type, target_user_id, details) - VALUES (v_community_id, v_admin_id, 'approve_member', v_user_id, - json_build_object('username', v_username)); - - RETURN json_build_object('success', true, 'message', 'Application approved'); - ELSE - UPDATE public.community_join_applications - SET status = 'rejected', reviewed_by = v_admin_id, reviewed_at = NOW(), - rejection_reason = p_rejection_reason, updated_at = NOW() - WHERE id = p_application_id; - - INSERT INTO public.admin_action_logs (community_id, admin_id, action_type, target_user_id, details) - VALUES (v_community_id, v_admin_id, 'reject_member', v_user_id, - json_build_object('username', v_username, 'reason', p_rejection_reason)); - - RETURN json_build_object('success', true, 'message', 'Application rejected'); - END IF; -END; -$$; - --- ===================================================== --- 9. RPC: Update community info (admin only) --- ===================================================== - -CREATE OR REPLACE FUNCTION public.update_community_info( - p_community_id TEXT, - p_description TEXT DEFAULT NULL, - p_welcome_message TEXT DEFAULT NULL, - p_rules TEXT DEFAULT NULL, - p_requires_approval BOOLEAN DEFAULT NULL -) -RETURNS JSON -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -DECLARE - v_admin_id UUID := auth.uid(); -BEGIN - IF NOT public.is_community_admin(p_community_id, v_admin_id) THEN - RETURN json_build_object('success', false, 'message', 'Permission denied'); - END IF; - - UPDATE public.communities - SET - description = COALESCE(p_description, description), - welcome_message = COALESCE(p_welcome_message, welcome_message), - rules = COALESCE(p_rules, rules), - requires_approval = COALESCE(p_requires_approval, requires_approval), - updated_at = NOW() - WHERE id = p_community_id; - - INSERT INTO public.admin_action_logs (community_id, admin_id, action_type, details) - VALUES (p_community_id, v_admin_id, 'edit_community', - json_build_object('description', p_description, 'welcome_message', p_welcome_message, 'requires_approval', p_requires_approval)); - - RETURN json_build_object('success', true, 'message', 'Community updated'); -END; -$$; - --- ===================================================== --- 10. RPC: Remove community member (admin only) --- ===================================================== - -CREATE OR REPLACE FUNCTION public.remove_community_member( - p_community_id TEXT, - p_user_id UUID, - p_reason TEXT DEFAULT NULL -) -RETURNS JSON -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -DECLARE - v_admin_id UUID := auth.uid(); - v_username TEXT; -BEGIN - IF NOT public.is_community_admin(p_community_id, v_admin_id) THEN - RETURN json_build_object('success', false, 'message', 'Permission denied'); - END IF; - - IF public.is_community_admin(p_community_id, p_user_id) THEN - RETURN json_build_object('success', false, 'message', 'Cannot remove admin'); - END IF; - - SELECT username INTO v_username FROM public.profiles WHERE id = p_user_id; - - DELETE FROM public.user_community_memberships - WHERE community_id = p_community_id AND user_id = p_user_id; - - IF NOT FOUND THEN - RETURN json_build_object('success', false, 'message', 'User is not a member'); - END IF; - - UPDATE public.communities - SET member_count = GREATEST(0, member_count - 1), updated_at = NOW() - WHERE id = p_community_id; - - INSERT INTO public.admin_action_logs (community_id, admin_id, action_type, target_user_id, details) - VALUES (p_community_id, v_admin_id, 'remove_member', p_user_id, - json_build_object('username', v_username, 'reason', p_reason)); - - RETURN json_build_object('success', true, 'message', 'Member removed'); -END; -$$; - --- ===================================================== --- 11. RPC: Grant event credits (admin only) --- ===================================================== - -CREATE OR REPLACE FUNCTION public.grant_event_credits( - p_event_id UUID, - p_user_ids UUID[], - p_credits_per_user INTEGER, - p_reason TEXT -) -RETURNS JSON -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -DECLARE - v_admin_id UUID := auth.uid(); - v_community_id TEXT; - v_user_id UUID; - v_granted_count INTEGER := 0; -BEGIN - SELECT community_id INTO v_community_id - FROM public.community_events - WHERE id = p_event_id; - - IF NOT FOUND THEN - RETURN json_build_object('success', false, 'message', 'Event not found', 'granted_count', 0); - END IF; - - IF NOT ( - public.is_community_admin(v_community_id, v_admin_id) OR - EXISTS (SELECT 1 FROM public.community_events WHERE id = p_event_id AND created_by = v_admin_id) - ) THEN - RETURN json_build_object('success', false, 'message', 'Permission denied', 'granted_count', 0); - END IF; - - IF p_credits_per_user <= 0 OR p_credits_per_user > 1000 THEN - RETURN json_build_object('success', false, 'message', 'Invalid credit amount (must be 1-1000)', 'granted_count', 0); - END IF; - - FOREACH v_user_id IN ARRAY p_user_ids LOOP - IF EXISTS ( - SELECT 1 FROM public.event_registrations - WHERE event_id = p_event_id AND user_id = v_user_id - ) THEN - UPDATE public.profiles - SET credits = credits + p_credits_per_user - WHERE id = v_user_id; - - INSERT INTO public.credit_grants (user_id, granted_by, community_id, event_id, amount, reason) - VALUES (v_user_id, v_admin_id, v_community_id, p_event_id, p_credits_per_user, p_reason); - - v_granted_count := v_granted_count + 1; - END IF; - END LOOP; - - INSERT INTO public.admin_action_logs (community_id, admin_id, action_type, target_event_id, details) - VALUES (v_community_id, v_admin_id, 'grant_credits', p_event_id, - json_build_object('user_count', v_granted_count, 'credits_per_user', p_credits_per_user, - 'total_credits', v_granted_count * p_credits_per_user, 'reason', p_reason)); - - RETURN json_build_object('success', true, 'message', format('Credits granted to %s users', v_granted_count), 'granted_count', v_granted_count); -END; -$$; - --- ===================================================== --- 12. RPC: Get community members (admin view) --- ===================================================== - -CREATE OR REPLACE FUNCTION public.get_community_members_admin( - p_community_id TEXT -) -RETURNS TABLE ( - user_id UUID, - username TEXT, - credits INTEGER, - status TEXT, - joined_at TIMESTAMPTZ, - is_admin BOOLEAN -) -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -BEGIN - IF NOT public.is_community_admin(p_community_id, auth.uid()) THEN - RAISE EXCEPTION 'Permission denied'; - END IF; - - RETURN QUERY - SELECT - m.user_id, - COALESCE(p.username, 'Anonymous')::TEXT AS username, - COALESCE(p.credits, 0) AS credits, - m.status, - m.joined_at, - (m.status = 'admin') AS is_admin - FROM public.user_community_memberships m - LEFT JOIN public.profiles p ON m.user_id = p.id - WHERE m.community_id = p_community_id - AND m.status IN ('member', 'admin') - ORDER BY - CASE WHEN m.status = 'admin' THEN 0 ELSE 1 END, - m.joined_at; -END; -$$; - --- ===================================================== --- 13. RPC: Get admin action logs --- ===================================================== - -CREATE OR REPLACE FUNCTION public.get_admin_action_logs( - p_community_id TEXT, - p_limit INTEGER DEFAULT 50 -) -RETURNS TABLE ( - id UUID, - admin_username TEXT, - action_type TEXT, - target_username TEXT, - details JSONB, - created_at TIMESTAMPTZ -) -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -BEGIN - IF NOT public.is_community_admin(p_community_id, auth.uid()) THEN - RAISE EXCEPTION 'Permission denied'; - END IF; - - RETURN QUERY - SELECT - l.id, - COALESCE(admin_p.username, 'Unknown')::TEXT AS admin_username, - l.action_type, - COALESCE(target_p.username, NULL)::TEXT AS target_username, - l.details, - l.created_at - FROM public.admin_action_logs l - LEFT JOIN public.profiles admin_p ON l.admin_id = admin_p.id - LEFT JOIN public.profiles target_p ON l.target_user_id = target_p.id - WHERE l.community_id = p_community_id - ORDER BY l.created_at DESC - LIMIT p_limit; -END; -$$; - --- ===================================================== --- 14. RPC: Get event participants --- ===================================================== - -CREATE OR REPLACE FUNCTION public.get_event_participants(p_event_id UUID) -RETURNS TABLE ( - user_id UUID, - username TEXT, - credits INTEGER, - registered_at TIMESTAMPTZ -) -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -BEGIN - RETURN QUERY - SELECT - r.user_id, - COALESCE(p.username, 'Anonymous')::TEXT AS username, - COALESCE(p.credits, 0) AS credits, - r.registered_at - FROM public.event_registrations r - LEFT JOIN public.profiles p ON r.user_id = p.id - WHERE r.event_id = p_event_id - ORDER BY r.registered_at; -END; -$$; - --- ===================================================== --- 15. GRANT PERMISSIONS --- ===================================================== - -GRANT EXECUTE ON FUNCTION public.is_community_admin(TEXT, UUID) TO authenticated; -GRANT EXECUTE ON FUNCTION public.apply_to_join_community(TEXT, TEXT) TO authenticated; -GRANT EXECUTE ON FUNCTION public.get_pending_applications(TEXT) TO authenticated; -GRANT EXECUTE ON FUNCTION public.review_join_application(UUID, BOOLEAN, TEXT) TO authenticated; -GRANT EXECUTE ON FUNCTION public.update_community_info(TEXT, TEXT, TEXT, TEXT, BOOLEAN) TO authenticated; -GRANT EXECUTE ON FUNCTION public.remove_community_member(TEXT, UUID, TEXT) TO authenticated; -GRANT EXECUTE ON FUNCTION public.grant_event_credits(UUID, UUID[], INTEGER, TEXT) TO authenticated; -GRANT EXECUTE ON FUNCTION public.get_community_members_admin(TEXT) TO authenticated; -GRANT EXECUTE ON FUNCTION public.get_admin_action_logs(TEXT, INTEGER) TO authenticated; -GRANT EXECUTE ON FUNCTION public.get_event_participants(UUID) TO authenticated; - --- ===================================================== --- 16. RLS POLICIES --- ===================================================== - -ALTER TABLE public.community_join_applications ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "Users can view own applications" ON public.community_join_applications; -CREATE POLICY "Users can view own applications" -ON public.community_join_applications FOR SELECT -USING ( - auth.uid() = user_id OR - public.is_community_admin(community_id, auth.uid()) -); - -ALTER TABLE public.admin_action_logs ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "Admins can view action logs" ON public.admin_action_logs; -CREATE POLICY "Admins can view action logs" -ON public.admin_action_logs FOR SELECT -USING (public.is_community_admin(community_id, auth.uid())); - -ALTER TABLE public.credit_grants ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "Users can view own credit grants" ON public.credit_grants; -CREATE POLICY "Users can view own credit grants" -ON public.credit_grants FOR SELECT -USING ( - auth.uid() = user_id OR - (community_id IS NOT NULL AND public.is_community_admin(community_id, auth.uid())) -); diff --git a/The Trash/migrations/006_achievements_system.sql b/The Trash/migrations/006_achievements_system.sql deleted file mode 100644 index 2c00968..0000000 --- a/The Trash/migrations/006_achievements_system.sql +++ /dev/null @@ -1,137 +0,0 @@ --- 006_achievements_system.sql --- New tables for Achievement System --- Fixed: correct table names (profiles, user_community_memberships) and types (community_id TEXT) - --- 1. Create achievements table -CREATE TABLE IF NOT EXISTS public.achievements ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - community_id TEXT REFERENCES public.communities(id) ON DELETE CASCADE, -- NULL means official achievement - name TEXT NOT NULL, - description TEXT, - icon_name TEXT NOT NULL, -- SF Symbol name - created_by UUID REFERENCES auth.users(id), - created_at TIMESTAMPTZ DEFAULT NOW(), - points INT DEFAULT 0, -- Achievement point value (optional) - is_hidden BOOLEAN DEFAULT FALSE, - rarity TEXT DEFAULT 'common' CHECK (rarity IN ('common', 'rare', 'epic', 'legendary')), - trigger_key TEXT UNIQUE -); - --- 2. Create user_achievements table (Many-to-Many) -CREATE TABLE IF NOT EXISTS public.user_achievements ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, - achievement_id UUID REFERENCES public.achievements(id) ON DELETE CASCADE, - community_id TEXT REFERENCES public.communities(id) ON DELETE SET NULL, - granted_at TIMESTAMPTZ DEFAULT NOW(), - granted_by UUID REFERENCES auth.users(id), - UNIQUE(user_id, achievement_id) -); - --- 3. Add selected_achievement_id and total_scans to profiles -ALTER TABLE public.profiles -ADD COLUMN IF NOT EXISTS selected_achievement_id UUID REFERENCES public.achievements(id) ON DELETE SET NULL; - -ALTER TABLE public.profiles -ADD COLUMN IF NOT EXISTS total_scans INT DEFAULT 0; - --- 4. Enable RLS -ALTER TABLE public.achievements ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.user_achievements ENABLE ROW LEVEL SECURITY; - --- 5. RLS Policies - -CREATE POLICY "Allow public read on achievements" ON public.achievements - FOR SELECT USING (true); - -CREATE POLICY "Allow admins to create community achievements" ON public.achievements - FOR INSERT WITH CHECK ( - community_id IS NOT NULL AND - EXISTS ( - SELECT 1 FROM public.user_community_memberships - WHERE community_id = public.achievements.community_id - AND user_id = auth.uid() - AND status = 'admin' - ) - ); - -CREATE POLICY "Allow admins to update community achievements" ON public.achievements - FOR UPDATE USING ( - community_id IS NOT NULL AND - EXISTS ( - SELECT 1 FROM public.user_community_memberships - WHERE community_id = public.achievements.community_id - AND user_id = auth.uid() - AND status = 'admin' - ) - ); - -CREATE POLICY "Allow users to read achievements" ON public.user_achievements - FOR SELECT USING (true); - -CREATE POLICY "Allow admins to grant achievements" ON public.user_achievements - FOR INSERT WITH CHECK ( - EXISTS ( - SELECT 1 FROM public.achievements a - JOIN public.user_community_memberships cm ON a.community_id = cm.community_id - WHERE a.id = achievement_id - AND cm.user_id = auth.uid() - AND cm.status = 'admin' - ) OR - (SELECT community_id FROM public.achievements WHERE id = achievement_id) IS NULL - ); - --- 6. Functions - --- Function to equip an achievement -CREATE OR REPLACE FUNCTION set_primary_achievement(achievement_id UUID) -RETURNS VOID AS $$ -BEGIN - IF achievement_id IS NOT NULL AND NOT EXISTS ( - SELECT 1 FROM public.user_achievements ua - WHERE ua.user_id = auth.uid() AND ua.achievement_id = set_primary_achievement.achievement_id - ) THEN - RAISE EXCEPTION 'User does not own this achievement'; - END IF; - - UPDATE public.profiles - SET selected_achievement_id = set_primary_achievement.achievement_id - WHERE id = auth.uid(); -END; -$$ LANGUAGE plpgsql SECURITY DEFINER; - --- Function to fetch my achievements with details -CREATE OR REPLACE FUNCTION get_my_achievements() -RETURNS TABLE ( - user_achievement_id UUID, - achievement_id UUID, - name TEXT, - description TEXT, - icon_name TEXT, - community_id TEXT, - community_name TEXT, - granted_at TIMESTAMPTZ, - is_equipped BOOLEAN, - rarity TEXT -) AS $$ -BEGIN - RETURN QUERY - SELECT - ua.id, - a.id, - a.name, - a.description, - a.icon_name, - a.community_id, - c.name, - ua.granted_at, - (p.selected_achievement_id = a.id), - a.rarity - FROM public.user_achievements ua - JOIN public.achievements a ON ua.achievement_id = a.id - LEFT JOIN public.communities c ON a.community_id = c.id - LEFT JOIN public.profiles p ON ua.user_id = p.id - WHERE ua.user_id = auth.uid() - ORDER BY ua.granted_at DESC; -END; -$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/The Trash/migrations/007_update_leaderboard_rpc.sql b/The Trash/migrations/007_update_leaderboard_rpc.sql deleted file mode 100644 index c62b21b..0000000 --- a/The Trash/migrations/007_update_leaderboard_rpc.sql +++ /dev/null @@ -1,34 +0,0 @@ --- 007_update_leaderboard_rpc.sql --- Update get_community_leaderboard to include achievement icon --- Fixed: correct table names (profiles, user_community_memberships) and types (p_community_id TEXT) - -CREATE OR REPLACE FUNCTION public.get_community_leaderboard( - p_community_id TEXT, - p_limit INTEGER DEFAULT 100 -) -RETURNS TABLE ( - id UUID, - username TEXT, - credits INT, - community_name TEXT, - achievement_icon TEXT -) -AS $$ -BEGIN - RETURN QUERY - SELECT - p.id, - COALESCE(p.username, 'Anonymous'), - COALESCE(p.credits, 0), - c.name, - a.icon_name - FROM public.user_community_memberships cm - JOIN public.profiles p ON cm.user_id = p.id - JOIN public.communities c ON cm.community_id = c.id - LEFT JOIN public.achievements a ON p.selected_achievement_id = a.id - WHERE cm.community_id = p_community_id - AND cm.status IN ('member', 'admin') - ORDER BY p.credits DESC - LIMIT p_limit; -END; -$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/The Trash/migrations/008_achievement_system_enhance.sql b/The Trash/migrations/008_achievement_system_enhance.sql deleted file mode 100644 index c607372..0000000 --- a/The Trash/migrations/008_achievement_system_enhance.sql +++ /dev/null @@ -1,143 +0,0 @@ --- 008_achievement_system_enhance.sql --- Enhance achievement system: rarity, trigger keys, auto-grant, member picker - --- 1. Add rarity and trigger_key to achievements -ALTER TABLE public.achievements -ADD COLUMN IF NOT EXISTS rarity TEXT DEFAULT 'common' CHECK (rarity IN ('common', 'rare', 'epic', 'legendary')); - -ALTER TABLE public.achievements -ADD COLUMN IF NOT EXISTS trigger_key TEXT UNIQUE; - --- 2. Add total_scans to profiles for scan-count triggers -ALTER TABLE public.profiles -ADD COLUMN IF NOT EXISTS total_scans INT DEFAULT 0; - --- 3. Seed official system achievements (community_id = NULL means official) -INSERT INTO public.achievements (id, name, description, icon_name, community_id, rarity, trigger_key, is_hidden) -VALUES - ('a0000001-0000-0000-0000-000000000001', 'First Steps', 'Complete your first trash scan', 'leaf.arrow.circlepath', NULL, 'common', 'first_scan', false), - ('a0000001-0000-0000-0000-000000000002', 'Green Guardian', 'Earn 100 credits', 'shield.lefthalf.filled', NULL, 'common', 'credits_100', false), - ('a0000001-0000-0000-0000-000000000003', 'Eco Warrior', 'Earn 500 credits', 'bolt.shield.fill', NULL, 'rare', 'credits_500', false), - ('a0000001-0000-0000-0000-000000000004', 'Planet Savior', 'Earn 2000 credits', 'globe.americas.fill', NULL, 'epic', 'credits_2000', false), - ('a0000001-0000-0000-0000-000000000005', 'Trash Detective', 'Scan 10 items', 'magnifyingglass', NULL, 'common', 'scans_10', false), - ('a0000001-0000-0000-0000-000000000006', 'Sorting Master', 'Scan 50 items', 'archivebox.fill', NULL, 'rare', 'scans_50', false), - ('a0000001-0000-0000-0000-000000000007', 'Community Member', 'Join your first community', 'person.3.fill', NULL, 'common', 'join_community', false), - ('a0000001-0000-0000-0000-000000000008', 'Arena Champion', 'Win a 1v1 duel', 'trophy.fill', NULL, 'rare', 'arena_win', false) -ON CONFLICT (id) DO NOTHING; - --- 4. RPC: Check and auto-grant achievement by trigger key -CREATE OR REPLACE FUNCTION public.check_and_grant_achievement(p_trigger_key TEXT) -RETURNS JSON AS $$ -DECLARE - v_user_id UUID := auth.uid(); - v_achievement RECORD; - v_profile RECORD; - v_already_has BOOLEAN; - v_qualifies BOOLEAN := false; -BEGIN - IF v_user_id IS NULL THEN - RETURN json_build_object('granted', false, 'reason', 'Not authenticated'); - END IF; - - -- Find the achievement by trigger key - SELECT * INTO v_achievement FROM public.achievements - WHERE trigger_key = p_trigger_key AND community_id IS NULL; - - IF NOT FOUND THEN - RETURN json_build_object('granted', false, 'reason', 'Achievement not found'); - END IF; - - -- Check if already earned - SELECT EXISTS ( - SELECT 1 FROM public.user_achievements - WHERE user_id = v_user_id AND achievement_id = v_achievement.id - ) INTO v_already_has; - - IF v_already_has THEN - RETURN json_build_object('granted', false, 'reason', 'Already earned'); - END IF; - - -- Get user profile - SELECT * INTO v_profile FROM public.profiles WHERE id = v_user_id; - - -- Check qualification based on trigger key - CASE p_trigger_key - WHEN 'first_scan' THEN - v_qualifies := COALESCE(v_profile.total_scans, 0) >= 1; - WHEN 'scans_10' THEN - v_qualifies := COALESCE(v_profile.total_scans, 0) >= 10; - WHEN 'scans_50' THEN - v_qualifies := COALESCE(v_profile.total_scans, 0) >= 50; - WHEN 'credits_100' THEN - v_qualifies := COALESCE(v_profile.credits, 0) >= 100; - WHEN 'credits_500' THEN - v_qualifies := COALESCE(v_profile.credits, 0) >= 500; - WHEN 'credits_2000' THEN - v_qualifies := COALESCE(v_profile.credits, 0) >= 2000; - WHEN 'join_community' THEN - v_qualifies := EXISTS ( - SELECT 1 FROM public.user_community_memberships - WHERE user_id = v_user_id AND status IN ('member', 'admin') - ); - WHEN 'arena_win' THEN - -- Arena win is granted directly from the duel completion flow - v_qualifies := true; - ELSE - v_qualifies := false; - END CASE; - - IF NOT v_qualifies THEN - RETURN json_build_object('granted', false, 'reason', 'Not qualified'); - END IF; - - -- Grant the achievement - INSERT INTO public.user_achievements (user_id, achievement_id) - VALUES (v_user_id, v_achievement.id); - - RETURN json_build_object( - 'granted', true, - 'achievement_id', v_achievement.id, - 'name', v_achievement.name, - 'description', v_achievement.description, - 'icon_name', v_achievement.icon_name, - 'rarity', v_achievement.rarity - ); -END; -$$ LANGUAGE plpgsql SECURITY DEFINER; - --- 5. RPC: Get community members with achievement ownership status (for admin grant UI) -CREATE OR REPLACE FUNCTION public.get_community_members_for_grant( - p_community_id TEXT, - p_achievement_id UUID -) -RETURNS TABLE ( - user_id UUID, - username TEXT, - already_has BOOLEAN -) AS $$ -BEGIN - RETURN QUERY - SELECT - m.user_id, - COALESCE(p.username, 'Anonymous')::TEXT, - EXISTS ( - SELECT 1 FROM public.user_achievements ua - WHERE ua.user_id = m.user_id AND ua.achievement_id = p_achievement_id - ) - FROM public.user_community_memberships m - JOIN public.profiles p ON m.user_id = p.id - WHERE m.community_id = p_community_id - AND m.status IN ('member', 'admin') - ORDER BY p.username ASC; -END; -$$ LANGUAGE plpgsql SECURITY DEFINER; - --- 6. RPC: Increment total_scans (called after each successful scan) -CREATE OR REPLACE FUNCTION public.increment_total_scans() -RETURNS VOID AS $$ -BEGIN - UPDATE public.profiles - SET total_scans = COALESCE(total_scans, 0) + 1 - WHERE id = auth.uid(); -END; -$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/The Trash/migrations/20260206200000_user_created_content.sql b/The Trash/migrations/20260206200000_user_created_content.sql deleted file mode 100644 index 0b7c48a..0000000 --- a/The Trash/migrations/20260206200000_user_created_content.sql +++ /dev/null @@ -1,363 +0,0 @@ --- ===================================================== --- Migration: 004_user_created_content.sql --- Description: Support user-created communities and events with limits --- Author: Albert Huang --- Date: 2026-02-06 --- Features: --- - Users can create up to 3 communities --- - Users can create up to 7 events per week --- - Events can be hosted by communities or individuals --- ===================================================== - --- ===================================================== --- 1. ADD CREATOR FIELD TO COMMUNITIES --- ===================================================== - -ALTER TABLE public.communities -ADD COLUMN IF NOT EXISTS created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL; - --- Index for counting user's communities -CREATE INDEX IF NOT EXISTS idx_communities_created_by ON public.communities(created_by); - --- ===================================================== --- 2. ADD CREATOR AND INDIVIDUAL HOSTING TO EVENTS --- ===================================================== - --- Make community_id optional (NULL = personal event) -ALTER TABLE public.community_events -ALTER COLUMN community_id DROP NOT NULL; - --- Add creator field -ALTER TABLE public.community_events -ADD COLUMN IF NOT EXISTS created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL; - --- Add is_personal flag -ALTER TABLE public.community_events -ADD COLUMN IF NOT EXISTS is_personal BOOLEAN DEFAULT false; - --- Index for counting user's events -CREATE INDEX IF NOT EXISTS idx_events_created_by ON public.community_events(created_by); -CREATE INDEX IF NOT EXISTS idx_events_is_personal ON public.community_events(is_personal); - --- ===================================================== --- 3. FUNCTION: Check if user can create community (max 3) --- ===================================================== - -CREATE OR REPLACE FUNCTION public.can_user_create_community() -RETURNS json -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID := auth.uid(); - v_count INTEGER; - v_max_allowed INTEGER := 3; -BEGIN - IF v_user_id IS NULL THEN - RETURN json_build_object('allowed', false, 'reason', 'Not authenticated', 'current_count', 0, 'max_allowed', v_max_allowed); - END IF; - - SELECT COUNT(*) INTO v_count - FROM public.communities - WHERE created_by = v_user_id; - - IF v_count >= v_max_allowed THEN - RETURN json_build_object('allowed', false, 'reason', 'Maximum community limit reached', 'current_count', v_count, 'max_allowed', v_max_allowed); - END IF; - - RETURN json_build_object('allowed', true, 'reason', NULL, 'current_count', v_count, 'max_allowed', v_max_allowed); -END; -$$; - --- ===================================================== --- 4. FUNCTION: Check if user can create event (max 7/week) --- ===================================================== - -CREATE OR REPLACE FUNCTION public.can_user_create_event() -RETURNS json -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID := auth.uid(); - v_count INTEGER; - v_max_allowed INTEGER := 7; - v_week_start TIMESTAMPTZ; -BEGIN - IF v_user_id IS NULL THEN - RETURN json_build_object('allowed', false, 'reason', 'Not authenticated', 'current_count', 0, 'max_allowed', v_max_allowed); - END IF; - - -- Calculate start of current week (Monday) - v_week_start := date_trunc('week', NOW()); - - SELECT COUNT(*) INTO v_count - FROM public.community_events - WHERE created_by = v_user_id - AND created_at >= v_week_start; - - IF v_count >= v_max_allowed THEN - RETURN json_build_object('allowed', false, 'reason', 'Weekly event limit reached', 'current_count', v_count, 'max_allowed', v_max_allowed); - END IF; - - RETURN json_build_object('allowed', true, 'reason', NULL, 'current_count', v_count, 'max_allowed', v_max_allowed); -END; -$$; - --- ===================================================== --- 5. FUNCTION: Create community --- ===================================================== - -CREATE OR REPLACE FUNCTION public.create_community( - p_id TEXT, - p_name TEXT, - p_city TEXT, - p_state TEXT, - p_description TEXT DEFAULT NULL, - p_latitude DECIMAL DEFAULT NULL, - p_longitude DECIMAL DEFAULT NULL -) -RETURNS json -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID := auth.uid(); - v_can_create json; - v_community_id TEXT; -BEGIN - IF v_user_id IS NULL THEN - RETURN json_build_object('success', false, 'message', 'Not authenticated'); - END IF; - - -- Check limit - v_can_create := public.can_user_create_community(); - IF NOT (v_can_create->>'allowed')::boolean THEN - RETURN json_build_object('success', false, 'message', v_can_create->>'reason'); - END IF; - - -- Check if ID already exists - IF EXISTS (SELECT 1 FROM public.communities WHERE id = p_id) THEN - RETURN json_build_object('success', false, 'message', 'Community ID already exists'); - END IF; - - -- Create community - INSERT INTO public.communities (id, name, city, state, description, latitude, longitude, created_by, member_count) - VALUES (p_id, p_name, p_city, p_state, p_description, p_latitude, p_longitude, v_user_id, 1) - RETURNING id INTO v_community_id; - - -- Auto-join creator as admin - INSERT INTO public.user_community_memberships (user_id, community_id, status) - VALUES (v_user_id, v_community_id, 'admin'); - - RETURN json_build_object('success', true, 'message', 'Community created', 'community_id', v_community_id); -END; -$$; - --- ===================================================== --- 6. FUNCTION: Create event (community or personal) --- ===================================================== - -CREATE OR REPLACE FUNCTION public.create_event( - p_title TEXT, - p_description TEXT, - p_category TEXT, - p_event_date TIMESTAMPTZ, - p_location TEXT, - p_latitude DECIMAL, - p_longitude DECIMAL, - p_max_participants INTEGER DEFAULT 50, - p_community_id TEXT DEFAULT NULL, -- NULL for personal event - p_icon_name TEXT DEFAULT 'calendar' -) -RETURNS json -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID := auth.uid(); - v_can_create json; - v_event_id UUID; - v_organizer TEXT; - v_is_personal BOOLEAN; -BEGIN - IF v_user_id IS NULL THEN - RETURN json_build_object('success', false, 'message', 'Not authenticated'); - END IF; - - -- Check limit - v_can_create := public.can_user_create_event(); - IF NOT (v_can_create->>'allowed')::boolean THEN - RETURN json_build_object('success', false, 'message', v_can_create->>'reason'); - END IF; - - -- Determine if personal or community event - v_is_personal := (p_community_id IS NULL); - - -- Get organizer name - IF v_is_personal THEN - SELECT COALESCE(username, email, 'Anonymous') INTO v_organizer - FROM public.profiles - WHERE id = v_user_id; - ELSE - -- Check if user is member of the community - IF NOT EXISTS ( - SELECT 1 FROM public.user_community_memberships - WHERE user_id = v_user_id AND community_id = p_community_id AND status IN ('member', 'admin') - ) THEN - RETURN json_build_object('success', false, 'message', 'You must be a member of this community to create events'); - END IF; - - SELECT name INTO v_organizer - FROM public.communities - WHERE id = p_community_id; - END IF; - - -- Create event - INSERT INTO public.community_events ( - community_id, title, description, organizer, category, event_date, - location, latitude, longitude, max_participants, icon_name, - created_by, is_personal - ) - VALUES ( - p_community_id, p_title, p_description, v_organizer, p_category, p_event_date, - p_location, p_latitude, p_longitude, p_max_participants, p_icon_name, - v_user_id, v_is_personal - ) - RETURNING id INTO v_event_id; - - RETURN json_build_object('success', true, 'message', 'Event created', 'event_id', v_event_id); -END; -$$; - --- ===================================================== --- 7. UPDATE get_nearby_events TO INCLUDE PERSONAL EVENTS --- ===================================================== - --- Drop existing function first (return type changed) -DROP FUNCTION IF EXISTS public.get_nearby_events(DECIMAL, DECIMAL, DECIMAL, TEXT, BOOLEAN, TEXT); - -CREATE OR REPLACE FUNCTION public.get_nearby_events( - p_latitude DECIMAL, - p_longitude DECIMAL, - p_max_distance_km DECIMAL DEFAULT 50, - p_category TEXT DEFAULT NULL, - p_only_joined_communities BOOLEAN DEFAULT false, - p_sort_by TEXT DEFAULT 'date' -) -RETURNS TABLE ( - id UUID, - title TEXT, - description TEXT, - organizer TEXT, - category TEXT, - event_date TIMESTAMPTZ, - location TEXT, - latitude DECIMAL, - longitude DECIMAL, - icon_name TEXT, - max_participants INTEGER, - participant_count INTEGER, - community_id TEXT, - community_name TEXT, - distance_km DECIMAL, - is_registered BOOLEAN, - is_personal BOOLEAN -) -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID := auth.uid(); -BEGIN - RETURN QUERY - SELECT - e.id, - e.title, - e.description, - e.organizer, - e.category, - e.event_date, - e.location, - e.latitude, - e.longitude, - e.icon_name, - e.max_participants, - e.participant_count, - e.community_id, - c.name AS community_name, - public.calculate_distance_km(p_latitude, p_longitude, e.latitude, e.longitude) AS distance_km, - EXISTS ( - SELECT 1 FROM public.event_registrations r - WHERE r.event_id = e.id AND r.user_id = v_user_id AND r.status = 'registered' - ) AS is_registered, - COALESCE(e.is_personal, false) AS is_personal - FROM public.community_events e - LEFT JOIN public.communities c ON e.community_id = c.id - WHERE e.status = 'upcoming' - AND e.event_date >= NOW() - AND public.calculate_distance_km(p_latitude, p_longitude, e.latitude, e.longitude) <= p_max_distance_km - AND (p_category IS NULL OR e.category = p_category) - AND ( - NOT p_only_joined_communities - OR e.is_personal = true - OR EXISTS ( - SELECT 1 FROM public.user_community_memberships m - WHERE m.community_id = e.community_id AND m.user_id = v_user_id AND m.status IN ('member', 'admin') - ) - ) - ORDER BY - CASE WHEN p_sort_by = 'date' THEN e.event_date END ASC, - CASE WHEN p_sort_by = 'distance' THEN public.calculate_distance_km(p_latitude, p_longitude, e.latitude, e.longitude) END ASC, - CASE WHEN p_sort_by = 'popularity' THEN e.participant_count END DESC; -END; -$$; - --- ===================================================== --- 8. GRANT PERMISSIONS --- ===================================================== - -GRANT EXECUTE ON FUNCTION public.can_user_create_community() TO authenticated; -GRANT EXECUTE ON FUNCTION public.can_user_create_event() TO authenticated; -GRANT EXECUTE ON FUNCTION public.create_community(TEXT, TEXT, TEXT, TEXT, TEXT, DECIMAL, DECIMAL) TO authenticated; -GRANT EXECUTE ON FUNCTION public.create_event(TEXT, TEXT, TEXT, TIMESTAMPTZ, TEXT, DECIMAL, DECIMAL, INTEGER, TEXT, TEXT) TO authenticated; - --- ===================================================== --- 9. RLS POLICIES FOR NEW FIELDS --- ===================================================== - --- Allow users to see communities they created -DROP POLICY IF EXISTS "Users can view their created communities" ON public.communities; -CREATE POLICY "Users can view their created communities" -ON public.communities FOR SELECT -USING (true); - --- Allow users to update their own communities -DROP POLICY IF EXISTS "Users can update their own communities" ON public.communities; -CREATE POLICY "Users can update their own communities" -ON public.communities FOR UPDATE -USING (auth.uid() = created_by); - --- Allow authenticated users to create communities -DROP POLICY IF EXISTS "Authenticated users can create communities" ON public.communities; -CREATE POLICY "Authenticated users can create communities" -ON public.communities FOR INSERT -WITH CHECK (auth.uid() IS NOT NULL); - --- Allow users to see all events -DROP POLICY IF EXISTS "Users can view all events" ON public.community_events; -CREATE POLICY "Users can view all events" -ON public.community_events FOR SELECT -USING (true); - --- Allow authenticated users to create events -DROP POLICY IF EXISTS "Authenticated users can create events" ON public.community_events; -CREATE POLICY "Authenticated users can create events" -ON public.community_events FOR INSERT -WITH CHECK (auth.uid() IS NOT NULL); - --- Allow users to update their own events -DROP POLICY IF EXISTS "Users can update their own events" ON public.community_events; -CREATE POLICY "Users can update their own events" -ON public.community_events FOR UPDATE -USING (auth.uid() = created_by); diff --git a/The Trash/migrations/20260206210000_admin_only_community_events.sql b/The Trash/migrations/20260206210000_admin_only_community_events.sql deleted file mode 100644 index 193b727..0000000 --- a/The Trash/migrations/20260206210000_admin_only_community_events.sql +++ /dev/null @@ -1,114 +0,0 @@ --- ===================================================== --- Migration: 005_admin_only_community_events.sql --- Description: Only community admins can create community events --- Author: Albert Huang --- Date: 2026-02-06 --- ===================================================== - --- Update create_event function to require admin status for community events -CREATE OR REPLACE FUNCTION public.create_event( - p_title TEXT, - p_description TEXT, - p_category TEXT, - p_event_date TIMESTAMPTZ, - p_location TEXT, - p_latitude DECIMAL, - p_longitude DECIMAL, - p_max_participants INTEGER DEFAULT 50, - p_community_id TEXT DEFAULT NULL, -- NULL for personal event - p_icon_name TEXT DEFAULT 'calendar' -) -RETURNS json -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID := auth.uid(); - v_can_create json; - v_event_id UUID; - v_organizer TEXT; - v_is_personal BOOLEAN; -BEGIN - IF v_user_id IS NULL THEN - RETURN json_build_object('success', false, 'message', 'Not authenticated'); - END IF; - - -- Check limit - v_can_create := public.can_user_create_event(); - IF NOT (v_can_create->>'allowed')::boolean THEN - RETURN json_build_object('success', false, 'message', v_can_create->>'reason'); - END IF; - - -- Determine if personal or community event - v_is_personal := (p_community_id IS NULL); - - -- Get organizer name - IF v_is_personal THEN - SELECT COALESCE(username, email, 'Anonymous') INTO v_organizer - FROM public.profiles - WHERE id = v_user_id; - ELSE - -- 🔥 CHANGED: Only admins can create community events - IF NOT EXISTS ( - SELECT 1 FROM public.user_community_memberships - WHERE user_id = v_user_id AND community_id = p_community_id AND status = 'admin' - ) THEN - RETURN json_build_object('success', false, 'message', 'Only community admins can create community events'); - END IF; - - SELECT name INTO v_organizer - FROM public.communities - WHERE id = p_community_id; - END IF; - - -- Create event - INSERT INTO public.community_events ( - community_id, title, description, organizer, category, event_date, - location, latitude, longitude, max_participants, icon_name, - created_by, is_personal - ) - VALUES ( - p_community_id, p_title, p_description, v_organizer, p_category, p_event_date, - p_location, p_latitude, p_longitude, p_max_participants, p_icon_name, - v_user_id, v_is_personal - ) - RETURNING id INTO v_event_id; - - RETURN json_build_object('success', true, 'message', 'Event created', 'event_id', v_event_id); -END; -$$; - --- ===================================================== --- Update get_my_communities to include membership status --- ===================================================== - -CREATE OR REPLACE FUNCTION public.get_my_communities() -RETURNS TABLE ( - id TEXT, - name TEXT, - city TEXT, - state TEXT, - description TEXT, - member_count INTEGER, - joined_at TIMESTAMPTZ, - status TEXT -) -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -BEGIN - RETURN QUERY - SELECT - c.id, - c.name, - c.city, - c.state, - c.description, - c.member_count, - m.joined_at, - m.status - FROM public.user_community_memberships m - JOIN public.communities c ON m.community_id = c.id - WHERE m.user_id = auth.uid() AND m.status IN ('member', 'admin') - ORDER BY m.joined_at DESC; -END; -$$; diff --git a/The Trash/migrations/20260206220000_update_get_my_communities.sql b/The Trash/migrations/20260206220000_update_get_my_communities.sql deleted file mode 100644 index ca0c045..0000000 --- a/The Trash/migrations/20260206220000_update_get_my_communities.sql +++ /dev/null @@ -1,42 +0,0 @@ --- ===================================================== --- Migration: 006_update_get_my_communities.sql --- Description: Add status field to get_my_communities response --- Author: Albert Huang --- Date: 2026-02-06 --- ===================================================== - --- Drop and recreate get_my_communities to include membership status -DROP FUNCTION IF EXISTS public.get_my_communities(); - -CREATE OR REPLACE FUNCTION public.get_my_communities() -RETURNS TABLE ( - id TEXT, - name TEXT, - city TEXT, - state TEXT, - description TEXT, - member_count INTEGER, - joined_at TIMESTAMPTZ, - status TEXT -) -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -BEGIN - RETURN QUERY - SELECT - c.id, - c.name, - c.city, - c.state, - c.description, - c.member_count, - m.joined_at, - m.status - FROM public.user_community_memberships m - JOIN public.communities c ON m.community_id = c.id - WHERE m.user_id = auth.uid() AND m.status IN ('member', 'admin') - ORDER BY m.joined_at DESC; -END; -$$; - -GRANT EXECUTE ON FUNCTION public.get_my_communities() TO authenticated; diff --git a/The Trash/migrations/20260206231000_fix_get_community_events.sql b/The Trash/migrations/20260206231000_fix_get_community_events.sql deleted file mode 100644 index ce43294..0000000 --- a/The Trash/migrations/20260206231000_fix_get_community_events.sql +++ /dev/null @@ -1,65 +0,0 @@ --- ===================================================== --- Migration: 007_fix_get_community_events.sql --- Description: Fix/add function to get events for a specific community --- Author: Albert Huang --- Date: 2026-02-06 --- ===================================================== - --- First drop the existing function to allow changing return type -DROP FUNCTION IF EXISTS public.get_community_events(TEXT); - --- Function to get events for a specific community -CREATE FUNCTION public.get_community_events(p_community_id TEXT) -RETURNS TABLE ( - id UUID, - title TEXT, - description TEXT, - organizer TEXT, - category TEXT, - event_date TIMESTAMPTZ, - location TEXT, - latitude DECIMAL, - longitude DECIMAL, - icon_name TEXT, - max_participants INTEGER, - participant_count INTEGER, - community_id TEXT, - community_name TEXT, - distance_km DECIMAL, - is_registered BOOLEAN, - is_personal BOOLEAN -) -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -BEGIN - RETURN QUERY - SELECT - e.id, - e.title, - e.description, - e.organizer, - e.category, - e.event_date, - e.location, - e.latitude, - e.longitude, - e.icon_name, - e.max_participants, - e.participant_count, - e.community_id, - c.name as community_name, - 0::DECIMAL as distance_km, - EXISTS ( - SELECT 1 FROM public.event_registrations r - WHERE r.event_id = e.id AND r.user_id = auth.uid() AND r.status = 'registered' - ) as is_registered, - COALESCE(e.is_personal, false) as is_personal - FROM public.community_events e - LEFT JOIN public.communities c ON e.community_id = c.id - WHERE e.community_id = p_community_id - ORDER BY e.event_date DESC; -END; -$$; - -GRANT EXECUTE ON FUNCTION public.get_community_events(TEXT) TO authenticated; -GRANT EXECUTE ON FUNCTION public.get_community_events(TEXT) TO anon; diff --git a/The Trash/migrations/20260208000000_admin_permissions.sql b/The Trash/migrations/20260208000000_admin_permissions.sql deleted file mode 100644 index 523582b..0000000 --- a/The Trash/migrations/20260208000000_admin_permissions.sql +++ /dev/null @@ -1,570 +0,0 @@ --- ===================================================== --- 005_admin_permissions.sql --- Community admin permissions: applications, member management, --- credit grants, audit logs --- ===================================================== - --- ===================================================== --- 1. Join Applications Table --- ===================================================== - -CREATE TABLE IF NOT EXISTS public.community_join_applications ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - community_id TEXT NOT NULL REFERENCES public.communities(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')), - message TEXT, - rejection_reason TEXT, - reviewed_by UUID REFERENCES auth.users(id), - reviewed_at TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT timezone('utc', now()), - updated_at TIMESTAMPTZ DEFAULT timezone('utc', now()), - - UNIQUE(community_id, user_id) -); - -CREATE INDEX IF NOT EXISTS idx_applications_community ON public.community_join_applications(community_id, status); -CREATE INDEX IF NOT EXISTS idx_applications_user ON public.community_join_applications(user_id); -CREATE INDEX IF NOT EXISTS idx_applications_status ON public.community_join_applications(status); - --- ===================================================== --- 2. Community Settings Columns --- ===================================================== - -ALTER TABLE public.communities -ADD COLUMN IF NOT EXISTS requires_approval BOOLEAN DEFAULT false, -ADD COLUMN IF NOT EXISTS welcome_message TEXT, -ADD COLUMN IF NOT EXISTS rules TEXT, -ADD COLUMN IF NOT EXISTS tags TEXT[], -ADD COLUMN IF NOT EXISTS is_private BOOLEAN DEFAULT false; - --- ===================================================== --- 3. Admin Action Logs Table --- ===================================================== - -CREATE TABLE IF NOT EXISTS public.admin_action_logs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - community_id TEXT NOT NULL REFERENCES public.communities(id) ON DELETE CASCADE, - admin_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - action_type TEXT NOT NULL CHECK (action_type IN ( - 'approve_member', 'reject_member', 'remove_member', 'grant_credits', - 'edit_community', 'edit_event', 'delete_event', 'pin_post', 'delete_post' - )), - target_user_id UUID REFERENCES auth.users(id), - target_event_id UUID, - details JSONB, - created_at TIMESTAMPTZ DEFAULT timezone('utc', now()) -); - -CREATE INDEX IF NOT EXISTS idx_admin_logs_community ON public.admin_action_logs(community_id, created_at DESC); -CREATE INDEX IF NOT EXISTS idx_admin_logs_admin ON public.admin_action_logs(admin_id); - --- ===================================================== --- 4. Credit Grants Table --- ===================================================== - -CREATE TABLE IF NOT EXISTS public.credit_grants ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - granted_by UUID NOT NULL REFERENCES auth.users(id), - community_id TEXT REFERENCES public.communities(id) ON DELETE SET NULL, - event_id UUID, - amount INTEGER NOT NULL CHECK (amount > 0), - reason TEXT NOT NULL, - created_at TIMESTAMPTZ DEFAULT timezone('utc', now()) -); - -CREATE INDEX IF NOT EXISTS idx_credit_grants_user ON public.credit_grants(user_id); -CREATE INDEX IF NOT EXISTS idx_credit_grants_community ON public.credit_grants(community_id); -CREATE INDEX IF NOT EXISTS idx_credit_grants_event ON public.credit_grants(event_id); - --- ===================================================== --- 5. RPC: Check if user is community admin --- ===================================================== - -CREATE OR REPLACE FUNCTION public.is_community_admin( - p_community_id TEXT, - p_user_id UUID DEFAULT auth.uid() -) -RETURNS BOOLEAN -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -BEGIN - RETURN EXISTS ( - SELECT 1 FROM public.user_community_memberships - WHERE community_id = p_community_id - AND user_id = p_user_id - AND status = 'admin' - ); -END; -$$; - --- ===================================================== --- 6. RPC: Apply to join community (with approval support) --- ===================================================== - -CREATE OR REPLACE FUNCTION public.apply_to_join_community( - p_community_id TEXT, - p_message TEXT DEFAULT NULL -) -RETURNS JSON -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID := auth.uid(); - v_requires_approval BOOLEAN; - v_community_name TEXT; -BEGIN - IF v_user_id IS NULL THEN - RETURN json_build_object('success', false, 'message', 'Not authenticated', 'requires_approval', false); - END IF; - - SELECT requires_approval, name INTO v_requires_approval, v_community_name - FROM public.communities - WHERE id = p_community_id AND is_active = true; - - IF NOT FOUND THEN - RETURN json_build_object('success', false, 'message', 'Community not found', 'requires_approval', false); - END IF; - - IF EXISTS ( - SELECT 1 FROM public.user_community_memberships - WHERE user_id = v_user_id AND community_id = p_community_id - ) THEN - RETURN json_build_object('success', false, 'message', 'Already a member', 'requires_approval', false); - END IF; - - -- If no approval needed, join directly - IF NOT COALESCE(v_requires_approval, false) THEN - INSERT INTO public.user_community_memberships (user_id, community_id, status) - VALUES (v_user_id, p_community_id, 'member'); - - UPDATE public.communities - SET member_count = member_count + 1, updated_at = NOW() - WHERE id = p_community_id; - - RETURN json_build_object( - 'success', true, - 'message', 'Joined successfully', - 'requires_approval', false - ); - END IF; - - -- Approval required: create application - INSERT INTO public.community_join_applications (community_id, user_id, message) - VALUES (p_community_id, v_user_id, p_message) - ON CONFLICT (community_id, user_id) - DO UPDATE SET - status = 'pending', - message = EXCLUDED.message, - updated_at = NOW(); - - RETURN json_build_object( - 'success', true, - 'message', 'Application submitted', - 'requires_approval', true - ); -END; -$$; - --- ===================================================== --- 7. RPC: Get pending applications (admin only) --- ===================================================== - -CREATE OR REPLACE FUNCTION public.get_pending_applications( - p_community_id TEXT -) -RETURNS TABLE ( - id UUID, - user_id UUID, - username TEXT, - user_credits INTEGER, - message TEXT, - created_at TIMESTAMPTZ -) -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -BEGIN - IF NOT public.is_community_admin(p_community_id, auth.uid()) THEN - RAISE EXCEPTION 'Permission denied'; - END IF; - - RETURN QUERY - SELECT - a.id, - a.user_id, - COALESCE(p.username, 'Anonymous')::TEXT AS username, - COALESCE(p.credits, 0) AS user_credits, - a.message, - a.created_at - FROM public.community_join_applications a - LEFT JOIN public.profiles p ON a.user_id = p.id - WHERE a.community_id = p_community_id - AND a.status = 'pending' - ORDER BY a.created_at; -END; -$$; - --- ===================================================== --- 8. RPC: Review join application (admin only) --- ===================================================== - -CREATE OR REPLACE FUNCTION public.review_join_application( - p_application_id UUID, - p_approve BOOLEAN, - p_rejection_reason TEXT DEFAULT NULL -) -RETURNS JSON -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -DECLARE - v_admin_id UUID := auth.uid(); - v_community_id TEXT; - v_user_id UUID; - v_username TEXT; -BEGIN - SELECT community_id, user_id INTO v_community_id, v_user_id - FROM public.community_join_applications - WHERE id = p_application_id AND status = 'pending'; - - IF NOT FOUND THEN - RETURN json_build_object('success', false, 'message', 'Application not found'); - END IF; - - IF NOT public.is_community_admin(v_community_id, v_admin_id) THEN - RETURN json_build_object('success', false, 'message', 'Permission denied'); - END IF; - - SELECT username INTO v_username FROM public.profiles WHERE id = v_user_id; - - IF p_approve THEN - UPDATE public.community_join_applications - SET status = 'approved', reviewed_by = v_admin_id, reviewed_at = NOW(), updated_at = NOW() - WHERE id = p_application_id; - - INSERT INTO public.user_community_memberships (user_id, community_id, status) - VALUES (v_user_id, v_community_id, 'member') - ON CONFLICT (user_id, community_id) DO NOTHING; - - UPDATE public.communities - SET member_count = member_count + 1, updated_at = NOW() - WHERE id = v_community_id; - - INSERT INTO public.admin_action_logs (community_id, admin_id, action_type, target_user_id, details) - VALUES (v_community_id, v_admin_id, 'approve_member', v_user_id, - json_build_object('username', v_username)); - - RETURN json_build_object('success', true, 'message', 'Application approved'); - ELSE - UPDATE public.community_join_applications - SET status = 'rejected', reviewed_by = v_admin_id, reviewed_at = NOW(), - rejection_reason = p_rejection_reason, updated_at = NOW() - WHERE id = p_application_id; - - INSERT INTO public.admin_action_logs (community_id, admin_id, action_type, target_user_id, details) - VALUES (v_community_id, v_admin_id, 'reject_member', v_user_id, - json_build_object('username', v_username, 'reason', p_rejection_reason)); - - RETURN json_build_object('success', true, 'message', 'Application rejected'); - END IF; -END; -$$; - --- ===================================================== --- 9. RPC: Update community info (admin only) --- ===================================================== - -CREATE OR REPLACE FUNCTION public.update_community_info( - p_community_id TEXT, - p_description TEXT DEFAULT NULL, - p_welcome_message TEXT DEFAULT NULL, - p_rules TEXT DEFAULT NULL, - p_requires_approval BOOLEAN DEFAULT NULL -) -RETURNS JSON -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -DECLARE - v_admin_id UUID := auth.uid(); -BEGIN - IF NOT public.is_community_admin(p_community_id, v_admin_id) THEN - RETURN json_build_object('success', false, 'message', 'Permission denied'); - END IF; - - UPDATE public.communities - SET - description = COALESCE(p_description, description), - welcome_message = COALESCE(p_welcome_message, welcome_message), - rules = COALESCE(p_rules, rules), - requires_approval = COALESCE(p_requires_approval, requires_approval), - updated_at = NOW() - WHERE id = p_community_id; - - INSERT INTO public.admin_action_logs (community_id, admin_id, action_type, details) - VALUES (p_community_id, v_admin_id, 'edit_community', - json_build_object('description', p_description, 'welcome_message', p_welcome_message, 'requires_approval', p_requires_approval)); - - RETURN json_build_object('success', true, 'message', 'Community updated'); -END; -$$; - --- ===================================================== --- 10. RPC: Remove community member (admin only) --- ===================================================== - -CREATE OR REPLACE FUNCTION public.remove_community_member( - p_community_id TEXT, - p_user_id UUID, - p_reason TEXT DEFAULT NULL -) -RETURNS JSON -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -DECLARE - v_admin_id UUID := auth.uid(); - v_username TEXT; -BEGIN - IF NOT public.is_community_admin(p_community_id, v_admin_id) THEN - RETURN json_build_object('success', false, 'message', 'Permission denied'); - END IF; - - IF public.is_community_admin(p_community_id, p_user_id) THEN - RETURN json_build_object('success', false, 'message', 'Cannot remove admin'); - END IF; - - SELECT username INTO v_username FROM public.profiles WHERE id = p_user_id; - - DELETE FROM public.user_community_memberships - WHERE community_id = p_community_id AND user_id = p_user_id; - - IF NOT FOUND THEN - RETURN json_build_object('success', false, 'message', 'User is not a member'); - END IF; - - UPDATE public.communities - SET member_count = GREATEST(0, member_count - 1), updated_at = NOW() - WHERE id = p_community_id; - - INSERT INTO public.admin_action_logs (community_id, admin_id, action_type, target_user_id, details) - VALUES (p_community_id, v_admin_id, 'remove_member', p_user_id, - json_build_object('username', v_username, 'reason', p_reason)); - - RETURN json_build_object('success', true, 'message', 'Member removed'); -END; -$$; - --- ===================================================== --- 11. RPC: Grant event credits (admin only) --- ===================================================== - -CREATE OR REPLACE FUNCTION public.grant_event_credits( - p_event_id UUID, - p_user_ids UUID[], - p_credits_per_user INTEGER, - p_reason TEXT -) -RETURNS JSON -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -DECLARE - v_admin_id UUID := auth.uid(); - v_community_id TEXT; - v_user_id UUID; - v_granted_count INTEGER := 0; -BEGIN - SELECT community_id INTO v_community_id - FROM public.community_events - WHERE id = p_event_id; - - IF NOT FOUND THEN - RETURN json_build_object('success', false, 'message', 'Event not found', 'granted_count', 0); - END IF; - - IF NOT ( - public.is_community_admin(v_community_id, v_admin_id) OR - EXISTS (SELECT 1 FROM public.community_events WHERE id = p_event_id AND created_by = v_admin_id) - ) THEN - RETURN json_build_object('success', false, 'message', 'Permission denied', 'granted_count', 0); - END IF; - - IF p_credits_per_user <= 0 OR p_credits_per_user > 1000 THEN - RETURN json_build_object('success', false, 'message', 'Invalid credit amount (must be 1-1000)', 'granted_count', 0); - END IF; - - FOREACH v_user_id IN ARRAY p_user_ids LOOP - IF EXISTS ( - SELECT 1 FROM public.event_registrations - WHERE event_id = p_event_id AND user_id = v_user_id - ) THEN - UPDATE public.profiles - SET credits = credits + p_credits_per_user - WHERE id = v_user_id; - - INSERT INTO public.credit_grants (user_id, granted_by, community_id, event_id, amount, reason) - VALUES (v_user_id, v_admin_id, v_community_id, p_event_id, p_credits_per_user, p_reason); - - v_granted_count := v_granted_count + 1; - END IF; - END LOOP; - - INSERT INTO public.admin_action_logs (community_id, admin_id, action_type, target_event_id, details) - VALUES (v_community_id, v_admin_id, 'grant_credits', p_event_id, - json_build_object('user_count', v_granted_count, 'credits_per_user', p_credits_per_user, - 'total_credits', v_granted_count * p_credits_per_user, 'reason', p_reason)); - - RETURN json_build_object('success', true, 'message', format('Credits granted to %s users', v_granted_count), 'granted_count', v_granted_count); -END; -$$; - --- ===================================================== --- 12. RPC: Get community members (admin view) --- ===================================================== - -CREATE OR REPLACE FUNCTION public.get_community_members_admin( - p_community_id TEXT -) -RETURNS TABLE ( - user_id UUID, - username TEXT, - credits INTEGER, - status TEXT, - joined_at TIMESTAMPTZ, - is_admin BOOLEAN -) -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -BEGIN - IF NOT public.is_community_admin(p_community_id, auth.uid()) THEN - RAISE EXCEPTION 'Permission denied'; - END IF; - - RETURN QUERY - SELECT - m.user_id, - COALESCE(p.username, 'Anonymous')::TEXT AS username, - COALESCE(p.credits, 0) AS credits, - m.status, - m.joined_at, - (m.status = 'admin') AS is_admin - FROM public.user_community_memberships m - LEFT JOIN public.profiles p ON m.user_id = p.id - WHERE m.community_id = p_community_id - AND m.status IN ('member', 'admin') - ORDER BY - CASE WHEN m.status = 'admin' THEN 0 ELSE 1 END, - m.joined_at; -END; -$$; - --- ===================================================== --- 13. RPC: Get admin action logs --- ===================================================== - -CREATE OR REPLACE FUNCTION public.get_admin_action_logs( - p_community_id TEXT, - p_limit INTEGER DEFAULT 50 -) -RETURNS TABLE ( - id UUID, - admin_username TEXT, - action_type TEXT, - target_username TEXT, - details JSONB, - created_at TIMESTAMPTZ -) -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -BEGIN - IF NOT public.is_community_admin(p_community_id, auth.uid()) THEN - RAISE EXCEPTION 'Permission denied'; - END IF; - - RETURN QUERY - SELECT - l.id, - COALESCE(admin_p.username, 'Unknown')::TEXT AS admin_username, - l.action_type, - COALESCE(target_p.username, NULL)::TEXT AS target_username, - l.details, - l.created_at - FROM public.admin_action_logs l - LEFT JOIN public.profiles admin_p ON l.admin_id = admin_p.id - LEFT JOIN public.profiles target_p ON l.target_user_id = target_p.id - WHERE l.community_id = p_community_id - ORDER BY l.created_at DESC - LIMIT p_limit; -END; -$$; - --- ===================================================== --- 14. RPC: Get event participants --- ===================================================== - -CREATE OR REPLACE FUNCTION public.get_event_participants(p_event_id UUID) -RETURNS TABLE ( - user_id UUID, - username TEXT, - credits INTEGER, - registered_at TIMESTAMPTZ -) -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -BEGIN - RETURN QUERY - SELECT - r.user_id, - COALESCE(p.username, 'Anonymous')::TEXT AS username, - COALESCE(p.credits, 0) AS credits, - r.registered_at - FROM public.event_registrations r - LEFT JOIN public.profiles p ON r.user_id = p.id - WHERE r.event_id = p_event_id - ORDER BY r.registered_at; -END; -$$; - --- ===================================================== --- 15. GRANT PERMISSIONS --- ===================================================== - -GRANT EXECUTE ON FUNCTION public.is_community_admin(TEXT, UUID) TO authenticated; -GRANT EXECUTE ON FUNCTION public.apply_to_join_community(TEXT, TEXT) TO authenticated; -GRANT EXECUTE ON FUNCTION public.get_pending_applications(TEXT) TO authenticated; -GRANT EXECUTE ON FUNCTION public.review_join_application(UUID, BOOLEAN, TEXT) TO authenticated; -GRANT EXECUTE ON FUNCTION public.update_community_info(TEXT, TEXT, TEXT, TEXT, BOOLEAN) TO authenticated; -GRANT EXECUTE ON FUNCTION public.remove_community_member(TEXT, UUID, TEXT) TO authenticated; -GRANT EXECUTE ON FUNCTION public.grant_event_credits(UUID, UUID[], INTEGER, TEXT) TO authenticated; -GRANT EXECUTE ON FUNCTION public.get_community_members_admin(TEXT) TO authenticated; -GRANT EXECUTE ON FUNCTION public.get_admin_action_logs(TEXT, INTEGER) TO authenticated; -GRANT EXECUTE ON FUNCTION public.get_event_participants(UUID) TO authenticated; - --- ===================================================== --- 16. RLS POLICIES --- ===================================================== - -ALTER TABLE public.community_join_applications ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "Users can view own applications" ON public.community_join_applications; -CREATE POLICY "Users can view own applications" -ON public.community_join_applications FOR SELECT -USING ( - auth.uid() = user_id OR - public.is_community_admin(community_id, auth.uid()) -); - -ALTER TABLE public.admin_action_logs ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "Admins can view action logs" ON public.admin_action_logs; -CREATE POLICY "Admins can view action logs" -ON public.admin_action_logs FOR SELECT -USING (public.is_community_admin(community_id, auth.uid())); - -ALTER TABLE public.credit_grants ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "Users can view own credit grants" ON public.credit_grants; -CREATE POLICY "Users can view own credit grants" -ON public.credit_grants FOR SELECT -USING ( - auth.uid() = user_id OR - (community_id IS NOT NULL AND public.is_community_admin(community_id, auth.uid())) -); diff --git a/The Trash/migrations/20260208100000_optimization_triggers.sql b/The Trash/migrations/20260208100000_optimization_triggers.sql deleted file mode 100644 index 183d686..0000000 --- a/The Trash/migrations/20260208100000_optimization_triggers.sql +++ /dev/null @@ -1,207 +0,0 @@ --- ===================================================== --- Migration: 20260208100000_optimization_triggers.sql --- Description: Optimization: Auto-counters via Triggers & Spatial Query Perf --- Author: Albert Huang --- Date: 2026-02-08 --- ===================================================== - --- ===================================================== --- 1. TRIGGER FUNCTION: Update Community Member Count --- ===================================================== - -CREATE OR REPLACE FUNCTION public.handle_community_member_count() -RETURNS TRIGGER -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - IF (TG_OP = 'INSERT') THEN - -- Only count if status is 'member' or 'admin' - IF NEW.status IN ('member', 'admin') THEN - UPDATE public.communities - SET member_count = member_count + 1, updated_at = NOW() - WHERE id = NEW.community_id; - END IF; - RETURN NEW; - ELSIF (TG_OP = 'DELETE') THEN - -- Only decrement if status was 'member' or 'admin' - IF OLD.status IN ('member', 'admin') THEN - UPDATE public.communities - SET member_count = GREATEST(0, member_count - 1), updated_at = NOW() - WHERE id = OLD.community_id; - END IF; - RETURN OLD; - ELSIF (TG_OP = 'UPDATE') THEN - -- Handle status changes (e.g. pending -> member) - -- Case 1: Becoming a member - IF OLD.status NOT IN ('member', 'admin') AND NEW.status IN ('member', 'admin') THEN - UPDATE public.communities - SET member_count = member_count + 1, updated_at = NOW() - WHERE id = NEW.community_id; - -- Case 2: No longer a member (e.g. banned/left but kept record?) - usually DELETE is used, but covering bases - ELSIF OLD.status IN ('member', 'admin') AND NEW.status NOT IN ('member', 'admin') THEN - UPDATE public.communities - SET member_count = GREATEST(0, member_count - 1), updated_at = NOW() - WHERE id = NEW.community_id; - END IF; - RETURN NEW; - END IF; - RETURN NULL; -END; -$$; - --- Trigger for user_community_memberships -DROP TRIGGER IF EXISTS on_community_member_change ON public.user_community_memberships; -CREATE TRIGGER on_community_member_change -AFTER INSERT OR UPDATE OR DELETE ON public.user_community_memberships -FOR EACH ROW EXECUTE FUNCTION public.handle_community_member_count(); - - --- ===================================================== --- 2. TRIGGER FUNCTION: Update Event Participant Count --- ===================================================== - -CREATE OR REPLACE FUNCTION public.handle_event_participant_count() -RETURNS TRIGGER -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - IF (TG_OP = 'INSERT') THEN - IF NEW.status = 'registered' THEN - UPDATE public.community_events - SET participant_count = participant_count + 1 - WHERE id = NEW.event_id; - END IF; - RETURN NEW; - ELSIF (TG_OP = 'DELETE') THEN - IF OLD.status = 'registered' THEN - UPDATE public.community_events - SET participant_count = GREATEST(0, participant_count - 1) - WHERE id = OLD.event_id; - END IF; - RETURN OLD; - ELSIF (TG_OP = 'UPDATE') THEN - -- Case 1: Becoming registered - IF OLD.status != 'registered' AND NEW.status = 'registered' THEN - UPDATE public.community_events - SET participant_count = participant_count + 1 - WHERE id = NEW.event_id; - -- Case 2: No longer registered - ELSIF OLD.status = 'registered' AND NEW.status != 'registered' THEN - UPDATE public.community_events - SET participant_count = GREATEST(0, participant_count - 1) - WHERE id = NEW.event_id; - END IF; - RETURN NEW; - END IF; - RETURN NULL; -END; -$$; - --- Trigger for event_registrations -DROP TRIGGER IF EXISTS on_event_registration_change ON public.event_registrations; -CREATE TRIGGER on_event_registration_change -AFTER INSERT OR UPDATE OR DELETE ON public.event_registrations -FOR EACH ROW EXECUTE FUNCTION public.handle_event_participant_count(); - - --- ===================================================== --- 3. OPTIMIZATION: get_nearby_events with Bounding Box --- ===================================================== - --- Redefine get_nearby_events to use a bounding box pre-filter --- This avoids calculating Haversine distance for points clearly outside the range. --- 1 deg latitude ~= 111 km. 1 deg longitude varies but is <= 111km. --- A crude box of +/- (max_dist_km / 111) degrees is a safe superset. - -CREATE OR REPLACE FUNCTION public.get_nearby_events( - p_latitude DECIMAL, - p_longitude DECIMAL, - p_max_distance_km DECIMAL DEFAULT 50, - p_category TEXT DEFAULT NULL, - p_only_joined_communities BOOLEAN DEFAULT false, - p_sort_by TEXT DEFAULT 'date' -) -RETURNS TABLE ( - id UUID, - title TEXT, - description TEXT, - organizer TEXT, - category TEXT, - event_date TIMESTAMPTZ, - location TEXT, - latitude DECIMAL, - longitude DECIMAL, - icon_name TEXT, - max_participants INTEGER, - participant_count INTEGER, - community_id TEXT, - community_name TEXT, - distance_km DECIMAL, - is_registered BOOLEAN, - is_personal BOOLEAN -) -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID := auth.uid(); - v_lat_range DECIMAL; - v_lon_range DECIMAL; -BEGIN - -- Calculate rough bounding box (1 deg approx 111km) - -- Adding a small buffer (1.1 factor) to be safe - v_lat_range := (p_max_distance_km / 111.0) * 1.1; - -- Longitude degrees shrink as we move away from equator, but using 111km is safe as a lower bound for the 'degree width' in denominator, - -- meaning we might over-select, which is fine for a pre-filter. - -- To be more precise: v_lon_range := (p_max_distance_km / (111.0 * cos(radians(p_latitude)))) * 1.1; - -- For simplicity and speed in SQL without complex math in declaration: - v_lon_range := (p_max_distance_km / 50.0) * 1.1; -- Very generous box to avoid complex cos() logic issues at poles - - RETURN QUERY - SELECT - e.id, - e.title, - e.description, - e.organizer, - e.category, - e.event_date, - e.location, - e.latitude, - e.longitude, - e.icon_name, - e.max_participants, - e.participant_count, - e.community_id, - c.name AS community_name, - public.calculate_distance_km(p_latitude, p_longitude, e.latitude, e.longitude) AS distance_km, - EXISTS ( - SELECT 1 FROM public.event_registrations r - WHERE r.event_id = e.id AND r.user_id = v_user_id AND r.status = 'registered' - ) AS is_registered, - COALESCE(e.is_personal, false) AS is_personal - FROM public.community_events e - LEFT JOIN public.communities c ON e.community_id = c.id - WHERE e.status = 'upcoming' - AND e.event_date >= NOW() - -- Bounding Box Pre-filter - AND e.latitude BETWEEN (p_latitude - v_lat_range) AND (p_latitude + v_lat_range) - AND e.longitude BETWEEN (p_longitude - v_lon_range) AND (p_longitude + v_lon_range) - -- Primary Filter - AND public.calculate_distance_km(p_latitude, p_longitude, e.latitude, e.longitude) <= p_max_distance_km - AND (p_category IS NULL OR e.category = p_category) - AND ( - NOT p_only_joined_communities - OR e.is_personal = true - OR EXISTS ( - SELECT 1 FROM public.user_community_memberships m - WHERE m.community_id = e.community_id AND m.user_id = v_user_id AND m.status IN ('member', 'admin') - ) - ) - ORDER BY - CASE WHEN p_sort_by = 'date' THEN e.event_date END ASC, - CASE WHEN p_sort_by = 'distance' THEN public.calculate_distance_km(p_latitude, p_longitude, e.latitude, e.longitude) END ASC, - CASE WHEN p_sort_by = 'popularity' THEN e.participant_count END DESC; -END; -$$; diff --git a/The Trash/migrations/20260208120000_admin_permissions.sql b/The Trash/migrations/20260208120000_admin_permissions.sql deleted file mode 100644 index bc36241..0000000 --- a/The Trash/migrations/20260208120000_admin_permissions.sql +++ /dev/null @@ -1,621 +0,0 @@ --- ===================================================== --- 005_admin_permissions.sql --- 社区管理员权限功能 --- Created: 2026-02-08 --- Version: 1.0 --- ===================================================== - --- ===================================================== --- 1. 加入申请表 (Join Applications) --- ===================================================== - -CREATE TABLE IF NOT EXISTS public.community_join_applications ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - community_id TEXT NOT NULL REFERENCES public.communities(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')), - message TEXT, -- 用户申请时的留言 - rejection_reason TEXT, -- 拒绝理由 - reviewed_by UUID REFERENCES auth.users(id), -- 审批人 - reviewed_at TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT timezone('utc', now()), - updated_at TIMESTAMPTZ DEFAULT timezone('utc', now()), - - UNIQUE(community_id, user_id) -- 每个用户每个社区只能有一个pending申请 -); - -CREATE INDEX IF NOT EXISTS idx_applications_community ON public.community_join_applications(community_id, status); -CREATE INDEX IF NOT EXISTS idx_applications_user ON public.community_join_applications(user_id); -CREATE INDEX IF NOT EXISTS idx_applications_status ON public.community_join_applications(status); - -COMMENT ON TABLE public.community_join_applications IS '社区加入申请表'; - --- ===================================================== --- 2. 社区设置表 (Community Settings) --- ===================================================== - -ALTER TABLE public.communities -ADD COLUMN IF NOT EXISTS requires_approval BOOLEAN DEFAULT false, -- 是否需要审批才能加入 -ADD COLUMN IF NOT EXISTS welcome_message TEXT, -- 欢迎消息 -ADD COLUMN IF NOT EXISTS rules TEXT, -- 社区规则 -ADD COLUMN IF NOT EXISTS tags TEXT[], -- 社区标签 -ADD COLUMN IF NOT EXISTS is_private BOOLEAN DEFAULT false; -- 是否私密社区 - -COMMENT ON COLUMN public.communities.requires_approval IS '是否需要管理员审批才能加入'; -COMMENT ON COLUMN public.communities.is_private IS '私密社区不会出现在公开列表中'; - --- ===================================================== --- 3. 管理员操作日志表 (Admin Action Logs) --- ===================================================== - -CREATE TABLE IF NOT EXISTS public.admin_action_logs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - community_id TEXT NOT NULL REFERENCES public.communities(id) ON DELETE CASCADE, - admin_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - action_type TEXT NOT NULL CHECK (action_type IN ( - 'approve_member', 'reject_member', 'remove_member', 'grant_credits', - 'edit_community', 'edit_event', 'delete_event', 'pin_post', 'delete_post' - )), - target_user_id UUID REFERENCES auth.users(id), -- 操作的目标用户 - target_event_id UUID, -- 操作的目标活动 - details JSONB, -- 操作详情 - created_at TIMESTAMPTZ DEFAULT timezone('utc', now()) -); - -CREATE INDEX IF NOT EXISTS idx_admin_logs_community ON public.admin_action_logs(community_id, created_at DESC); -CREATE INDEX IF NOT EXISTS idx_admin_logs_admin ON public.admin_action_logs(admin_id); - -COMMENT ON TABLE public.admin_action_logs IS '管理员操作日志,用于审计'; - --- ===================================================== --- 4. 积分发放记录表 (Credit Grants) --- ===================================================== - -CREATE TABLE IF NOT EXISTS public.credit_grants ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - granted_by UUID NOT NULL REFERENCES auth.users(id), -- 发放者 - community_id TEXT REFERENCES public.communities(id) ON DELETE SET NULL, - event_id UUID, -- 关联的活动 - amount INTEGER NOT NULL CHECK (amount > 0), - reason TEXT NOT NULL, - created_at TIMESTAMPTZ DEFAULT timezone('utc', now()) -); - -CREATE INDEX IF NOT EXISTS idx_credit_grants_user ON public.credit_grants(user_id); -CREATE INDEX IF NOT EXISTS idx_credit_grants_community ON public.credit_grants(community_id); -CREATE INDEX IF NOT EXISTS idx_credit_grants_event ON public.credit_grants(event_id); - -COMMENT ON TABLE public.credit_grants IS '管理员手动发放积分的记录'; - --- ===================================================== --- 5. RPC: 检查是否是社区管理员 --- ===================================================== - -CREATE OR REPLACE FUNCTION public.is_community_admin( - p_community_id TEXT, - p_user_id UUID DEFAULT auth.uid() -) -RETURNS BOOLEAN -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -BEGIN - RETURN EXISTS ( - SELECT 1 FROM public.user_community_memberships - WHERE community_id = p_community_id - AND user_id = p_user_id - AND status = 'admin' - ); -END; -$$; - -COMMENT ON FUNCTION public.is_community_admin(TEXT, UUID) IS '检查用户是否是社区管理员'; - --- ===================================================== --- 6. RPC: 申请加入社区 --- ===================================================== - -CREATE OR REPLACE FUNCTION public.apply_to_join_community( - p_community_id TEXT, - p_message TEXT DEFAULT NULL -) -RETURNS JSON -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID := auth.uid(); - v_requires_approval BOOLEAN; - v_community_name TEXT; -BEGIN - -- 检查是否登录 - IF v_user_id IS NULL THEN - RETURN json_build_object('success', false, 'message', 'Not authenticated'); - END IF; - - -- 检查社区是否存在并获取设置 - SELECT requires_approval, name INTO v_requires_approval, v_community_name - FROM public.communities - WHERE id = p_community_id AND is_active = true; - - IF NOT FOUND THEN - RETURN json_build_object('success', false, 'message', 'Community not found'); - END IF; - - -- 检查是否已经是成员 - IF EXISTS ( - SELECT 1 FROM public.user_community_memberships - WHERE user_id = v_user_id AND community_id = p_community_id - ) THEN - RETURN json_build_object('success', false, 'message', 'Already a member'); - END IF; - - -- 如果不需要审批,直接加入 - IF NOT v_requires_approval THEN - INSERT INTO public.user_community_memberships (user_id, community_id, status) - VALUES (v_user_id, p_community_id, 'member'); - - UPDATE public.communities - SET member_count = member_count + 1, updated_at = NOW() - WHERE id = p_community_id; - - RETURN json_build_object( - 'success', true, - 'message', 'Joined successfully', - 'requires_approval', false - ); - END IF; - - -- 需要审批:创建申请 - INSERT INTO public.community_join_applications (community_id, user_id, message) - VALUES (p_community_id, v_user_id, p_message) - ON CONFLICT (community_id, user_id) - DO UPDATE SET - status = 'pending', - message = EXCLUDED.message, - updated_at = NOW(); - - RETURN json_build_object( - 'success', true, - 'message', 'Application submitted', - 'requires_approval', true - ); -END; -$$; - -COMMENT ON FUNCTION public.apply_to_join_community IS '申请加入社区(如需审批则创建申请)'; - --- ===================================================== --- 7. RPC: 获取待审批的申请 --- ===================================================== - -CREATE OR REPLACE FUNCTION public.get_pending_applications( - p_community_id TEXT -) -RETURNS TABLE ( - id UUID, - user_id UUID, - username TEXT, - user_credits INTEGER, - message TEXT, - created_at TIMESTAMPTZ -) -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -BEGIN - -- 检查权限 - IF NOT public.is_community_admin(p_community_id, auth.uid()) THEN - RAISE EXCEPTION 'Permission denied: Only admins can view applications'; - END IF; - - RETURN QUERY - SELECT - a.id, - a.user_id, - COALESCE(p.username, 'Anonymous')::TEXT AS username, - COALESCE(p.credits, 0) AS user_credits, - a.message, - a.created_at - FROM public.community_join_applications a - LEFT JOIN public.profiles p ON a.user_id = p.id - WHERE a.community_id = p_community_id - AND a.status = 'pending' - ORDER BY a.created_at; -END; -$$; - -COMMENT ON FUNCTION public.get_pending_applications IS '获取社区待审批的加入申请(仅管理员)'; - --- ===================================================== --- 8. RPC: 审批申请 --- ===================================================== - -CREATE OR REPLACE FUNCTION public.review_join_application( - p_application_id UUID, - p_approve BOOLEAN, - p_rejection_reason TEXT DEFAULT NULL -) -RETURNS JSON -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -DECLARE - v_admin_id UUID := auth.uid(); - v_community_id TEXT; - v_user_id UUID; - v_username TEXT; -BEGIN - -- 获取申请信息 - SELECT community_id, user_id INTO v_community_id, v_user_id - FROM public.community_join_applications - WHERE id = p_application_id AND status = 'pending'; - - IF NOT FOUND THEN - RETURN json_build_object('success', false, 'message', 'Application not found'); - END IF; - - -- 检查权限 - IF NOT public.is_community_admin(v_community_id, v_admin_id) THEN - RETURN json_build_object('success', false, 'message', 'Permission denied'); - END IF; - - -- 获取用户名(用于日志) - SELECT username INTO v_username FROM public.profiles WHERE id = v_user_id; - - IF p_approve THEN - -- 批准:更新申请状态并添加为成员 - UPDATE public.community_join_applications - SET status = 'approved', - reviewed_by = v_admin_id, - reviewed_at = NOW(), - updated_at = NOW() - WHERE id = p_application_id; - - INSERT INTO public.user_community_memberships (user_id, community_id, status) - VALUES (v_user_id, v_community_id, 'member') - ON CONFLICT (user_id, community_id) DO NOTHING; - - UPDATE public.communities - SET member_count = member_count + 1, updated_at = NOW() - WHERE id = v_community_id; - - -- 记录日志 - INSERT INTO public.admin_action_logs (community_id, admin_id, action_type, target_user_id, details) - VALUES (v_community_id, v_admin_id, 'approve_member', v_user_id, - json_build_object('username', v_username)); - - RETURN json_build_object('success', true, 'message', 'Application approved'); - ELSE - -- 拒绝:更新申请状态 - UPDATE public.community_join_applications - SET status = 'rejected', - reviewed_by = v_admin_id, - reviewed_at = NOW(), - rejection_reason = p_rejection_reason, - updated_at = NOW() - WHERE id = p_application_id; - - -- 记录日志 - INSERT INTO public.admin_action_logs (community_id, admin_id, action_type, target_user_id, details) - VALUES (v_community_id, v_admin_id, 'reject_member', v_user_id, - json_build_object('username', v_username, 'reason', p_rejection_reason)); - - RETURN json_build_object('success', true, 'message', 'Application rejected'); - END IF; -END; -$$; - -COMMENT ON FUNCTION public.review_join_application IS '审批社区加入申请(仅管理员)'; - --- ===================================================== --- 9. RPC: 更新社区信息 --- ===================================================== - -CREATE OR REPLACE FUNCTION public.update_community_info( - p_community_id TEXT, - p_description TEXT DEFAULT NULL, - p_welcome_message TEXT DEFAULT NULL, - p_rules TEXT DEFAULT NULL, - p_requires_approval BOOLEAN DEFAULT NULL -) -RETURNS JSON -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -DECLARE - v_admin_id UUID := auth.uid(); -BEGIN - -- 检查权限 - IF NOT public.is_community_admin(p_community_id, v_admin_id) THEN - RETURN json_build_object('success', false, 'message', 'Permission denied'); - END IF; - - -- 更新社区信息 - UPDATE public.communities - SET - description = COALESCE(p_description, description), - welcome_message = COALESCE(p_welcome_message, welcome_message), - rules = COALESCE(p_rules, rules), - requires_approval = COALESCE(p_requires_approval, requires_approval), - updated_at = NOW() - WHERE id = p_community_id; - - -- 记录日志 - INSERT INTO public.admin_action_logs (community_id, admin_id, action_type, details) - VALUES (p_community_id, v_admin_id, 'edit_community', - json_build_object( - 'description', p_description, - 'welcome_message', p_welcome_message, - 'requires_approval', p_requires_approval - )); - - RETURN json_build_object('success', true, 'message', 'Community updated'); -END; -$$; - -COMMENT ON FUNCTION public.update_community_info IS '更新社区信息(仅管理员)'; - --- ===================================================== --- 10. RPC: 移除社区成员 --- ===================================================== - -CREATE OR REPLACE FUNCTION public.remove_community_member( - p_community_id TEXT, - p_user_id UUID, - p_reason TEXT DEFAULT NULL -) -RETURNS JSON -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -DECLARE - v_admin_id UUID := auth.uid(); - v_username TEXT; -BEGIN - -- 检查权限 - IF NOT public.is_community_admin(p_community_id, v_admin_id) THEN - RETURN json_build_object('success', false, 'message', 'Permission denied'); - END IF; - - -- 不能移除管理员 - IF public.is_community_admin(p_community_id, p_user_id) THEN - RETURN json_build_object('success', false, 'message', 'Cannot remove admin'); - END IF; - - -- 获取用户名 - SELECT username INTO v_username FROM public.profiles WHERE id = p_user_id; - - -- 删除成员 - DELETE FROM public.user_community_memberships - WHERE community_id = p_community_id AND user_id = p_user_id; - - IF NOT FOUND THEN - RETURN json_build_object('success', false, 'message', 'User is not a member'); - END IF; - - -- 更新成员数 - UPDATE public.communities - SET member_count = GREATEST(0, member_count - 1), updated_at = NOW() - WHERE id = p_community_id; - - -- 记录日志 - INSERT INTO public.admin_action_logs (community_id, admin_id, action_type, target_user_id, details) - VALUES (p_community_id, v_admin_id, 'remove_member', p_user_id, - json_build_object('username', v_username, 'reason', p_reason)); - - RETURN json_build_object('success', true, 'message', 'Member removed'); -END; -$$; - -COMMENT ON FUNCTION public.remove_community_member IS '移除社区成员(仅管理员)'; - --- ===================================================== --- 11. RPC: 给活动参与者发放积分 --- ===================================================== - -CREATE OR REPLACE FUNCTION public.grant_event_credits( - p_event_id UUID, - p_user_ids UUID[], - p_credits_per_user INTEGER, - p_reason TEXT -) -RETURNS JSON -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -DECLARE - v_admin_id UUID := auth.uid(); - v_community_id TEXT; - v_user_id UUID; - v_granted_count INTEGER := 0; -BEGIN - -- 获取活动所属社区 - SELECT community_id INTO v_community_id - FROM public.community_events - WHERE id = p_event_id; - - IF NOT FOUND THEN - RETURN json_build_object('success', false, 'message', 'Event not found'); - END IF; - - -- 检查权限(必须是社区管理员或活动创建者) - IF NOT ( - public.is_community_admin(v_community_id, v_admin_id) OR - EXISTS (SELECT 1 FROM public.community_events WHERE id = p_event_id AND created_by = v_admin_id) - ) THEN - RETURN json_build_object('success', false, 'message', 'Permission denied'); - END IF; - - -- 验证积分数量 - IF p_credits_per_user <= 0 OR p_credits_per_user > 1000 THEN - RETURN json_build_object('success', false, 'message', 'Invalid credit amount (must be 1-1000)'); - END IF; - - -- 为每个用户发放积分 - FOREACH v_user_id IN ARRAY p_user_ids LOOP - -- 检查用户是否报名了该活动 - IF EXISTS ( - SELECT 1 FROM public.event_registrations - WHERE event_id = p_event_id AND user_id = v_user_id - ) THEN - -- 增加积分 - UPDATE public.profiles - SET credits = credits + p_credits_per_user - WHERE id = v_user_id; - - -- 记录发放历史 - INSERT INTO public.credit_grants (user_id, granted_by, community_id, event_id, amount, reason) - VALUES (v_user_id, v_admin_id, v_community_id, p_event_id, p_credits_per_user, p_reason); - - v_granted_count := v_granted_count + 1; - END IF; - END LOOP; - - -- 记录管理员操作日志 - INSERT INTO public.admin_action_logs (community_id, admin_id, action_type, target_event_id, details) - VALUES (v_community_id, v_admin_id, 'grant_credits', p_event_id, - json_build_object( - 'user_count', v_granted_count, - 'credits_per_user', p_credits_per_user, - 'total_credits', v_granted_count * p_credits_per_user, - 'reason', p_reason - )); - - RETURN json_build_object( - 'success', true, - 'message', format('Credits granted to %s users', v_granted_count), - 'granted_count', v_granted_count - ); -END; -$$; - -COMMENT ON FUNCTION public.grant_event_credits IS '为活动参与者批量发放积分(仅管理员)'; - --- ===================================================== --- 12. RPC: 获取社区成员列表(管理员视图) --- ===================================================== - -CREATE OR REPLACE FUNCTION public.get_community_members_admin( - p_community_id TEXT -) -RETURNS TABLE ( - user_id UUID, - username TEXT, - credits INTEGER, - status TEXT, - joined_at TIMESTAMPTZ, - is_admin BOOLEAN -) -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -BEGIN - -- 检查权限 - IF NOT public.is_community_admin(p_community_id, auth.uid()) THEN - RAISE EXCEPTION 'Permission denied: Only admins can view member details'; - END IF; - - RETURN QUERY - SELECT - m.user_id, - COALESCE(p.username, 'Anonymous')::TEXT AS username, - COALESCE(p.credits, 0) AS credits, - m.status, - m.joined_at, - (m.status = 'admin') AS is_admin - FROM public.user_community_memberships m - LEFT JOIN public.profiles p ON m.user_id = p.id - WHERE m.community_id = p_community_id - AND m.status IN ('member', 'admin') - ORDER BY - CASE WHEN m.status = 'admin' THEN 0 ELSE 1 END, - m.joined_at; -END; -$$; - -COMMENT ON FUNCTION public.get_community_members_admin IS '获取社区成员列表(管理员视图,含详细信息)'; - --- ===================================================== --- 13. RPC: 获取管理员操作日志 --- ===================================================== - -CREATE OR REPLACE FUNCTION public.get_admin_action_logs( - p_community_id TEXT, - p_limit INTEGER DEFAULT 50 -) -RETURNS TABLE ( - id UUID, - admin_username TEXT, - action_type TEXT, - target_username TEXT, - details JSONB, - created_at TIMESTAMPTZ -) -LANGUAGE plpgsql SECURITY DEFINER -AS $$ -BEGIN - -- 检查权限 - IF NOT public.is_community_admin(p_community_id, auth.uid()) THEN - RAISE EXCEPTION 'Permission denied: Only admins can view action logs'; - END IF; - - RETURN QUERY - SELECT - l.id, - COALESCE(admin_p.username, 'Unknown')::TEXT AS admin_username, - l.action_type, - COALESCE(target_p.username, NULL)::TEXT AS target_username, - l.details, - l.created_at - FROM public.admin_action_logs l - LEFT JOIN public.profiles admin_p ON l.admin_id = admin_p.id - LEFT JOIN public.profiles target_p ON l.target_user_id = target_p.id - WHERE l.community_id = p_community_id - ORDER BY l.created_at DESC - LIMIT p_limit; -END; -$$; - -COMMENT ON FUNCTION public.get_admin_action_logs IS '获取管理员操作日志(仅管理员)'; - --- ===================================================== --- 14. GRANT PERMISSIONS --- ===================================================== - -GRANT EXECUTE ON FUNCTION public.is_community_admin(TEXT, UUID) TO authenticated; -GRANT EXECUTE ON FUNCTION public.apply_to_join_community(TEXT, TEXT) TO authenticated; -GRANT EXECUTE ON FUNCTION public.get_pending_applications(TEXT) TO authenticated; -GRANT EXECUTE ON FUNCTION public.review_join_application(UUID, BOOLEAN, TEXT) TO authenticated; -GRANT EXECUTE ON FUNCTION public.update_community_info(TEXT, TEXT, TEXT, TEXT, BOOLEAN) TO authenticated; -GRANT EXECUTE ON FUNCTION public.remove_community_member(TEXT, UUID, TEXT) TO authenticated; -GRANT EXECUTE ON FUNCTION public.grant_event_credits(UUID, UUID[], INTEGER, TEXT) TO authenticated; -GRANT EXECUTE ON FUNCTION public.get_community_members_admin(TEXT) TO authenticated; -GRANT EXECUTE ON FUNCTION public.get_admin_action_logs(TEXT, INTEGER) TO authenticated; - --- ===================================================== --- 15. RLS POLICIES --- ===================================================== - --- 申请表:用户只能看自己的申请,管理员可以看所有申请 -ALTER TABLE public.community_join_applications ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "Users can view own applications" ON public.community_join_applications; -CREATE POLICY "Users can view own applications" -ON public.community_join_applications FOR SELECT -USING ( - auth.uid() = user_id OR - public.is_community_admin(community_id, auth.uid()) -); - --- 操作日志:只有管理员可以查看 -ALTER TABLE public.admin_action_logs ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "Admins can view action logs" ON public.admin_action_logs; -CREATE POLICY "Admins can view action logs" -ON public.admin_action_logs FOR SELECT -USING (public.is_community_admin(community_id, auth.uid())); - --- 积分发放记录:用户可以看自己的,管理员可以看社区的 -ALTER TABLE public.credit_grants ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "Users can view own credit grants" ON public.credit_grants; -CREATE POLICY "Users can view own credit grants" -ON public.credit_grants FOR SELECT -USING ( - auth.uid() = user_id OR - (community_id IS NOT NULL AND public.is_community_admin(community_id, auth.uid())) -); diff --git a/The Trash/migrations/20260209000000_arena_solo_modes.sql b/The Trash/migrations/20260209000000_arena_solo_modes.sql deleted file mode 100644 index 898b519..0000000 --- a/The Trash/migrations/20260209000000_arena_solo_modes.sql +++ /dev/null @@ -1,128 +0,0 @@ --- ============================================================ --- Migration 008: Arena Solo Modes (Streak + Speed Sort support) --- Date: 2026-02-09 --- Description: --- - Create streak_records table for tracking streak game results --- - RPC: get_quiz_questions_batch(p_limit) for variable-count fetches --- - RPC: submit_streak_record(p_streak_count) to record + award points --- - RPC: get_streak_leaderboard(p_limit) for top streaks --- ============================================================ - --- ============================================================ --- PART 1: streak_records table --- ============================================================ - -CREATE TABLE IF NOT EXISTS public.streak_records ( - id UUID DEFAULT gen_random_uuid() PRIMARY KEY, - user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, - streak_count INT NOT NULL, - created_at TIMESTAMPTZ DEFAULT timezone('utc', now()) -); - -ALTER TABLE public.streak_records OWNER TO postgres; - --- Index for leaderboard queries -CREATE INDEX IF NOT EXISTS idx_streak_records_user_id ON public.streak_records(user_id); -CREATE INDEX IF NOT EXISTS idx_streak_records_streak_count ON public.streak_records(streak_count DESC); - --- Enable RLS -ALTER TABLE public.streak_records ENABLE ROW LEVEL SECURITY; - --- Authenticated users can read all streak records (for leaderboard) -CREATE POLICY "Streak records are readable by authenticated users" - ON public.streak_records - FOR SELECT - TO authenticated - USING (true); - --- Users can only insert their own streak records -CREATE POLICY "Users can insert their own streak records" - ON public.streak_records - FOR INSERT - TO authenticated - WITH CHECK (auth.uid() = user_id); - --- ============================================================ --- PART 2: RPC Functions --- ============================================================ - --- get_quiz_questions_batch: fetch variable number of random questions -CREATE OR REPLACE FUNCTION public.get_quiz_questions_batch(p_limit INT DEFAULT 10) -RETURNS SETOF public.quiz_questions -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - RETURN QUERY - SELECT * - FROM public.quiz_questions - WHERE is_active = true - ORDER BY random() - LIMIT p_limit; -END; -$$; - -ALTER FUNCTION public.get_quiz_questions_batch(INT) OWNER TO postgres; - --- submit_streak_record: record streak + award 5 points per correct answer -CREATE OR REPLACE FUNCTION public.submit_streak_record(p_streak_count INT) -RETURNS UUID -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID; - v_record_id UUID; - v_points INT; -BEGIN - v_user_id := auth.uid(); - IF v_user_id IS NULL THEN - RAISE EXCEPTION 'Not authenticated'; - END IF; - - -- Insert streak record - INSERT INTO public.streak_records (user_id, streak_count) - VALUES (v_user_id, p_streak_count) - RETURNING id INTO v_record_id; - - -- Award points: 5 per correct answer - v_points := p_streak_count * 5; - IF v_points > 0 THEN - UPDATE public.profiles - SET credits = credits + v_points - WHERE id = v_user_id; - END IF; - - RETURN v_record_id; -END; -$$; - -ALTER FUNCTION public.submit_streak_record(INT) OWNER TO postgres; - --- get_streak_leaderboard: top streaks with user info -CREATE OR REPLACE FUNCTION public.get_streak_leaderboard(p_limit INT DEFAULT 20) -RETURNS TABLE ( - user_id UUID, - display_name TEXT, - best_streak INT, - total_games BIGINT -) -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - RETURN QUERY - SELECT - sr.user_id, - COALESCE(p.display_name, 'Anonymous') AS display_name, - MAX(sr.streak_count) AS best_streak, - COUNT(sr.id) AS total_games - FROM public.streak_records sr - JOIN public.profiles p ON p.id = sr.user_id - GROUP BY sr.user_id, p.display_name - ORDER BY best_streak DESC, total_games DESC - LIMIT p_limit; -END; -$$; - -ALTER FUNCTION public.get_streak_leaderboard(INT) OWNER TO postgres; diff --git a/The Trash/migrations/20260209010000_daily_challenge.sql b/The Trash/migrations/20260209010000_daily_challenge.sql deleted file mode 100644 index 006a445..0000000 --- a/The Trash/migrations/20260209010000_daily_challenge.sql +++ /dev/null @@ -1,242 +0,0 @@ --- ============================================================ --- Migration 009: Daily Challenge --- Date: 2026-02-09 --- Description: --- - daily_challenges table (one row per day, fixed 10 questions) --- - daily_challenge_results table (one result per user per day) --- - RPC: get_daily_challenge() — get/create today's challenge --- - RPC: submit_daily_challenge() — submit result --- - RPC: get_daily_leaderboard() — daily rankings --- ============================================================ - --- ============================================================ --- PART 1: daily_challenges table --- ============================================================ - -CREATE TABLE IF NOT EXISTS public.daily_challenges ( - id UUID DEFAULT gen_random_uuid() PRIMARY KEY, - challenge_date DATE NOT NULL UNIQUE, - question_ids UUID[] NOT NULL, - created_at TIMESTAMPTZ DEFAULT timezone('utc', now()) -); - -ALTER TABLE public.daily_challenges OWNER TO postgres; - -CREATE INDEX IF NOT EXISTS idx_daily_challenges_date ON public.daily_challenges(challenge_date DESC); - -ALTER TABLE public.daily_challenges ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "Daily challenges are readable by authenticated users" - ON public.daily_challenges - FOR SELECT - TO authenticated - USING (true); - --- ============================================================ --- PART 2: daily_challenge_results table --- ============================================================ - -CREATE TABLE IF NOT EXISTS public.daily_challenge_results ( - id UUID DEFAULT gen_random_uuid() PRIMARY KEY, - user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, - challenge_date DATE NOT NULL, - score INT NOT NULL DEFAULT 0, - correct_count INT NOT NULL DEFAULT 0, - time_seconds DECIMAL NOT NULL DEFAULT 0, - max_combo INT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ DEFAULT timezone('utc', now()), - UNIQUE (user_id, challenge_date) -); - -ALTER TABLE public.daily_challenge_results OWNER TO postgres; - -CREATE INDEX IF NOT EXISTS idx_daily_results_date ON public.daily_challenge_results(challenge_date, score DESC); -CREATE INDEX IF NOT EXISTS idx_daily_results_user ON public.daily_challenge_results(user_id); - -ALTER TABLE public.daily_challenge_results ENABLE ROW LEVEL SECURITY; - --- Users can read all results (for leaderboard) -CREATE POLICY "Daily results are readable by authenticated users" - ON public.daily_challenge_results - FOR SELECT - TO authenticated - USING (true); - --- Users can insert their own results -CREATE POLICY "Users can insert their own daily results" - ON public.daily_challenge_results - FOR INSERT - TO authenticated - WITH CHECK (auth.uid() = user_id); - --- ============================================================ --- PART 3: RPC Functions --- ============================================================ - --- get_daily_challenge: get or create today's challenge -CREATE OR REPLACE FUNCTION public.get_daily_challenge() -RETURNS JSON -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID; - v_today DATE; - v_challenge_id UUID; - v_question_ids UUID[]; - v_already_played BOOLEAN; - v_questions JSON; -BEGIN - v_user_id := auth.uid(); - IF v_user_id IS NULL THEN - RAISE EXCEPTION 'Not authenticated'; - END IF; - - v_today := (timezone('utc', now()))::date; - - -- Try to get existing challenge for today - SELECT id, question_ids INTO v_challenge_id, v_question_ids - FROM public.daily_challenges - WHERE challenge_date = v_today; - - -- Create if not exists - IF v_challenge_id IS NULL THEN - -- Pick 10 random questions - SELECT ARRAY( - SELECT q.id - FROM public.quiz_questions q - WHERE q.is_active = true - ORDER BY random() - LIMIT 10 - ) INTO v_question_ids; - - INSERT INTO public.daily_challenges (challenge_date, question_ids) - VALUES (v_today, v_question_ids) - ON CONFLICT (challenge_date) DO UPDATE SET challenge_date = EXCLUDED.challenge_date - RETURNING id INTO v_challenge_id; - - -- Re-read in case of race condition - SELECT question_ids INTO v_question_ids - FROM public.daily_challenges - WHERE id = v_challenge_id; - END IF; - - -- Check if user already played today - SELECT EXISTS( - SELECT 1 FROM public.daily_challenge_results - WHERE user_id = v_user_id AND challenge_date = v_today - ) INTO v_already_played; - - -- Get questions in order (using unnest with ordinality to preserve array order) - SELECT json_agg(q ORDER BY ord.ordinality) - INTO v_questions - FROM unnest(v_question_ids) WITH ORDINALITY AS ord(qid, ordinality) - JOIN public.quiz_questions q ON q.id = ord.qid; - - RETURN json_build_object( - 'challenge_id', v_challenge_id, - 'challenge_date', v_today, - 'already_played', v_already_played, - 'questions', COALESCE(v_questions, '[]'::json) - ); -END; -$$; - -ALTER FUNCTION public.get_daily_challenge() OWNER TO postgres; - --- submit_daily_challenge: submit result (only once per day) -CREATE OR REPLACE FUNCTION public.submit_daily_challenge( - p_score INT, - p_correct_count INT, - p_time_seconds DECIMAL, - p_max_combo INT -) -RETURNS JSON -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID; - v_today DATE; - v_result_id UUID; - v_points INT; -BEGIN - v_user_id := auth.uid(); - IF v_user_id IS NULL THEN - RAISE EXCEPTION 'Not authenticated'; - END IF; - - v_today := (timezone('utc', now()))::date; - - -- Check challenge exists for today - IF NOT EXISTS (SELECT 1 FROM public.daily_challenges WHERE challenge_date = v_today) THEN - RAISE EXCEPTION 'No daily challenge for today'; - END IF; - - -- Check not already played - IF EXISTS (SELECT 1 FROM public.daily_challenge_results WHERE user_id = v_user_id AND challenge_date = v_today) THEN - RAISE EXCEPTION 'Already completed today''s challenge'; - END IF; - - -- Insert result - INSERT INTO public.daily_challenge_results (user_id, challenge_date, score, correct_count, time_seconds, max_combo) - VALUES (v_user_id, v_today, p_score, p_correct_count, p_time_seconds, p_max_combo) - RETURNING id INTO v_result_id; - - -- Award points (same as score) - v_points := p_score; - IF v_points > 0 THEN - UPDATE public.profiles - SET credits = credits + v_points - WHERE id = v_user_id; - END IF; - - RETURN json_build_object( - 'result_id', v_result_id, - 'points_awarded', v_points - ); -END; -$$; - -ALTER FUNCTION public.submit_daily_challenge(INT, INT, DECIMAL, INT) OWNER TO postgres; - --- get_daily_leaderboard: rankings for a given date -CREATE OR REPLACE FUNCTION public.get_daily_leaderboard( - p_date DATE DEFAULT NULL, - p_limit INT DEFAULT 50 -) -RETURNS TABLE ( - rank BIGINT, - user_id UUID, - display_name TEXT, - score INT, - correct_count INT, - time_seconds DECIMAL, - max_combo INT -) -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_date DATE; -BEGIN - v_date := COALESCE(p_date, (timezone('utc', now()))::date); - - RETURN QUERY - SELECT - ROW_NUMBER() OVER (ORDER BY dr.score DESC, dr.time_seconds ASC) AS rank, - dr.user_id, - COALESCE(p.display_name, 'Anonymous') AS display_name, - dr.score, - dr.correct_count, - dr.time_seconds, - dr.max_combo - FROM public.daily_challenge_results dr - JOIN public.profiles p ON p.id = dr.user_id - WHERE dr.challenge_date = v_date - ORDER BY dr.score DESC, dr.time_seconds ASC - LIMIT p_limit; -END; -$$; - -ALTER FUNCTION public.get_daily_leaderboard(DATE, INT) OWNER TO postgres; diff --git a/The Trash/migrations/20260209020000_arena_duel.sql b/The Trash/migrations/20260209020000_arena_duel.sql deleted file mode 100644 index f5adbaf..0000000 --- a/The Trash/migrations/20260209020000_arena_duel.sql +++ /dev/null @@ -1,529 +0,0 @@ --- ============================================================ --- Migration 010: Arena Duel (1v1 Realtime) --- Date: 2026-02-09 --- Description: --- - arena_challenges table (duel sessions) --- - arena_challenge_answers table (server-side answer verification) --- - RPCs for full duel lifecycle --- ============================================================ - --- ============================================================ --- PART 1: arena_challenges table --- ============================================================ - -CREATE TABLE IF NOT EXISTS public.arena_challenges ( - id UUID DEFAULT gen_random_uuid() PRIMARY KEY, - challenger_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, - opponent_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, - status TEXT NOT NULL DEFAULT 'pending' - CHECK (status IN ('pending', 'accepted', 'in_progress', 'completed', 'expired', 'declined', 'cancelled')), - question_ids UUID[] NOT NULL, - channel_name TEXT UNIQUE, - challenger_score INT DEFAULT 0, - opponent_score INT DEFAULT 0, - winner_id UUID REFERENCES public.profiles(id), - created_at TIMESTAMPTZ DEFAULT timezone('utc', now()), - expires_at TIMESTAMPTZ DEFAULT timezone('utc', now()) + INTERVAL '10 minutes', - started_at TIMESTAMPTZ, - completed_at TIMESTAMPTZ -); - -ALTER TABLE public.arena_challenges OWNER TO postgres; - -CREATE INDEX IF NOT EXISTS idx_challenges_challenger ON public.arena_challenges(challenger_id, status); -CREATE INDEX IF NOT EXISTS idx_challenges_opponent ON public.arena_challenges(opponent_id, status); -CREATE INDEX IF NOT EXISTS idx_challenges_status ON public.arena_challenges(status); -CREATE INDEX IF NOT EXISTS idx_challenges_channel ON public.arena_challenges(channel_name); - -ALTER TABLE public.arena_challenges ENABLE ROW LEVEL SECURITY; - --- Users can only see challenges they're part of -CREATE POLICY "Users can view their own challenges" - ON public.arena_challenges - FOR SELECT - TO authenticated - USING (auth.uid() = challenger_id OR auth.uid() = opponent_id); - --- ============================================================ --- PART 2: arena_challenge_answers table --- ============================================================ - -CREATE TABLE IF NOT EXISTS public.arena_challenge_answers ( - id UUID DEFAULT gen_random_uuid() PRIMARY KEY, - challenge_id UUID NOT NULL REFERENCES public.arena_challenges(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, - question_index INT NOT NULL, - selected_category TEXT NOT NULL, - is_correct BOOLEAN NOT NULL, - answer_time_ms INT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ DEFAULT timezone('utc', now()), - UNIQUE (challenge_id, user_id, question_index) -); - -ALTER TABLE public.arena_challenge_answers OWNER TO postgres; - -CREATE INDEX IF NOT EXISTS idx_challenge_answers_challenge ON public.arena_challenge_answers(challenge_id, user_id); - -ALTER TABLE public.arena_challenge_answers ENABLE ROW LEVEL SECURITY; - --- Users can see answers for challenges they're part of -CREATE POLICY "Users can view answers for their challenges" - ON public.arena_challenge_answers - FOR SELECT - TO authenticated - USING ( - EXISTS ( - SELECT 1 FROM public.arena_challenges ac - WHERE ac.id = challenge_id - AND (ac.challenger_id = auth.uid() OR ac.opponent_id = auth.uid()) - ) - ); - --- ============================================================ --- PART 3: RPC Functions --- ============================================================ - --- create_arena_challenge: create a new duel challenge -CREATE OR REPLACE FUNCTION public.create_arena_challenge(p_opponent_id UUID) -RETURNS JSON -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID; - v_challenge_id UUID; - v_question_ids UUID[]; - v_channel_name TEXT; -BEGIN - v_user_id := auth.uid(); - IF v_user_id IS NULL THEN - RAISE EXCEPTION 'Not authenticated'; - END IF; - - IF v_user_id = p_opponent_id THEN - RAISE EXCEPTION 'Cannot challenge yourself'; - END IF; - - -- Select 10 random questions - SELECT ARRAY( - SELECT q.id - FROM public.quiz_questions q - WHERE q.is_active = true - ORDER BY random() - LIMIT 10 - ) INTO v_question_ids; - - IF array_length(v_question_ids, 1) < 10 THEN - RAISE EXCEPTION 'Not enough questions available'; - END IF; - - v_challenge_id := gen_random_uuid(); - v_channel_name := 'duel:' || v_challenge_id::text; - - INSERT INTO public.arena_challenges ( - id, challenger_id, opponent_id, status, question_ids, channel_name - ) VALUES ( - v_challenge_id, v_user_id, p_opponent_id, 'pending', v_question_ids, v_channel_name - ); - - RETURN json_build_object( - 'challenge_id', v_challenge_id, - 'channel_name', v_channel_name, - 'status', 'pending' - ); -END; -$$; - -ALTER FUNCTION public.create_arena_challenge(UUID) OWNER TO postgres; - --- accept_arena_challenge: accept a pending challenge -CREATE OR REPLACE FUNCTION public.accept_arena_challenge(p_challenge_id UUID) -RETURNS JSON -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID; - v_challenge RECORD; - v_questions JSON; -BEGIN - v_user_id := auth.uid(); - IF v_user_id IS NULL THEN - RAISE EXCEPTION 'Not authenticated'; - END IF; - - -- Get challenge - SELECT * INTO v_challenge - FROM public.arena_challenges - WHERE id = p_challenge_id; - - IF v_challenge IS NULL THEN - RAISE EXCEPTION 'Challenge not found'; - END IF; - - IF v_challenge.opponent_id != v_user_id AND v_challenge.challenger_id != v_user_id THEN - RAISE EXCEPTION 'Not your challenge'; - END IF; - - IF v_challenge.status != 'pending' THEN - RAISE EXCEPTION 'Challenge is no longer pending (status: %)', v_challenge.status; - END IF; - - -- Check expiry - IF v_challenge.expires_at < timezone('utc', now()) THEN - UPDATE public.arena_challenges SET status = 'expired' WHERE id = p_challenge_id; - RAISE EXCEPTION 'Challenge has expired'; - END IF; - - -- Accept - UPDATE public.arena_challenges - SET status = 'accepted' - WHERE id = p_challenge_id; - - -- Get questions in order - SELECT json_agg(q ORDER BY ord.ordinality) - INTO v_questions - FROM unnest(v_challenge.question_ids) WITH ORDINALITY AS ord(qid, ordinality) - JOIN public.quiz_questions q ON q.id = ord.qid; - - RETURN json_build_object( - 'challenge_id', p_challenge_id, - 'channel_name', v_challenge.channel_name, - 'questions', COALESCE(v_questions, '[]'::json), - 'challenger_id', v_challenge.challenger_id, - 'opponent_id', v_challenge.opponent_id - ); -END; -$$; - -ALTER FUNCTION public.accept_arena_challenge(UUID) OWNER TO postgres; - --- decline_arena_challenge: decline or cancel a challenge -CREATE OR REPLACE FUNCTION public.decline_arena_challenge(p_challenge_id UUID) -RETURNS VOID -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID; - v_challenge RECORD; -BEGIN - v_user_id := auth.uid(); - IF v_user_id IS NULL THEN - RAISE EXCEPTION 'Not authenticated'; - END IF; - - SELECT * INTO v_challenge - FROM public.arena_challenges - WHERE id = p_challenge_id; - - IF v_challenge IS NULL THEN - RAISE EXCEPTION 'Challenge not found'; - END IF; - - IF v_challenge.challenger_id != v_user_id AND v_challenge.opponent_id != v_user_id THEN - RAISE EXCEPTION 'Not your challenge'; - END IF; - - IF v_challenge.status NOT IN ('pending', 'accepted') THEN - RAISE EXCEPTION 'Cannot decline challenge in status: %', v_challenge.status; - END IF; - - IF v_challenge.challenger_id = v_user_id THEN - UPDATE public.arena_challenges SET status = 'cancelled' WHERE id = p_challenge_id; - ELSE - UPDATE public.arena_challenges SET status = 'declined' WHERE id = p_challenge_id; - END IF; -END; -$$; - -ALTER FUNCTION public.decline_arena_challenge(UUID) OWNER TO postgres; - --- submit_duel_answer: submit + verify a single answer -CREATE OR REPLACE FUNCTION public.submit_duel_answer( - p_challenge_id UUID, - p_question_index INT, - p_selected_category TEXT, - p_answer_time_ms INT -) -RETURNS JSON -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID; - v_challenge RECORD; - v_question_id UUID; - v_correct_category TEXT; - v_is_correct BOOLEAN; -BEGIN - v_user_id := auth.uid(); - IF v_user_id IS NULL THEN - RAISE EXCEPTION 'Not authenticated'; - END IF; - - -- Get challenge - SELECT * INTO v_challenge - FROM public.arena_challenges - WHERE id = p_challenge_id; - - IF v_challenge IS NULL THEN - RAISE EXCEPTION 'Challenge not found'; - END IF; - - IF v_challenge.challenger_id != v_user_id AND v_challenge.opponent_id != v_user_id THEN - RAISE EXCEPTION 'Not your challenge'; - END IF; - - IF v_challenge.status NOT IN ('accepted', 'in_progress') THEN - RAISE EXCEPTION 'Challenge is not active (status: %)', v_challenge.status; - END IF; - - -- Update to in_progress if needed - IF v_challenge.status = 'accepted' THEN - UPDATE public.arena_challenges - SET status = 'in_progress', started_at = timezone('utc', now()) - WHERE id = p_challenge_id AND status = 'accepted'; - END IF; - - -- Get the question at this index - v_question_id := v_challenge.question_ids[p_question_index + 1]; -- 1-indexed array - - IF v_question_id IS NULL THEN - RAISE EXCEPTION 'Invalid question index: %', p_question_index; - END IF; - - -- Get correct answer - SELECT correct_category INTO v_correct_category - FROM public.quiz_questions - WHERE id = v_question_id; - - v_is_correct := (p_selected_category = v_correct_category); - - -- Insert answer (upsert to handle retries) - INSERT INTO public.arena_challenge_answers ( - challenge_id, user_id, question_index, selected_category, is_correct, answer_time_ms - ) VALUES ( - p_challenge_id, v_user_id, p_question_index, p_selected_category, v_is_correct, p_answer_time_ms - ) - ON CONFLICT (challenge_id, user_id, question_index) DO NOTHING; - - RETURN json_build_object( - 'is_correct', v_is_correct, - 'correct_category', v_correct_category, - 'question_index', p_question_index - ); -END; -$$; - -ALTER FUNCTION public.submit_duel_answer(UUID, INT, TEXT, INT) OWNER TO postgres; - --- complete_arena_challenge: finalize scores and award points (idempotent) -CREATE OR REPLACE FUNCTION public.complete_arena_challenge(p_challenge_id UUID) -RETURNS JSON -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID; - v_challenge RECORD; - v_challenger_correct INT; - v_opponent_correct INT; - v_challenger_score INT; - v_opponent_score INT; - v_winner_id UUID; - v_challenger_points INT; - v_opponent_points INT; -BEGIN - v_user_id := auth.uid(); - IF v_user_id IS NULL THEN - RAISE EXCEPTION 'Not authenticated'; - END IF; - - SELECT * INTO v_challenge - FROM public.arena_challenges - WHERE id = p_challenge_id; - - IF v_challenge IS NULL THEN - RAISE EXCEPTION 'Challenge not found'; - END IF; - - IF v_challenge.challenger_id != v_user_id AND v_challenge.opponent_id != v_user_id THEN - RAISE EXCEPTION 'Not your challenge'; - END IF; - - -- If already completed, return existing result - IF v_challenge.status = 'completed' THEN - RETURN json_build_object( - 'challenge_id', p_challenge_id, - 'challenger_score', v_challenge.challenger_score, - 'opponent_score', v_challenge.opponent_score, - 'winner_id', v_challenge.winner_id, - 'already_completed', true - ); - END IF; - - -- Calculate scores (20 points per correct answer) - SELECT COUNT(*) FILTER (WHERE is_correct) INTO v_challenger_correct - FROM public.arena_challenge_answers - WHERE challenge_id = p_challenge_id AND user_id = v_challenge.challenger_id; - - SELECT COUNT(*) FILTER (WHERE is_correct) INTO v_opponent_correct - FROM public.arena_challenge_answers - WHERE challenge_id = p_challenge_id AND user_id = v_challenge.opponent_id; - - v_challenger_score := v_challenger_correct * 20; - v_opponent_score := v_opponent_correct * 20; - - -- Determine winner - IF v_challenger_score > v_opponent_score THEN - v_winner_id := v_challenge.challenger_id; - ELSIF v_opponent_score > v_challenger_score THEN - v_winner_id := v_challenge.opponent_id; - ELSE - v_winner_id := NULL; -- tie - END IF; - - -- Award points: winner 50, loser 10, tie each 30 - IF v_winner_id IS NULL THEN - v_challenger_points := 30; - v_opponent_points := 30; - ELSIF v_winner_id = v_challenge.challenger_id THEN - v_challenger_points := 50; - v_opponent_points := 10; - ELSE - v_challenger_points := 10; - v_opponent_points := 50; - END IF; - - -- Update challenge - UPDATE public.arena_challenges - SET - status = 'completed', - challenger_score = v_challenger_score, - opponent_score = v_opponent_score, - winner_id = v_winner_id, - completed_at = timezone('utc', now()) - WHERE id = p_challenge_id AND status != 'completed'; - - -- Award credits - UPDATE public.profiles SET credits = credits + v_challenger_points WHERE id = v_challenge.challenger_id; - UPDATE public.profiles SET credits = credits + v_opponent_points WHERE id = v_challenge.opponent_id; - - RETURN json_build_object( - 'challenge_id', p_challenge_id, - 'challenger_score', v_challenger_score, - 'opponent_score', v_opponent_score, - 'winner_id', v_winner_id, - 'challenger_points', v_challenger_points, - 'opponent_points', v_opponent_points, - 'already_completed', false - ); -END; -$$; - -ALTER FUNCTION public.complete_arena_challenge(UUID) OWNER TO postgres; - --- get_my_challenges: list challenges for the current user -CREATE OR REPLACE FUNCTION public.get_my_challenges(p_status TEXT DEFAULT NULL) -RETURNS JSON -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID; - v_result JSON; -BEGIN - v_user_id := auth.uid(); - IF v_user_id IS NULL THEN - RAISE EXCEPTION 'Not authenticated'; - END IF; - - -- Expire old pending challenges - UPDATE public.arena_challenges - SET status = 'expired' - WHERE status = 'pending' - AND expires_at < timezone('utc', now()); - - SELECT json_agg(row_to_json(t)) - INTO v_result - FROM ( - SELECT - ac.id, - ac.challenger_id, - ac.opponent_id, - ac.status, - ac.challenger_score, - ac.opponent_score, - ac.winner_id, - ac.channel_name, - ac.created_at, - ac.expires_at, - ac.started_at, - ac.completed_at, - cp.display_name AS challenger_name, - op.display_name AS opponent_name - FROM public.arena_challenges ac - JOIN public.profiles cp ON cp.id = ac.challenger_id - JOIN public.profiles op ON op.id = ac.opponent_id - WHERE (ac.challenger_id = v_user_id OR ac.opponent_id = v_user_id) - AND (p_status IS NULL OR ac.status = p_status) - ORDER BY ac.created_at DESC - LIMIT 50 - ) t; - - RETURN COALESCE(v_result, '[]'::json); -END; -$$; - -ALTER FUNCTION public.get_my_challenges(TEXT) OWNER TO postgres; - --- get_challenge_questions: get questions for an accepted challenge (for challenger) -CREATE OR REPLACE FUNCTION public.get_challenge_questions(p_challenge_id UUID) -RETURNS JSON -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID; - v_challenge RECORD; - v_questions JSON; -BEGIN - v_user_id := auth.uid(); - IF v_user_id IS NULL THEN - RAISE EXCEPTION 'Not authenticated'; - END IF; - - SELECT * INTO v_challenge - FROM public.arena_challenges - WHERE id = p_challenge_id; - - IF v_challenge IS NULL THEN - RAISE EXCEPTION 'Challenge not found'; - END IF; - - IF v_challenge.challenger_id != v_user_id AND v_challenge.opponent_id != v_user_id THEN - RAISE EXCEPTION 'Not your challenge'; - END IF; - - IF v_challenge.status NOT IN ('accepted', 'in_progress') THEN - RAISE EXCEPTION 'Challenge is not ready for play'; - END IF; - - -- Get questions in order - SELECT json_agg(q ORDER BY ord.ordinality) - INTO v_questions - FROM unnest(v_challenge.question_ids) WITH ORDINALITY AS ord(qid, ordinality) - JOIN public.quiz_questions q ON q.id = ord.qid; - - RETURN json_build_object( - 'challenge_id', p_challenge_id, - 'channel_name', v_challenge.channel_name, - 'questions', COALESCE(v_questions, '[]'::json), - 'challenger_id', v_challenge.challenger_id, - 'opponent_id', v_challenge.opponent_id - ); -END; -$$; - -ALTER FUNCTION public.get_challenge_questions(UUID) OWNER TO postgres; diff --git a/The Trash/migrations/20260209030000_fix_column_names_and_seed.sql b/The Trash/migrations/20260209030000_fix_column_names_and_seed.sql deleted file mode 100644 index 6ee4758..0000000 --- a/The Trash/migrations/20260209030000_fix_column_names_and_seed.sql +++ /dev/null @@ -1,175 +0,0 @@ --- ============================================================ --- Migration 011: Fix display_name → username + Seed quiz data --- Date: 2026-02-09 --- Description: --- - Fix all RPC functions that reference display_name to use username --- - Seed quiz_questions with real trash sorting questions --- ============================================================ - --- ============================================================ --- PART 1: Fix RPC functions (display_name → username) --- ============================================================ - --- Fix get_streak_leaderboard -CREATE OR REPLACE FUNCTION public.get_streak_leaderboard(p_limit INT DEFAULT 20) -RETURNS TABLE ( - user_id UUID, - display_name TEXT, - best_streak INT, - total_games BIGINT -) -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - RETURN QUERY - SELECT - sr.user_id, - COALESCE(p.username, 'Anonymous') AS display_name, - MAX(sr.streak_count) AS best_streak, - COUNT(sr.id) AS total_games - FROM public.streak_records sr - JOIN public.profiles p ON p.id = sr.user_id - GROUP BY sr.user_id, p.username - ORDER BY best_streak DESC, total_games DESC - LIMIT p_limit; -END; -$$; - --- Fix get_daily_leaderboard -CREATE OR REPLACE FUNCTION public.get_daily_leaderboard( - p_date DATE DEFAULT NULL, - p_limit INT DEFAULT 50 -) -RETURNS TABLE ( - rank BIGINT, - user_id UUID, - display_name TEXT, - score INT, - correct_count INT, - time_seconds DECIMAL, - max_combo INT -) -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_date DATE; -BEGIN - v_date := COALESCE(p_date, (timezone('utc', now()))::date); - - RETURN QUERY - SELECT - ROW_NUMBER() OVER (ORDER BY dr.score DESC, dr.time_seconds ASC) AS rank, - dr.user_id, - COALESCE(p.username, 'Anonymous') AS display_name, - dr.score, - dr.correct_count, - dr.time_seconds, - dr.max_combo - FROM public.daily_challenge_results dr - JOIN public.profiles p ON p.id = dr.user_id - WHERE dr.challenge_date = v_date - ORDER BY dr.score DESC, dr.time_seconds ASC - LIMIT p_limit; -END; -$$; - --- Fix get_my_challenges -CREATE OR REPLACE FUNCTION public.get_my_challenges(p_status TEXT DEFAULT NULL) -RETURNS JSON -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID; - v_result JSON; -BEGIN - v_user_id := auth.uid(); - IF v_user_id IS NULL THEN - RAISE EXCEPTION 'Not authenticated'; - END IF; - - -- Expire old pending challenges - UPDATE public.arena_challenges - SET status = 'expired' - WHERE status = 'pending' - AND expires_at < timezone('utc', now()); - - SELECT json_agg(row_to_json(t)) - INTO v_result - FROM ( - SELECT - ac.id, - ac.challenger_id, - ac.opponent_id, - ac.status, - ac.challenger_score, - ac.opponent_score, - ac.winner_id, - ac.channel_name, - ac.created_at, - ac.expires_at, - ac.started_at, - ac.completed_at, - cp.username AS challenger_name, - op.username AS opponent_name - FROM public.arena_challenges ac - JOIN public.profiles cp ON cp.id = ac.challenger_id - JOIN public.profiles op ON op.id = ac.opponent_id - WHERE (ac.challenger_id = v_user_id OR ac.opponent_id = v_user_id) - AND (p_status IS NULL OR ac.status = p_status) - ORDER BY ac.created_at DESC - LIMIT 50 - ) t; - - RETURN COALESCE(v_result, '[]'::json); -END; -$$; - --- ============================================================ --- PART 2: Seed quiz_questions with real trash sorting questions --- Using public domain / free stock image URLs from Supabase Storage --- or placeholder URLs that the app can display --- ============================================================ - -INSERT INTO public.quiz_questions (image_url, correct_category, item_name, is_active) VALUES --- Recyclable items -('https://images.unsplash.com/photo-1572949645841-094ead53d9a7?w=400', 'Recyclable', 'Plastic Bottle', true), -('https://images.unsplash.com/photo-1558618666-fcd25c85f82e?w=400', 'Recyclable', 'Aluminum Can', true), -('https://images.unsplash.com/photo-1589927986089-35812388d1f4?w=400', 'Recyclable', 'Cardboard Box', true), -('https://images.unsplash.com/photo-1585386959984-a4155224a1ad?w=400', 'Recyclable', 'Glass Jar', true), -('https://images.unsplash.com/photo-1600298882525-5107bb87cf64?w=400', 'Recyclable', 'Newspaper', true), -('https://images.unsplash.com/photo-1523293836414-f04e712e1f3b?w=400', 'Recyclable', 'Plastic Container', true), -('https://images.unsplash.com/photo-1619642751034-765dfdf7c58e?w=400', 'Recyclable', 'Tin Can', true), -('https://images.unsplash.com/photo-1530587191325-3db32d826c18?w=400', 'Recyclable', 'Paper Bag', true), - --- Compostable items -('https://images.unsplash.com/photo-1571771894821-ce9b6c11b08e?w=400', 'Compostable', 'Banana Peel', true), -('https://images.unsplash.com/photo-1582515073490-39981397c445?w=400', 'Compostable', 'Apple Core', true), -('https://images.unsplash.com/photo-1540420773420-3366772f4999?w=400', 'Compostable', 'Salad Leaves', true), -('https://images.unsplash.com/photo-1516594798947-e65505dbb29d?w=400', 'Compostable', 'Egg Shells', true), -('https://images.unsplash.com/photo-1601004890684-d8573e12a8da?w=400', 'Compostable', 'Coffee Grounds', true), -('https://images.unsplash.com/photo-1587049352846-4a222e784d38?w=400', 'Compostable', 'Orange Peel', true), -('https://images.unsplash.com/photo-1574323347407-f5e1ad6d020b?w=400', 'Compostable', 'Tea Bag', true), -('https://images.unsplash.com/photo-1615485290382-441e4d049cb5?w=400', 'Compostable', 'Bread Slice', true), - --- Hazardous items -('https://images.unsplash.com/photo-1619641805634-8d29b4024a95?w=400', 'Hazardous', 'Battery', true), -('https://images.unsplash.com/photo-1558618666-fcd25c85f82e?w=400&q=80', 'Hazardous', 'Paint Can', true), -('https://images.unsplash.com/photo-1583947215259-38e31be8751f?w=400', 'Hazardous', 'Light Bulb', true), -('https://images.unsplash.com/photo-1612538498456-e861df91d4d0?w=400', 'Hazardous', 'Motor Oil', true), -('https://images.unsplash.com/photo-1585435557343-3b092031a831?w=400', 'Hazardous', 'Cleaning Chemicals', true), -('https://images.unsplash.com/photo-1587854692152-cbe660dbde88?w=400', 'Hazardous', 'Medicine Bottle', true), -('https://images.unsplash.com/photo-1609592424614-0ac4c5db0f5e?w=400', 'Hazardous', 'Aerosol Can', true), -('https://images.unsplash.com/photo-1558618666-fcd25c85f82e?w=400&q=60', 'Hazardous', 'Pesticide', true), - --- Landfill items -('https://images.unsplash.com/photo-1558171013-2846a3057b6b?w=400', 'Landfill', 'Chip Bag', true), -('https://images.unsplash.com/photo-1605001011156-cbf0b0f67a51?w=400', 'Landfill', 'Styrofoam Cup', true), -('https://images.unsplash.com/photo-1581783898377-1c85bf937427?w=400', 'Landfill', 'Diaper', true), -('https://images.unsplash.com/photo-1558171013-2846a3057b6b?w=400&q=80', 'Landfill', 'Plastic Wrap', true), -('https://images.unsplash.com/photo-1600585152220-90363fe7e115?w=400', 'Landfill', 'Broken Ceramic', true), -('https://images.unsplash.com/photo-1622226119165-68fad54b2b77?w=400', 'Landfill', 'Used Tissue', true), -('https://images.unsplash.com/photo-1571210862729-78a52d3779a2?w=400', 'Landfill', 'Rubber Gloves', true), -('https://images.unsplash.com/photo-1567538096630-e0c55bd6374c?w=400', 'Landfill', 'Candy Wrapper', true); diff --git a/The Trash/migrations/20260209040000_challenge_expiry_and_unique.sql b/The Trash/migrations/20260209040000_challenge_expiry_and_unique.sql deleted file mode 100644 index 4915c59..0000000 --- a/The Trash/migrations/20260209040000_challenge_expiry_and_unique.sql +++ /dev/null @@ -1,107 +0,0 @@ --- ============================================================ --- Migration 012: Challenge expiry 1 min + unique pending constraint --- Date: 2026-02-09 --- Description: --- - Change challenge expiry from 10 minutes to 1 minute --- - Only allow one pending challenge per challenger→opponent pair --- ============================================================ - --- 1. Change default expiry to 1 minute for new rows -ALTER TABLE public.arena_challenges -ALTER COLUMN expires_at SET DEFAULT timezone('utc', now()) + INTERVAL '1 minute'; - --- 2. Expire all stale pending challenges first -UPDATE public.arena_challenges -SET status = 'expired' -WHERE status = 'pending' -AND expires_at < timezone('utc', now()); - --- 3. Deduplicate remaining pending challenges (keep the newest, expire the rest) -UPDATE public.arena_challenges -SET status = 'expired' -WHERE id IN ( - SELECT id FROM ( - SELECT id, - ROW_NUMBER() OVER ( - PARTITION BY challenger_id, opponent_id - ORDER BY created_at DESC - ) AS rn - FROM public.arena_challenges - WHERE status = 'pending' - ) sub - WHERE rn > 1 -); - --- 4. Now safe to create the partial unique index -CREATE UNIQUE INDEX IF NOT EXISTS idx_challenges_unique_pending -ON public.arena_challenges(challenger_id, opponent_id) -WHERE status = 'pending'; - --- 5. Update create_arena_challenge: 1 min expiry + duplicate check -CREATE OR REPLACE FUNCTION public.create_arena_challenge(p_opponent_id UUID) -RETURNS JSON -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID; - v_challenge_id UUID; - v_question_ids UUID[]; - v_channel_name TEXT; -BEGIN - v_user_id := auth.uid(); - IF v_user_id IS NULL THEN - RAISE EXCEPTION 'Not authenticated'; - END IF; - - IF v_user_id = p_opponent_id THEN - RAISE EXCEPTION 'Cannot challenge yourself'; - END IF; - - -- Expire stale pending challenges first - UPDATE public.arena_challenges - SET status = 'expired' - WHERE status = 'pending' - AND expires_at < timezone('utc', now()); - - -- Check for existing pending challenge to this opponent - IF EXISTS ( - SELECT 1 FROM public.arena_challenges - WHERE challenger_id = v_user_id - AND opponent_id = p_opponent_id - AND status = 'pending' - ) THEN - RAISE EXCEPTION 'You already have a pending challenge to this player'; - END IF; - - -- Select 10 random questions - SELECT ARRAY( - SELECT q.id - FROM public.quiz_questions q - WHERE q.is_active = true - ORDER BY random() - LIMIT 10 - ) INTO v_question_ids; - - IF array_length(v_question_ids, 1) < 10 THEN - RAISE EXCEPTION 'Not enough questions available'; - END IF; - - v_challenge_id := gen_random_uuid(); - v_channel_name := 'duel:' || v_challenge_id::text; - - INSERT INTO public.arena_challenges ( - id, challenger_id, opponent_id, status, question_ids, channel_name, - expires_at - ) VALUES ( - v_challenge_id, v_user_id, p_opponent_id, 'pending', v_question_ids, v_channel_name, - timezone('utc', now()) + INTERVAL '1 minute' - ); - - RETURN json_build_object( - 'challenge_id', v_challenge_id, - 'channel_name', v_channel_name, - 'status', 'pending' - ); -END; -$$; diff --git a/The Trash/migrations/20260212013000_security_hardening.sql b/The Trash/migrations/20260212013000_security_hardening.sql deleted file mode 100644 index 5712dc7..0000000 --- a/The Trash/migrations/20260212013000_security_hardening.sql +++ /dev/null @@ -1,479 +0,0 @@ --- ============================================================ --- Migration: 20260212013000_security_hardening.sql --- Goal: --- * Eliminate Supabase advisor warnings around mutable search_path --- * Centralize auth.uid() access through a stable helper for RLS policies --- * Tighten row-level security on high-traffic tables so only authenticated --- users (or community admins) can read/write sensitive rows --- * Replace permissive feedback_logs policy with owner-scoped access --- ============================================================ - --- 1) Force every public function to run with a deterministic search_path. -DO $$ -DECLARE - rec RECORD; -BEGIN - FOR rec IN - SELECT p.oid::regprocedure AS func_name - FROM pg_proc p - JOIN pg_namespace n ON n.oid = p.pronamespace - WHERE n.nspname = 'public' - AND p.prokind = 'f' - LOOP - EXECUTE format('ALTER FUNCTION %s SET search_path = public, pg_temp;', rec.func_name); - END LOOP; -END $$; - --- 2) Provide a stable helper so RLS policies do not call auth.uid() repeatedly. -CREATE OR REPLACE FUNCTION public.current_user_id() -RETURNS uuid -LANGUAGE sql -STABLE -SECURITY DEFINER -SET search_path = public -AS $$ - SELECT auth.uid(); -$$; - -ALTER FUNCTION public.current_user_id() OWNER TO postgres; -GRANT EXECUTE ON FUNCTION public.current_user_id() TO authenticated; - --- ============================================================ --- Feedback logs: restrict inserts/reads to the owning user. --- ============================================================ - -ALTER TABLE public.feedback_logs ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "Enable insert for everyone" ON public.feedback_logs; -DROP POLICY IF EXISTS "Feedback readable" ON public.feedback_logs; -DROP POLICY IF EXISTS "Feedback self-manage" ON public.feedback_logs; - -CREATE POLICY "Feedback readable" - ON public.feedback_logs - FOR SELECT - TO authenticated - USING (user_id = public.current_user_id()); - -CREATE POLICY "Feedback self-manage" - ON public.feedback_logs - FOR ALL - TO authenticated - USING (user_id = public.current_user_id()) - WITH CHECK (user_id = public.current_user_id()); - --- ============================================================ --- Communities & memberships --- ============================================================ - -ALTER TABLE public.communities ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.user_community_memberships ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "Communities are viewable by everyone" ON public.communities; -DROP POLICY IF EXISTS "Users can view their created communities" ON public.communities; -DROP POLICY IF EXISTS "Users can update their own communities" ON public.communities; -DROP POLICY IF EXISTS "Authenticated users can create communities" ON public.communities; - -CREATE POLICY "Communities readable (authenticated)" - ON public.communities - FOR SELECT - TO authenticated - USING ( - public.current_user_id() IS NOT NULL - AND ( - COALESCE(is_private, false) = false - OR created_by = public.current_user_id() - OR EXISTS ( - SELECT 1 - FROM public.user_community_memberships m - WHERE m.community_id = public.communities.id - AND m.user_id = public.current_user_id() - AND m.status IN ('member', 'admin') - ) - ) - ); - -CREATE POLICY "Communities insert (authenticated)" - ON public.communities - FOR INSERT - TO authenticated - WITH CHECK ( - created_by = public.current_user_id() - AND public.current_user_id() IS NOT NULL - ); - -CREATE POLICY "Communities update own" - ON public.communities - FOR UPDATE - TO authenticated - USING ( - created_by = public.current_user_id() - OR EXISTS ( - SELECT 1 - FROM public.user_community_memberships m - WHERE m.community_id = public.communities.id - AND m.user_id = public.current_user_id() - AND m.status = 'admin' - ) - ) - WITH CHECK ( - created_by = public.current_user_id() - OR EXISTS ( - SELECT 1 - FROM public.user_community_memberships m - WHERE m.community_id = public.communities.id - AND m.user_id = public.current_user_id() - AND m.status = 'admin' - ) - ); - -CREATE POLICY "Communities delete own" - ON public.communities - FOR DELETE - TO authenticated - USING ( - created_by = public.current_user_id() - OR EXISTS ( - SELECT 1 - FROM public.user_community_memberships m - WHERE m.community_id = public.communities.id - AND m.user_id = public.current_user_id() - AND m.status = 'admin' - ) - ); - -DROP POLICY IF EXISTS "Users can view all memberships" ON public.user_community_memberships; -DROP POLICY IF EXISTS "Users can manage own memberships" ON public.user_community_memberships; - -CREATE POLICY "Membership roster visibility" - ON public.user_community_memberships - FOR SELECT - TO authenticated - USING ( - user_id = public.current_user_id() - OR EXISTS ( - SELECT 1 - FROM public.user_community_memberships my_membership - WHERE my_membership.user_id = public.current_user_id() - AND my_membership.community_id = public.user_community_memberships.community_id - AND my_membership.status IN ('member', 'admin') - ) - ); - -CREATE POLICY "Membership self-management" - ON public.user_community_memberships - FOR ALL - TO authenticated - USING (user_id = public.current_user_id()) - WITH CHECK (user_id = public.current_user_id()); - --- ============================================================ --- Community events & registrations --- ============================================================ - -ALTER TABLE public.community_events ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.event_registrations ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "Users can view all events" ON public.community_events; -DROP POLICY IF EXISTS "Users can update their own events" ON public.community_events; -DROP POLICY IF EXISTS "Authenticated users can create events" ON public.community_events; - -CREATE POLICY "Events readable (members)" - ON public.community_events - FOR SELECT - TO authenticated - USING ( - public.current_user_id() IS NOT NULL - AND ( - (is_personal IS TRUE AND created_by = public.current_user_id()) - OR (is_personal IS NOT TRUE AND ( - community_id IS NULL - OR EXISTS ( - SELECT 1 - FROM public.communities c - LEFT JOIN public.user_community_memberships m - ON m.community_id = c.id - AND m.user_id = public.current_user_id() - WHERE c.id = public.community_events.community_id - AND ( - COALESCE(c.is_private, false) = false - OR m.status IN ('member','admin') - OR c.created_by = public.current_user_id() - ) - ) - )) - ) - ); - -CREATE POLICY "Events insert (authenticated)" - ON public.community_events - FOR INSERT - TO authenticated - WITH CHECK ( - created_by = public.current_user_id() - AND ( - is_personal IS TRUE - OR community_id IS NULL - OR EXISTS ( - SELECT 1 - FROM public.user_community_memberships m - WHERE m.community_id = community_id - AND m.user_id = public.current_user_id() - AND m.status IN ('member','admin') - ) - ) - ); - -CREATE POLICY "Events update (owner-or-admin)" - ON public.community_events - FOR UPDATE - TO authenticated - USING ( - created_by = public.current_user_id() - OR ( - community_id IS NOT NULL - AND EXISTS ( - SELECT 1 - FROM public.user_community_memberships m - WHERE m.community_id = community_id - AND m.user_id = public.current_user_id() - AND m.status = 'admin' - ) - ) - ) - WITH CHECK ( - created_by = public.current_user_id() - OR ( - community_id IS NOT NULL - AND EXISTS ( - SELECT 1 - FROM public.user_community_memberships m - WHERE m.community_id = community_id - AND m.user_id = public.current_user_id() - AND m.status = 'admin' - ) - ) - ); - -CREATE POLICY "Events delete (owner-or-admin)" - ON public.community_events - FOR DELETE - TO authenticated - USING ( - created_by = public.current_user_id() - OR ( - community_id IS NOT NULL - AND EXISTS ( - SELECT 1 - FROM public.user_community_memberships m - WHERE m.community_id = community_id - AND m.user_id = public.current_user_id() - AND m.status = 'admin' - ) - ) - ); - -DROP POLICY IF EXISTS "Users can view own registrations" ON public.event_registrations; -DROP POLICY IF EXISTS "Users can manage own registrations" ON public.event_registrations; - -CREATE POLICY "Registrations readable (owner)" - ON public.event_registrations - FOR SELECT - TO authenticated - USING (user_id = public.current_user_id()); - -CREATE POLICY "Registrations self-management" - ON public.event_registrations - FOR ALL - TO authenticated - USING (user_id = public.current_user_id()) - WITH CHECK (user_id = public.current_user_id()); - --- ============================================================ --- Community admin workflows (applications, logs, credits) --- ============================================================ - -ALTER TABLE public.community_join_applications ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.admin_action_logs ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.credit_grants ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "Users can view own applications" ON public.community_join_applications; -CREATE POLICY "Users can view own applications" - ON public.community_join_applications - FOR SELECT - TO authenticated - USING ( - public.current_user_id() = user_id - OR public.is_community_admin(community_id, public.current_user_id()) - ); - -DROP POLICY IF EXISTS "Admins can view action logs" ON public.admin_action_logs; -CREATE POLICY "Admins can view action logs" - ON public.admin_action_logs - FOR SELECT - TO authenticated - USING (public.is_community_admin(community_id, public.current_user_id())); - -DROP POLICY IF EXISTS "Users can view own credit grants" ON public.credit_grants; -CREATE POLICY "Users can view own credit grants" - ON public.credit_grants - FOR SELECT - TO authenticated - USING ( - public.current_user_id() = user_id - OR ( - community_id IS NOT NULL - AND public.is_community_admin(community_id, public.current_user_id()) - ) - ); - --- ============================================================ --- Achievements --- ============================================================ - -ALTER TABLE public.achievements ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.user_achievements ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "Allow public read on achievements" ON public.achievements; -DROP POLICY IF EXISTS "Allow admins to create community achievements" ON public.achievements; -DROP POLICY IF EXISTS "Allow admins to update community achievements" ON public.achievements; - -CREATE POLICY "Achievements readable (auth)" - ON public.achievements - FOR SELECT - TO authenticated - USING (true); - -CREATE POLICY "Achievements insert (admins)" - ON public.achievements - FOR INSERT - TO authenticated - WITH CHECK ( - community_id IS NOT NULL - AND EXISTS ( - SELECT 1 - FROM public.user_community_memberships m - WHERE m.community_id = public.achievements.community_id - AND m.user_id = public.current_user_id() - AND m.status = 'admin' - ) - ); - -CREATE POLICY "Achievements update (admins)" - ON public.achievements - FOR UPDATE - TO authenticated - USING ( - community_id IS NOT NULL - AND EXISTS ( - SELECT 1 - FROM public.user_community_memberships m - WHERE m.community_id = public.achievements.community_id - AND m.user_id = public.current_user_id() - AND m.status = 'admin' - ) - ) - WITH CHECK ( - community_id IS NOT NULL - AND EXISTS ( - SELECT 1 - FROM public.user_community_memberships m - WHERE m.community_id = public.achievements.community_id - AND m.user_id = public.current_user_id() - AND m.status = 'admin' - ) - ); - -DROP POLICY IF EXISTS "Allow users to read achievements" ON public.user_achievements; -DROP POLICY IF EXISTS "Allow admins to grant achievements" ON public.user_achievements; - -CREATE POLICY "User achievements readable" - ON public.user_achievements - FOR SELECT - TO authenticated - USING ( - user_id = public.current_user_id() - OR ( - community_id IS NOT NULL - AND EXISTS ( - SELECT 1 - FROM public.user_community_memberships m - WHERE m.community_id = public.user_achievements.community_id - AND m.user_id = public.current_user_id() - AND m.status = 'admin' - ) - ) - ); - -CREATE POLICY "User achievements grant" - ON public.user_achievements - FOR INSERT - TO authenticated - WITH CHECK ( - EXISTS ( - SELECT 1 - FROM public.achievements a - LEFT JOIN public.user_community_memberships m - ON m.community_id = a.community_id - AND m.user_id = public.current_user_id() - WHERE a.id = achievement_id - AND ( - a.community_id IS NULL - OR m.status = 'admin' - ) - ) - ); - --- ============================================================ --- Daily & streak challenges --- ============================================================ - -ALTER TABLE public.daily_challenge_results ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.streak_records ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "Users can insert their own daily results" ON public.daily_challenge_results; -CREATE POLICY "Users can insert their own daily results" - ON public.daily_challenge_results - FOR INSERT - TO authenticated - WITH CHECK (user_id = public.current_user_id()); - -DROP POLICY IF EXISTS "Users can insert their own streak records" ON public.streak_records; -CREATE POLICY "Users can insert their own streak records" - ON public.streak_records - FOR INSERT - TO authenticated - WITH CHECK (user_id = public.current_user_id()); - --- ============================================================ --- Arena duel visibility --- ============================================================ - -ALTER TABLE public.arena_challenges ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.arena_challenge_answers ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "Users can view their own challenges" ON public.arena_challenges; -CREATE POLICY "Users can view their own challenges" - ON public.arena_challenges - FOR SELECT - TO authenticated - USING ( - public.current_user_id() = challenger_id - OR public.current_user_id() = opponent_id - ); - -DROP POLICY IF EXISTS "Users can view answers for their challenges" ON public.arena_challenge_answers; -CREATE POLICY "Users can view answers for their challenges" - ON public.arena_challenge_answers - FOR SELECT - TO authenticated - USING ( - EXISTS ( - SELECT 1 - FROM public.arena_challenges ac - WHERE ac.id = challenge_id - AND ( - ac.challenger_id = public.current_user_id() - OR ac.opponent_id = public.current_user_id() - ) - ) - ); diff --git a/The Trash/migrations/20260212020000_ucsd_email_badge.sql b/The Trash/migrations/20260212020000_ucsd_email_badge.sql deleted file mode 100644 index 82ce66f..0000000 --- a/The Trash/migrations/20260212020000_ucsd_email_badge.sql +++ /dev/null @@ -1,100 +0,0 @@ --- ============================================================ --- Migration: 20260212020000_ucsd_email_badge.sql --- Description: --- - Adds UCSD-specific achievement triggered by verified @ucsd.edu email --- - Updates check_and_grant_achievement to handle the new trigger --- ============================================================ - -INSERT INTO public.achievements (id, name, description, icon_name, community_id, rarity, trigger_key, is_hidden) -VALUES ( - 'a0000001-0000-0000-0000-000000000009', - 'UCSD Recycler', - 'Verify your UCSD email to represent Triton pride.', - 'graduationcap.fill', - NULL, - 'rare', - 'ucsd_email', - false -) -ON CONFLICT (id) DO NOTHING; - -CREATE OR REPLACE FUNCTION public.check_and_grant_achievement(p_trigger_key TEXT) -RETURNS JSON AS $$ -DECLARE - v_user_id UUID := auth.uid(); - v_achievement RECORD; - v_profile RECORD; - v_already_has BOOLEAN; - v_qualifies BOOLEAN := false; - v_auth_email TEXT; - v_email_confirmed_at TIMESTAMPTZ; -BEGIN - IF v_user_id IS NULL THEN - RETURN json_build_object('granted', false, 'reason', 'Not authenticated'); - END IF; - - SELECT * INTO v_achievement FROM public.achievements - WHERE trigger_key = p_trigger_key AND community_id IS NULL; - - IF NOT FOUND THEN - RETURN json_build_object('granted', false, 'reason', 'Achievement not found'); - END IF; - - SELECT EXISTS ( - SELECT 1 FROM public.user_achievements - WHERE user_id = v_user_id AND achievement_id = v_achievement.id - ) INTO v_already_has; - - IF v_already_has THEN - RETURN json_build_object('granted', false, 'reason', 'Already earned'); - END IF; - - SELECT * INTO v_profile FROM public.profiles WHERE id = v_user_id; - - CASE p_trigger_key - WHEN 'first_scan' THEN - v_qualifies := COALESCE(v_profile.total_scans, 0) >= 1; - WHEN 'scans_10' THEN - v_qualifies := COALESCE(v_profile.total_scans, 0) >= 10; - WHEN 'scans_50' THEN - v_qualifies := COALESCE(v_profile.total_scans, 0) >= 50; - WHEN 'credits_100' THEN - v_qualifies := COALESCE(v_profile.credits, 0) >= 100; - WHEN 'credits_500' THEN - v_qualifies := COALESCE(v_profile.credits, 0) >= 500; - WHEN 'credits_2000' THEN - v_qualifies := COALESCE(v_profile.credits, 0) >= 2000; - WHEN 'join_community' THEN - v_qualifies := EXISTS ( - SELECT 1 FROM public.user_community_memberships - WHERE user_id = v_user_id AND status IN ('member', 'admin') - ); - WHEN 'arena_win' THEN - v_qualifies := true; - WHEN 'ucsd_email' THEN - SELECT email, email_confirmed_at INTO v_auth_email, v_email_confirmed_at - FROM auth.users - WHERE id = v_user_id; - v_qualifies := v_email_confirmed_at IS NOT NULL - AND v_auth_email ILIKE '%@ucsd.edu'; - ELSE - v_qualifies := false; - END CASE; - - IF NOT v_qualifies THEN - RETURN json_build_object('granted', false, 'reason', 'Not qualified'); - END IF; - - INSERT INTO public.user_achievements (user_id, achievement_id) - VALUES (v_user_id, v_achievement.id); - - RETURN json_build_object( - 'granted', true, - 'achievement_id', v_achievement.id, - 'name', v_achievement.name, - 'description', v_achievement.description, - 'icon_name', v_achievement.icon_name, - 'rarity', v_achievement.rarity - ); -END; -$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/The Trash/migrations/20260212023000_friends_phone_normalization.sql b/The Trash/migrations/20260212023000_friends_phone_normalization.sql deleted file mode 100644 index f993cd1..0000000 --- a/The Trash/migrations/20260212023000_friends_phone_normalization.sql +++ /dev/null @@ -1,99 +0,0 @@ --- ============================================================ --- Migration: 20260212023000_friends_phone_normalization.sql --- Purpose: --- 1. Normalize phone numbers (default +1 for 10 digits) so that --- contacts saved without +1 can still match Supabase Auth phones. --- 2. Update find_friends_leaderboard to use the normalization helper. --- ============================================================ - -CREATE OR REPLACE FUNCTION public.normalize_phone_number(p_input TEXT) -RETURNS TEXT -LANGUAGE plpgsql -IMMUTABLE -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - digits TEXT; -BEGIN - IF p_input IS NULL THEN - RETURN NULL; - END IF; - - digits := regexp_replace(p_input, '[^0-9]', '', 'g'); - - IF digits IS NULL OR digits = '' THEN - RETURN NULL; - END IF; - - IF length(digits) = 10 THEN - RETURN '+1' || digits; - ELSIF length(digits) = 11 AND left(digits, 1) = '1' THEN - RETURN '+' || digits; - ELSE - RETURN '+' || digits; - END IF; -END; -$$; - -CREATE OR REPLACE FUNCTION public.find_friends_leaderboard( - p_emails TEXT[] DEFAULT ARRAY[]::TEXT[], - p_phones TEXT[] DEFAULT ARRAY[]::TEXT[] -) -RETURNS TABLE ( - id UUID, - username TEXT, - credits INT, - email TEXT, - phone TEXT -) AS $$ -BEGIN - RETURN QUERY - WITH normalized_emails AS ( - SELECT DISTINCT LOWER(TRIM(e)) AS email - FROM unnest(COALESCE(p_emails, ARRAY[]::TEXT[])) AS e - WHERE TRIM(e) <> '' - ), - normalized_phones AS ( - SELECT DISTINCT public.normalize_phone_number(raw_phone) AS phone - FROM unnest(COALESCE(p_phones, ARRAY[]::TEXT[])) AS raw_phone - CROSS JOIN LATERAL public.normalize_phone_number(raw_phone) - WHERE public.normalize_phone_number(raw_phone) IS NOT NULL - ), - profiles_with_auth AS ( - SELECT - p.id, - COALESCE(p.username, 'Anonymous')::TEXT AS username, - COALESCE(p.credits, 0) AS credits, - u.email, - u.phone, - public.normalize_phone_number(u.phone) AS normalized_phone - FROM public.profiles p - JOIN auth.users u ON u.id = p.id - ) - SELECT - pa.id, - pa.username, - pa.credits, - pa.email::TEXT, - pa.phone::TEXT - FROM profiles_with_auth pa - WHERE ( - EXISTS ( - SELECT 1 - FROM normalized_emails ne - WHERE ne.email = LOWER(pa.email) - ) - OR ( - pa.normalized_phone IS NOT NULL - AND EXISTS ( - SELECT 1 - FROM normalized_phones np - WHERE np.phone = pa.normalized_phone - ) - ) - ); -END; -$$ LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public, auth; diff --git a/The Trash/migrations/20260212023300_fix_friends_function.sql b/The Trash/migrations/20260212023300_fix_friends_function.sql deleted file mode 100644 index 6d373c3..0000000 --- a/The Trash/migrations/20260212023300_fix_friends_function.sql +++ /dev/null @@ -1,63 +0,0 @@ --- Fix find_friends_leaderboard casting issues (ensure email/phone returned as text) - -CREATE OR REPLACE FUNCTION public.find_friends_leaderboard( - p_emails TEXT[] DEFAULT ARRAY[]::TEXT[], - p_phones TEXT[] DEFAULT ARRAY[]::TEXT[] -) -RETURNS TABLE ( - id UUID, - username TEXT, - credits INT, - email TEXT, - phone TEXT -) AS $$ -BEGIN - RETURN QUERY - WITH normalized_emails AS ( - SELECT DISTINCT LOWER(TRIM(e)) AS email - FROM unnest(COALESCE(p_emails, ARRAY[]::TEXT[])) AS e - WHERE TRIM(e) <> '' - ), - normalized_phones AS ( - SELECT DISTINCT public.normalize_phone_number(raw_phone) AS phone - FROM unnest(COALESCE(p_phones, ARRAY[]::TEXT[])) AS raw_phone - CROSS JOIN LATERAL public.normalize_phone_number(raw_phone) - WHERE public.normalize_phone_number(raw_phone) IS NOT NULL - ), - profiles_with_auth AS ( - SELECT - p.id, - COALESCE(p.username, 'Anonymous')::TEXT AS username, - COALESCE(p.credits, 0) AS credits, - u.email::TEXT AS email, - u.phone::TEXT AS phone, - public.normalize_phone_number(u.phone) AS normalized_phone - FROM public.profiles p - JOIN auth.users u ON u.id = p.id - ) - SELECT - pa.id, - pa.username, - pa.credits, - pa.email, - pa.phone - FROM profiles_with_auth pa - WHERE ( - EXISTS ( - SELECT 1 - FROM normalized_emails ne - WHERE ne.email = LOWER(pa.email) - ) - OR ( - pa.normalized_phone IS NOT NULL - AND EXISTS ( - SELECT 1 - FROM normalized_phones np - WHERE np.phone = pa.normalized_phone - ) - ) - ); -END; -$$ LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public, auth; diff --git a/The Trash/migrations/20260213102000_fix_duel_completion_atomicity.sql b/The Trash/migrations/20260213102000_fix_duel_completion_atomicity.sql deleted file mode 100644 index a457c40..0000000 --- a/The Trash/migrations/20260213102000_fix_duel_completion_atomicity.sql +++ /dev/null @@ -1,125 +0,0 @@ --- Ensure duel completion is atomic and only finalizes after both players answered all questions. -CREATE OR REPLACE FUNCTION public.complete_arena_challenge(p_challenge_id UUID) -RETURNS JSON -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID; - v_challenge RECORD; - v_challenger_correct INT; - v_opponent_correct INT; - v_challenger_score INT; - v_opponent_score INT; - v_winner_id UUID; - v_challenger_points INT; - v_opponent_points INT; - v_total_questions INT; - v_challenger_answers INT; - v_opponent_answers INT; -BEGIN - v_user_id := auth.uid(); - IF v_user_id IS NULL THEN - RAISE EXCEPTION 'Not authenticated'; - END IF; - - -- Lock row to prevent concurrent completion from double-awarding credits. - SELECT * INTO v_challenge - FROM public.arena_challenges - WHERE id = p_challenge_id - FOR UPDATE; - - IF v_challenge IS NULL THEN - RAISE EXCEPTION 'Challenge not found'; - END IF; - - IF v_challenge.challenger_id != v_user_id AND v_challenge.opponent_id != v_user_id THEN - RAISE EXCEPTION 'Not your challenge'; - END IF; - - IF v_challenge.status = 'completed' THEN - RETURN json_build_object( - 'challenge_id', p_challenge_id, - 'challenger_score', v_challenge.challenger_score, - 'opponent_score', v_challenge.opponent_score, - 'winner_id', v_challenge.winner_id, - 'already_completed', true - ); - END IF; - - IF v_challenge.status NOT IN ('accepted', 'in_progress') THEN - RAISE EXCEPTION 'Challenge is not active (status: %)', v_challenge.status; - END IF; - - v_total_questions := COALESCE(array_length(v_challenge.question_ids, 1), 0); - IF v_total_questions <= 0 THEN - RAISE EXCEPTION 'Challenge has no questions'; - END IF; - - SELECT COUNT(*) INTO v_challenger_answers - FROM public.arena_challenge_answers - WHERE challenge_id = p_challenge_id AND user_id = v_challenge.challenger_id; - - SELECT COUNT(*) INTO v_opponent_answers - FROM public.arena_challenge_answers - WHERE challenge_id = p_challenge_id AND user_id = v_challenge.opponent_id; - - IF v_challenger_answers < v_total_questions OR v_opponent_answers < v_total_questions THEN - RAISE EXCEPTION 'Challenge not complete yet'; - END IF; - - SELECT COUNT(*) FILTER (WHERE is_correct) INTO v_challenger_correct - FROM public.arena_challenge_answers - WHERE challenge_id = p_challenge_id AND user_id = v_challenge.challenger_id; - - SELECT COUNT(*) FILTER (WHERE is_correct) INTO v_opponent_correct - FROM public.arena_challenge_answers - WHERE challenge_id = p_challenge_id AND user_id = v_challenge.opponent_id; - - v_challenger_score := v_challenger_correct * 20; - v_opponent_score := v_opponent_correct * 20; - - IF v_challenger_score > v_opponent_score THEN - v_winner_id := v_challenge.challenger_id; - ELSIF v_opponent_score > v_challenger_score THEN - v_winner_id := v_challenge.opponent_id; - ELSE - v_winner_id := NULL; - END IF; - - IF v_winner_id IS NULL THEN - v_challenger_points := 30; - v_opponent_points := 30; - ELSIF v_winner_id = v_challenge.challenger_id THEN - v_challenger_points := 50; - v_opponent_points := 10; - ELSE - v_challenger_points := 10; - v_opponent_points := 50; - END IF; - - UPDATE public.arena_challenges - SET - status = 'completed', - challenger_score = v_challenger_score, - opponent_score = v_opponent_score, - winner_id = v_winner_id, - completed_at = timezone('utc', now()) - WHERE id = p_challenge_id; - - UPDATE public.profiles SET credits = credits + v_challenger_points WHERE id = v_challenge.challenger_id; - UPDATE public.profiles SET credits = credits + v_opponent_points WHERE id = v_challenge.opponent_id; - - RETURN json_build_object( - 'challenge_id', p_challenge_id, - 'challenger_score', v_challenger_score, - 'opponent_score', v_opponent_score, - 'winner_id', v_winner_id, - 'challenger_points', v_challenger_points, - 'opponent_points', v_opponent_points, - 'already_completed', false - ); -END; -$$; - -ALTER FUNCTION public.complete_arena_challenge(UUID) OWNER TO postgres; diff --git a/The Trash/migrations/20260216041000_restore_legacy_rpc_compatibility.sql b/The Trash/migrations/20260216041000_restore_legacy_rpc_compatibility.sql deleted file mode 100644 index aa958b8..0000000 --- a/The Trash/migrations/20260216041000_restore_legacy_rpc_compatibility.sql +++ /dev/null @@ -1,592 +0,0 @@ --- ============================================================ --- Migration: Restore legacy RPC compatibility for iOS client --- Date: 2026-02-16 --- Goal: --- - Re-introduce RPCs still used by Swift client. --- - Keep function execution deterministic via explicit search_path. --- - Preserve compatibility while the app migrates to newer RPC names. --- ============================================================ - --- Ensure profile fields required by legacy RPCs exist. -ALTER TABLE public.profiles - ADD COLUMN IF NOT EXISTS total_scans INTEGER DEFAULT 0; - -ALTER TABLE public.profiles - ADD COLUMN IF NOT EXISTS selected_achievement_id UUID; - -ALTER TABLE public.profiles - ADD COLUMN IF NOT EXISTS location_city TEXT, - ADD COLUMN IF NOT EXISTS location_state TEXT, - ADD COLUMN IF NOT EXISTS location_latitude DOUBLE PRECISION, - ADD COLUMN IF NOT EXISTS location_longitude DOUBLE PRECISION; - --- Backward-compatible wrapper used by ArenaViewModel. -DROP FUNCTION IF EXISTS public.get_quiz_questions(); - -CREATE OR REPLACE FUNCTION public.get_quiz_questions() -RETURNS SETOF public.quiz_questions -LANGUAGE sql -SECURITY DEFINER -SET search_path = public, pg_temp -AS $$ - SELECT * FROM public.get_quiz_questions_batch(10); -$$; - -ALTER FUNCTION public.get_quiz_questions() OWNER TO postgres; -GRANT EXECUTE ON FUNCTION public.get_quiz_questions() TO authenticated; - --- Increment user credits after local gameplay or scan confirmation. -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 - FROM pg_proc p - JOIN pg_namespace n ON n.oid = p.pronamespace - WHERE n.nspname = 'public' - AND p.proname = 'increment_credits' - AND pg_get_function_identity_arguments(p.oid) = 'amount integer' - ) THEN - EXECUTE $create$ - CREATE FUNCTION public.increment_credits(amount INTEGER) - RETURNS INTEGER - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = public, pg_temp - AS $fn$ - DECLARE - v_user_id UUID; - v_new_credits INTEGER; - BEGIN - v_user_id := public.current_user_id(); - IF v_user_id IS NULL THEN - RAISE EXCEPTION 'Not authenticated'; - END IF; - - IF amount IS NULL OR amount <= 0 THEN - RAISE EXCEPTION 'amount must be a positive integer'; - END IF; - - UPDATE public.profiles - SET credits = COALESCE(credits, 0) + amount - WHERE id = v_user_id - RETURNING credits INTO v_new_credits; - - IF v_new_credits IS NULL THEN - RAISE EXCEPTION 'Profile not found for current user'; - END IF; - - RETURN v_new_credits; - END; - $fn$; - $create$; - END IF; -END $$; - -GRANT EXECUTE ON FUNCTION public.increment_credits(INTEGER) TO authenticated; - --- Increment total scan counter for achievement triggers. -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 - FROM pg_proc p - JOIN pg_namespace n ON n.oid = p.pronamespace - WHERE n.nspname = 'public' - AND p.proname = 'increment_total_scans' - AND pg_get_function_identity_arguments(p.oid) = '' - ) THEN - EXECUTE $create$ - CREATE FUNCTION public.increment_total_scans() - RETURNS INTEGER - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = public, pg_temp - AS $fn$ - DECLARE - v_user_id UUID; - v_new_total INTEGER; - BEGIN - v_user_id := public.current_user_id(); - IF v_user_id IS NULL THEN - RAISE EXCEPTION 'Not authenticated'; - END IF; - - UPDATE public.profiles - SET total_scans = COALESCE(total_scans, 0) + 1 - WHERE id = v_user_id - RETURNING total_scans INTO v_new_total; - - IF v_new_total IS NULL THEN - RAISE EXCEPTION 'Profile not found for current user'; - END IF; - - RETURN v_new_total; - END; - $fn$; - $create$; - END IF; -END $$; - -GRANT EXECUTE ON FUNCTION public.increment_total_scans() TO authenticated; - --- City-based community discovery used by UserSettings. -DROP FUNCTION IF EXISTS public.get_communities_by_city(TEXT); - -CREATE OR REPLACE FUNCTION public.get_communities_by_city(p_city TEXT) -RETURNS TABLE ( - id TEXT, - name TEXT, - city TEXT, - state TEXT, - description TEXT, - member_count INTEGER, - latitude DOUBLE PRECISION, - longitude DOUBLE PRECISION, - is_member BOOLEAN -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public, pg_temp -AS $$ -DECLARE - v_user_id UUID; -BEGIN - v_user_id := public.current_user_id(); - - RETURN QUERY - SELECT - c.id, - c.name, - c.city, - c.state, - c.description, - COALESCE(c.member_count, 0), - c.latitude::DOUBLE PRECISION, - c.longitude::DOUBLE PRECISION, - EXISTS ( - SELECT 1 - FROM public.user_community_memberships m - WHERE m.community_id = c.id - AND m.user_id = v_user_id - AND m.status IN ('member', 'admin') - ) - FROM public.communities c - WHERE c.city = p_city - AND c.is_active = true - ORDER BY c.member_count DESC, c.name ASC; -END; -$$; - -ALTER FUNCTION public.get_communities_by_city(TEXT) OWNER TO postgres; -GRANT EXECUTE ON FUNCTION public.get_communities_by_city(TEXT) TO authenticated; - --- Leave community and keep member_count in sync. -DROP FUNCTION IF EXISTS public.leave_community(TEXT); - -CREATE OR REPLACE FUNCTION public.leave_community(p_community_id TEXT) -RETURNS JSON -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public, pg_temp -AS $$ -DECLARE - v_user_id UUID; - v_deleted_count INTEGER; -BEGIN - v_user_id := public.current_user_id(); - IF v_user_id IS NULL THEN - RETURN json_build_object('success', false, 'message', 'Not authenticated'); - END IF; - - DELETE FROM public.user_community_memberships - WHERE user_id = v_user_id - AND community_id = p_community_id - AND status IN ('member', 'admin'); - - GET DIAGNOSTICS v_deleted_count = ROW_COUNT; - - IF COALESCE(v_deleted_count, 0) = 0 THEN - RETURN json_build_object('success', false, 'message', 'Not a member of this community'); - END IF; - - UPDATE public.communities - SET member_count = GREATEST(0, COALESCE(member_count, 0) - v_deleted_count), - updated_at = NOW() - WHERE id = p_community_id; - - RETURN json_build_object('success', true, 'message', 'Left community successfully'); -END; -$$; - -ALTER FUNCTION public.leave_community(TEXT) OWNER TO postgres; -GRANT EXECUTE ON FUNCTION public.leave_community(TEXT) TO authenticated; - --- Register event participation. -DROP FUNCTION IF EXISTS public.register_for_event(UUID); - -CREATE OR REPLACE FUNCTION public.register_for_event(p_event_id UUID) -RETURNS JSON -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public, pg_temp -AS $$ -DECLARE - v_user_id UUID; - v_event RECORD; - v_existing RECORD; -BEGIN - v_user_id := public.current_user_id(); - IF v_user_id IS NULL THEN - RETURN json_build_object('success', false, 'message', 'Not authenticated'); - END IF; - - SELECT * INTO v_event - FROM public.community_events - WHERE id = p_event_id - FOR UPDATE; - - IF NOT FOUND THEN - RETURN json_build_object('success', false, 'message', 'Event not found'); - END IF; - - IF v_event.status NOT IN ('upcoming', 'ongoing') THEN - RETURN json_build_object('success', false, 'message', 'Event is not open for registration'); - END IF; - - IF v_event.max_participants IS NOT NULL - AND COALESCE(v_event.participant_count, 0) >= v_event.max_participants THEN - RETURN json_build_object('success', false, 'message', 'Event is full'); - END IF; - - SELECT * INTO v_existing - FROM public.event_registrations - WHERE event_id = p_event_id - AND user_id = v_user_id; - - IF FOUND THEN - IF v_existing.status = 'registered' THEN - RETURN json_build_object('success', false, 'message', 'Already registered'); - ELSIF v_existing.status = 'cancelled' THEN - UPDATE public.event_registrations - SET status = 'registered', - registered_at = NOW() - WHERE id = v_existing.id; - ELSE - RETURN json_build_object('success', false, 'message', 'Cannot register for this event'); - END IF; - ELSE - INSERT INTO public.event_registrations (event_id, user_id, status, registered_at) - VALUES (p_event_id, v_user_id, 'registered', NOW()); - END IF; - - UPDATE public.community_events - SET participant_count = COALESCE(participant_count, 0) + 1, - updated_at = NOW() - WHERE id = p_event_id; - - RETURN json_build_object('success', true, 'message', 'Registration successful'); -END; -$$; - -ALTER FUNCTION public.register_for_event(UUID) OWNER TO postgres; -GRANT EXECUTE ON FUNCTION public.register_for_event(UUID) TO authenticated; - --- Cancel existing registration. -DROP FUNCTION IF EXISTS public.cancel_event_registration(UUID); - -CREATE OR REPLACE FUNCTION public.cancel_event_registration(p_event_id UUID) -RETURNS JSON -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public, pg_temp -AS $$ -DECLARE - v_user_id UUID; -BEGIN - v_user_id := public.current_user_id(); - IF v_user_id IS NULL THEN - RETURN json_build_object('success', false, 'message', 'Not authenticated'); - END IF; - - UPDATE public.event_registrations - SET status = 'cancelled' - WHERE event_id = p_event_id - AND user_id = v_user_id - AND status = 'registered'; - - IF NOT FOUND THEN - RETURN json_build_object('success', false, 'message', 'Registration not found'); - END IF; - - UPDATE public.community_events - SET participant_count = GREATEST(0, COALESCE(participant_count, 0) - 1), - updated_at = NOW() - WHERE id = p_event_id; - - RETURN json_build_object('success', true, 'message', 'Registration cancelled'); -END; -$$; - -ALTER FUNCTION public.cancel_event_registration(UUID) OWNER TO postgres; -GRANT EXECUTE ON FUNCTION public.cancel_event_registration(UUID) TO authenticated; - --- List my registrations for account screens. -DROP FUNCTION IF EXISTS public.get_my_registrations(); - -CREATE OR REPLACE FUNCTION public.get_my_registrations() -RETURNS TABLE ( - registration_id UUID, - event_id UUID, - event_title TEXT, - event_date TIMESTAMPTZ, - event_location TEXT, - event_category TEXT, - community_name TEXT, - registration_status TEXT, - registered_at TIMESTAMPTZ -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public, pg_temp -AS $$ -DECLARE - v_user_id UUID; -BEGIN - v_user_id := public.current_user_id(); - IF v_user_id IS NULL THEN - RAISE EXCEPTION 'Not authenticated'; - END IF; - - RETURN QUERY - SELECT - r.id AS registration_id, - e.id AS event_id, - e.title AS event_title, - e.event_date, - e.location AS event_location, - e.category AS event_category, - COALESCE(c.name, 'Personal') AS community_name, - r.status AS registration_status, - r.registered_at - FROM public.event_registrations r - JOIN public.community_events e ON e.id = r.event_id - LEFT JOIN public.communities c ON c.id = e.community_id - WHERE r.user_id = v_user_id - ORDER BY e.event_date DESC; -END; -$$; - -ALTER FUNCTION public.get_my_registrations() OWNER TO postgres; -GRANT EXECUTE ON FUNCTION public.get_my_registrations() TO authenticated; - --- Persist current user location preference. -DROP FUNCTION IF EXISTS public.update_user_location(TEXT, TEXT, DOUBLE PRECISION, DOUBLE PRECISION); - -CREATE OR REPLACE FUNCTION public.update_user_location( - p_city TEXT, - p_state TEXT, - p_latitude DOUBLE PRECISION, - p_longitude DOUBLE PRECISION -) -RETURNS JSON -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public, pg_temp -AS $$ -DECLARE - v_user_id UUID; -BEGIN - v_user_id := public.current_user_id(); - IF v_user_id IS NULL THEN - RETURN json_build_object('success', false, 'message', 'Not authenticated'); - END IF; - - UPDATE public.profiles - SET location_city = p_city, - location_state = p_state, - location_latitude = p_latitude, - location_longitude = p_longitude - WHERE id = v_user_id; - - RETURN json_build_object('success', true, 'message', 'Location updated'); -END; -$$; - -ALTER FUNCTION public.update_user_location(TEXT, TEXT, DOUBLE PRECISION, DOUBLE PRECISION) OWNER TO postgres; -GRANT EXECUTE ON FUNCTION public.update_user_location(TEXT, TEXT, DOUBLE PRECISION, DOUBLE PRECISION) TO authenticated; - --- Community leaderboard endpoint consumed by LeaderboardView. -DROP FUNCTION IF EXISTS public.get_community_leaderboard(TEXT, INTEGER); - -CREATE OR REPLACE FUNCTION public.get_community_leaderboard( - p_community_id TEXT, - p_limit INTEGER DEFAULT 100 -) -RETURNS TABLE ( - id UUID, - username TEXT, - credits INTEGER, - community_name TEXT, - achievement_icon TEXT -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public, pg_temp -AS $$ -BEGIN - RETURN QUERY - SELECT - p.id, - COALESCE(p.username, 'Anonymous')::TEXT AS username, - COALESCE(p.credits, 0) AS credits, - c.name AS community_name, - a.icon_name AS achievement_icon - FROM public.user_community_memberships cm - JOIN public.profiles p ON p.id = cm.user_id - JOIN public.communities c ON c.id = cm.community_id - LEFT JOIN public.achievements a ON a.id = p.selected_achievement_id - WHERE cm.community_id = p_community_id - AND cm.status IN ('member', 'admin') - ORDER BY COALESCE(p.credits, 0) DESC, p.username ASC - LIMIT LEAST(GREATEST(COALESCE(p_limit, 100), 1), 500); -END; -$$; - -ALTER FUNCTION public.get_community_leaderboard(TEXT, INTEGER) OWNER TO postgres; -GRANT EXECUTE ON FUNCTION public.get_community_leaderboard(TEXT, INTEGER) TO authenticated; - --- Equip or clear selected achievement. -DROP FUNCTION IF EXISTS public.set_primary_achievement(UUID); - -CREATE OR REPLACE FUNCTION public.set_primary_achievement(achievement_id UUID) -RETURNS VOID -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public, pg_temp -AS $$ -DECLARE - v_user_id UUID; -BEGIN - v_user_id := public.current_user_id(); - IF v_user_id IS NULL THEN - RAISE EXCEPTION 'Not authenticated'; - END IF; - - IF achievement_id IS NOT NULL AND NOT EXISTS ( - SELECT 1 - FROM public.user_achievements ua - WHERE ua.user_id = v_user_id - AND ua.achievement_id = set_primary_achievement.achievement_id - ) THEN - RAISE EXCEPTION 'User does not own this achievement'; - END IF; - - UPDATE public.profiles - SET selected_achievement_id = set_primary_achievement.achievement_id - WHERE id = v_user_id; -END; -$$; - -ALTER FUNCTION public.set_primary_achievement(UUID) OWNER TO postgres; -GRANT EXECUTE ON FUNCTION public.set_primary_achievement(UUID) TO authenticated; - --- Fetch earned achievements with display metadata. -DROP FUNCTION IF EXISTS public.get_my_achievements(); - -CREATE OR REPLACE FUNCTION public.get_my_achievements() -RETURNS TABLE ( - user_achievement_id UUID, - achievement_id UUID, - name TEXT, - description TEXT, - icon_name TEXT, - community_id TEXT, - community_name TEXT, - granted_at TIMESTAMPTZ, - is_equipped BOOLEAN, - rarity TEXT -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public, pg_temp -AS $$ -DECLARE - v_user_id UUID; -BEGIN - v_user_id := public.current_user_id(); - IF v_user_id IS NULL THEN - RAISE EXCEPTION 'Not authenticated'; - END IF; - - RETURN QUERY - SELECT - ua.id AS user_achievement_id, - a.id AS achievement_id, - a.name, - a.description, - a.icon_name, - a.community_id, - c.name AS community_name, - ua.granted_at, - (p.selected_achievement_id = a.id) AS is_equipped, - COALESCE(a.rarity, 'common') AS rarity - FROM public.user_achievements ua - JOIN public.achievements a ON a.id = ua.achievement_id - LEFT JOIN public.communities c ON c.id = a.community_id - LEFT JOIN public.profiles p ON p.id = ua.user_id - WHERE ua.user_id = v_user_id - ORDER BY ua.granted_at DESC; -END; -$$; - -ALTER FUNCTION public.get_my_achievements() OWNER TO postgres; -GRANT EXECUTE ON FUNCTION public.get_my_achievements() TO authenticated; - --- Admin helper for grant-achievement picker UI. -DROP FUNCTION IF EXISTS public.get_community_members_for_grant(TEXT, UUID); - -CREATE OR REPLACE FUNCTION public.get_community_members_for_grant( - p_community_id TEXT, - p_achievement_id UUID -) -RETURNS TABLE ( - user_id UUID, - username TEXT, - already_has BOOLEAN -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public, pg_temp -AS $$ -DECLARE - v_admin_id UUID; -BEGIN - v_admin_id := public.current_user_id(); - IF v_admin_id IS NULL THEN - RAISE EXCEPTION 'Not authenticated'; - END IF; - - IF NOT public.is_community_admin(p_community_id, v_admin_id) THEN - RAISE EXCEPTION 'Only community admins can view this list'; - END IF; - - RETURN QUERY - SELECT - m.user_id, - COALESCE(p.username, 'Anonymous')::TEXT AS username, - EXISTS ( - SELECT 1 - FROM public.user_achievements ua - WHERE ua.user_id = m.user_id - AND ua.achievement_id = p_achievement_id - ) AS already_has - FROM public.user_community_memberships m - JOIN public.profiles p ON p.id = m.user_id - WHERE m.community_id = p_community_id - AND m.status IN ('member', 'admin') - ORDER BY p.username ASC; -END; -$$; - -ALTER FUNCTION public.get_community_members_for_grant(TEXT, UUID) OWNER TO postgres; -GRANT EXECUTE ON FUNCTION public.get_community_members_for_grant(TEXT, UUID) TO authenticated; diff --git a/The Trash/migrations/20260217103000_fix_membership_policy_recursion.sql b/The Trash/migrations/20260217103000_fix_membership_policy_recursion.sql deleted file mode 100644 index 1748fef..0000000 --- a/The Trash/migrations/20260217103000_fix_membership_policy_recursion.sql +++ /dev/null @@ -1,102 +0,0 @@ --- ============================================================ --- Migration: 20260217103000_fix_membership_policy_recursion.sql --- Goal: --- * Fix "infinite recursion detected in policy" on membership tables. --- * Avoid self-referencing RLS subqueries on the same table. --- * Keep compatibility with both historical table names: --- public.user_community_memberships --- public.user_community_membership --- ============================================================ - -CREATE OR REPLACE FUNCTION public.can_view_community_roster( - p_community_id TEXT, - p_user_id UUID -) -RETURNS BOOLEAN -LANGUAGE plpgsql -STABLE -SECURITY DEFINER -SET search_path = public, pg_temp -AS $$ -DECLARE - v_result BOOLEAN := FALSE; -BEGIN - IF p_community_id IS NULL OR p_user_id IS NULL THEN - RETURN FALSE; - END IF; - - IF to_regclass('public.user_community_memberships') IS NOT NULL THEN - EXECUTE $sql$ - SELECT EXISTS ( - SELECT 1 - FROM public.user_community_memberships m - WHERE m.community_id = $1 - AND m.user_id = $2 - AND m.status IN ('member', 'admin') - ) - $sql$ - INTO v_result - USING p_community_id, p_user_id; - - RETURN v_result; - END IF; - - IF to_regclass('public.user_community_membership') IS NOT NULL THEN - EXECUTE $sql$ - SELECT EXISTS ( - SELECT 1 - FROM public.user_community_membership m - WHERE m.community_id = $1 - AND m.user_id = $2 - AND m.status IN ('member', 'admin') - ) - $sql$ - INTO v_result - USING p_community_id, p_user_id; - - RETURN v_result; - END IF; - - RETURN FALSE; -END; -$$; - -ALTER FUNCTION public.can_view_community_roster(TEXT, UUID) OWNER TO postgres; -REVOKE ALL ON FUNCTION public.can_view_community_roster(TEXT, UUID) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION public.can_view_community_roster(TEXT, UUID) TO authenticated; - -DO $$ -BEGIN - IF to_regclass('public.user_community_memberships') IS NOT NULL THEN - EXECUTE 'DROP POLICY IF EXISTS "Membership roster visibility" ON public.user_community_memberships'; - EXECUTE 'DROP POLICY IF EXISTS "Membership roster visibility (safe)" ON public.user_community_memberships'; - - EXECUTE $policy$ - CREATE POLICY "Membership roster visibility" - ON public.user_community_memberships - FOR SELECT - TO authenticated - USING ( - user_id = auth.uid() - OR public.can_view_community_roster(community_id, auth.uid()) - ) - $policy$; - END IF; - - IF to_regclass('public.user_community_membership') IS NOT NULL THEN - EXECUTE 'DROP POLICY IF EXISTS "Membership roster visibility" ON public.user_community_membership'; - EXECUTE 'DROP POLICY IF EXISTS "Membership roster visibility (safe)" ON public.user_community_membership'; - - EXECUTE $policy$ - CREATE POLICY "Membership roster visibility" - ON public.user_community_membership - FOR SELECT - TO authenticated - USING ( - user_id = auth.uid() - OR public.can_view_community_roster(community_id, auth.uid()) - ) - $policy$; - END IF; -END -$$; diff --git a/The Trash.xcodeproj/project.pbxproj b/legacy/swift-ios/The Trash.xcodeproj/project.pbxproj similarity index 100% rename from The Trash.xcodeproj/project.pbxproj rename to legacy/swift-ios/The Trash.xcodeproj/project.pbxproj diff --git a/The Trash.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/legacy/swift-ios/The Trash.xcodeproj/project.xcworkspace/contents.xcworkspacedata similarity index 100% rename from The Trash.xcodeproj/project.xcworkspace/contents.xcworkspacedata rename to legacy/swift-ios/The Trash.xcodeproj/project.xcworkspace/contents.xcworkspacedata diff --git a/The Trash.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/legacy/swift-ios/The Trash.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved similarity index 100% rename from The Trash.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved rename to legacy/swift-ios/The Trash.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/The Trash/App/ContentView.swift b/legacy/swift-ios/The Trash/App/ContentView.swift similarity index 100% rename from The Trash/App/ContentView.swift rename to legacy/swift-ios/The Trash/App/ContentView.swift diff --git a/The Trash/App/The_TrashApp.swift b/legacy/swift-ios/The Trash/App/The_TrashApp.swift similarity index 100% rename from The Trash/App/The_TrashApp.swift rename to legacy/swift-ios/The Trash/App/The_TrashApp.swift diff --git a/The Trash/Assets.xcassets/AccentColor.colorset/Contents.json b/legacy/swift-ios/The Trash/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from The Trash/Assets.xcassets/AccentColor.colorset/Contents.json rename to legacy/swift-ios/The Trash/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/The Trash/Assets.xcassets/AppIcon.appiconset/Contents.json b/legacy/swift-ios/The Trash/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from The Trash/Assets.xcassets/AppIcon.appiconset/Contents.json rename to legacy/swift-ios/The Trash/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/The Trash/Assets.xcassets/AppIcon.appiconset/The Trash Dark.png b/legacy/swift-ios/The Trash/Assets.xcassets/AppIcon.appiconset/The Trash Dark.png similarity index 100% rename from The Trash/Assets.xcassets/AppIcon.appiconset/The Trash Dark.png rename to legacy/swift-ios/The Trash/Assets.xcassets/AppIcon.appiconset/The Trash Dark.png diff --git a/The Trash/Assets.xcassets/AppIcon.appiconset/The Trash.png b/legacy/swift-ios/The Trash/Assets.xcassets/AppIcon.appiconset/The Trash.png similarity index 100% rename from The Trash/Assets.xcassets/AppIcon.appiconset/The Trash.png rename to legacy/swift-ios/The Trash/Assets.xcassets/AppIcon.appiconset/The Trash.png diff --git a/The Trash/Assets.xcassets/Color.colorset/Contents.json b/legacy/swift-ios/The Trash/Assets.xcassets/Color.colorset/Contents.json similarity index 100% rename from The Trash/Assets.xcassets/Color.colorset/Contents.json rename to legacy/swift-ios/The Trash/Assets.xcassets/Color.colorset/Contents.json diff --git a/The Trash/Assets.xcassets/Contents.json b/legacy/swift-ios/The Trash/Assets.xcassets/Contents.json similarity index 100% rename from The Trash/Assets.xcassets/Contents.json rename to legacy/swift-ios/The Trash/Assets.xcassets/Contents.json diff --git a/The Trash/Extensions/NeumorphicStyles.swift b/legacy/swift-ios/The Trash/Extensions/NeumorphicStyles.swift similarity index 100% rename from The Trash/Extensions/NeumorphicStyles.swift rename to legacy/swift-ios/The Trash/Extensions/NeumorphicStyles.swift diff --git a/The Trash/Extensions/View+BadgeStyle.swift b/legacy/swift-ios/The Trash/Extensions/View+BadgeStyle.swift similarity index 100% rename from The Trash/Extensions/View+BadgeStyle.swift rename to legacy/swift-ios/The Trash/Extensions/View+BadgeStyle.swift diff --git a/The Trash/Extensions/View+OptionalNavigation.swift b/legacy/swift-ios/The Trash/Extensions/View+OptionalNavigation.swift similarity index 100% rename from The Trash/Extensions/View+OptionalNavigation.swift rename to legacy/swift-ios/The Trash/Extensions/View+OptionalNavigation.swift diff --git a/The Trash/Extensions/View+RoundedCorner.swift b/legacy/swift-ios/The Trash/Extensions/View+RoundedCorner.swift similarity index 100% rename from The Trash/Extensions/View+RoundedCorner.swift rename to legacy/swift-ios/The Trash/Extensions/View+RoundedCorner.swift diff --git a/The Trash/Localizable.xcstrings b/legacy/swift-ios/The Trash/Localizable.xcstrings similarity index 100% rename from The Trash/Localizable.xcstrings rename to legacy/swift-ios/The Trash/Localizable.xcstrings diff --git a/The Trash/Models/AchievementModels.swift b/legacy/swift-ios/The Trash/Models/AchievementModels.swift similarity index 100% rename from The Trash/Models/AchievementModels.swift rename to legacy/swift-ios/The Trash/Models/AchievementModels.swift diff --git a/The Trash/Models/ArenaModels.swift b/legacy/swift-ios/The Trash/Models/ArenaModels.swift similarity index 100% rename from The Trash/Models/ArenaModels.swift rename to legacy/swift-ios/The Trash/Models/ArenaModels.swift diff --git a/The Trash/Models/CommunityModels.swift b/legacy/swift-ios/The Trash/Models/CommunityModels.swift similarity index 100% rename from The Trash/Models/CommunityModels.swift rename to legacy/swift-ios/The Trash/Models/CommunityModels.swift diff --git a/The Trash/Models/DailyChallengeModels.swift b/legacy/swift-ios/The Trash/Models/DailyChallengeModels.swift similarity index 100% rename from The Trash/Models/DailyChallengeModels.swift rename to legacy/swift-ios/The Trash/Models/DailyChallengeModels.swift diff --git a/The Trash/Models/DuelModels.swift b/legacy/swift-ios/The Trash/Models/DuelModels.swift similarity index 100% rename from The Trash/Models/DuelModels.swift rename to legacy/swift-ios/The Trash/Models/DuelModels.swift diff --git a/The Trash/Models/LeaderboardModels.swift b/legacy/swift-ios/The Trash/Models/LeaderboardModels.swift similarity index 100% rename from The Trash/Models/LeaderboardModels.swift rename to legacy/swift-ios/The Trash/Models/LeaderboardModels.swift diff --git a/The Trash/Models/LocationModels.swift b/legacy/swift-ios/The Trash/Models/LocationModels.swift similarity index 100% rename from The Trash/Models/LocationModels.swift rename to legacy/swift-ios/The Trash/Models/LocationModels.swift diff --git a/The Trash/Models/TrashModels.swift b/legacy/swift-ios/The Trash/Models/TrashModels.swift similarity index 100% rename from The Trash/Models/TrashModels.swift rename to legacy/swift-ios/The Trash/Models/TrashModels.swift diff --git a/The Trash/Services/AchievementService.swift b/legacy/swift-ios/The Trash/Services/AchievementService.swift similarity index 100% rename from The Trash/Services/AchievementService.swift rename to legacy/swift-ios/The Trash/Services/AchievementService.swift diff --git a/The Trash/Services/ArenaRouter.swift b/legacy/swift-ios/The Trash/Services/ArenaRouter.swift similarity index 100% rename from The Trash/Services/ArenaRouter.swift rename to legacy/swift-ios/The Trash/Services/ArenaRouter.swift diff --git a/The Trash/Services/ArenaService.swift b/legacy/swift-ios/The Trash/Services/ArenaService.swift similarity index 100% rename from The Trash/Services/ArenaService.swift rename to legacy/swift-ios/The Trash/Services/ArenaService.swift diff --git a/The Trash/Services/CommunityService.swift b/legacy/swift-ios/The Trash/Services/CommunityService.swift similarity index 100% rename from The Trash/Services/CommunityService.swift rename to legacy/swift-ios/The Trash/Services/CommunityService.swift diff --git a/The Trash/Services/DuelRealtimeManager.swift b/legacy/swift-ios/The Trash/Services/DuelRealtimeManager.swift similarity index 100% rename from The Trash/Services/DuelRealtimeManager.swift rename to legacy/swift-ios/The Trash/Services/DuelRealtimeManager.swift diff --git a/The Trash/Services/FeedbackService.swift b/legacy/swift-ios/The Trash/Services/FeedbackService.swift similarity index 100% rename from The Trash/Services/FeedbackService.swift rename to legacy/swift-ios/The Trash/Services/FeedbackService.swift diff --git a/The Trash/Services/FriendService.swift b/legacy/swift-ios/The Trash/Services/FriendService.swift similarity index 100% rename from The Trash/Services/FriendService.swift rename to legacy/swift-ios/The Trash/Services/FriendService.swift diff --git a/The Trash/Services/LocationManager.swift b/legacy/swift-ios/The Trash/Services/LocationManager.swift similarity index 100% rename from The Trash/Services/LocationManager.swift rename to legacy/swift-ios/The Trash/Services/LocationManager.swift diff --git a/The Trash/Services/RealClassifierService.swift b/legacy/swift-ios/The Trash/Services/RealClassifierService.swift similarity index 100% rename from The Trash/Services/RealClassifierService.swift rename to legacy/swift-ios/The Trash/Services/RealClassifierService.swift diff --git a/The Trash/Services/SupabaseManager.swift b/legacy/swift-ios/The Trash/Services/SupabaseManager.swift similarity index 100% rename from The Trash/Services/SupabaseManager.swift rename to legacy/swift-ios/The Trash/Services/SupabaseManager.swift diff --git a/The Trash/Services/UserSettings.swift b/legacy/swift-ios/The Trash/Services/UserSettings.swift similarity index 100% rename from The Trash/Services/UserSettings.swift rename to legacy/swift-ios/The Trash/Services/UserSettings.swift diff --git a/The Trash/Theme/EcoSkeuomorphicTheme.swift b/legacy/swift-ios/The Trash/Theme/EcoSkeuomorphicTheme.swift similarity index 100% rename from The Trash/Theme/EcoSkeuomorphicTheme.swift rename to legacy/swift-ios/The Trash/Theme/EcoSkeuomorphicTheme.swift diff --git a/The Trash/Theme/NeumorphicTheme.swift b/legacy/swift-ios/The Trash/Theme/NeumorphicTheme.swift similarity index 100% rename from The Trash/Theme/NeumorphicTheme.swift rename to legacy/swift-ios/The Trash/Theme/NeumorphicTheme.swift diff --git a/The Trash/Theme/PaperTextureView.swift b/legacy/swift-ios/The Trash/Theme/PaperTextureView.swift similarity index 100% rename from The Trash/Theme/PaperTextureView.swift rename to legacy/swift-ios/The Trash/Theme/PaperTextureView.swift diff --git a/The Trash/Theme/StampedIcon.swift b/legacy/swift-ios/The Trash/Theme/StampedIcon.swift similarity index 100% rename from The Trash/Theme/StampedIcon.swift rename to legacy/swift-ios/The Trash/Theme/StampedIcon.swift diff --git a/The Trash/Theme/ThemeBackgroundView.swift b/legacy/swift-ios/The Trash/Theme/ThemeBackgroundView.swift similarity index 100% rename from The Trash/Theme/ThemeBackgroundView.swift rename to legacy/swift-ios/The Trash/Theme/ThemeBackgroundView.swift diff --git a/The Trash/Theme/ThemeComponents.swift b/legacy/swift-ios/The Trash/Theme/ThemeComponents.swift similarity index 100% rename from The Trash/Theme/ThemeComponents.swift rename to legacy/swift-ios/The Trash/Theme/ThemeComponents.swift diff --git a/The Trash/Theme/ThemeEnvironment.swift b/legacy/swift-ios/The Trash/Theme/ThemeEnvironment.swift similarity index 100% rename from The Trash/Theme/ThemeEnvironment.swift rename to legacy/swift-ios/The Trash/Theme/ThemeEnvironment.swift diff --git a/The Trash/Theme/ThemeManager.swift b/legacy/swift-ios/The Trash/Theme/ThemeManager.swift similarity index 100% rename from The Trash/Theme/ThemeManager.swift rename to legacy/swift-ios/The Trash/Theme/ThemeManager.swift diff --git a/The Trash/Theme/ThemeOption.swift b/legacy/swift-ios/The Trash/Theme/ThemeOption.swift similarity index 100% rename from The Trash/Theme/ThemeOption.swift rename to legacy/swift-ios/The Trash/Theme/ThemeOption.swift diff --git a/The Trash/Theme/TrashIcon.swift b/legacy/swift-ios/The Trash/Theme/TrashIcon.swift similarity index 100% rename from The Trash/Theme/TrashIcon.swift rename to legacy/swift-ios/The Trash/Theme/TrashIcon.swift diff --git a/The Trash/Theme/TrashLabel.swift b/legacy/swift-ios/The Trash/Theme/TrashLabel.swift similarity index 100% rename from The Trash/Theme/TrashLabel.swift rename to legacy/swift-ios/The Trash/Theme/TrashLabel.swift diff --git a/The Trash/Theme/TrashTheme.swift b/legacy/swift-ios/The Trash/Theme/TrashTheme.swift similarity index 100% rename from The Trash/Theme/TrashTheme.swift rename to legacy/swift-ios/The Trash/Theme/TrashTheme.swift diff --git a/The Trash/Theme/VibrantTheme.swift b/legacy/swift-ios/The Trash/Theme/VibrantTheme.swift similarity index 100% rename from The Trash/Theme/VibrantTheme.swift rename to legacy/swift-ios/The Trash/Theme/VibrantTheme.swift diff --git a/The Trash/ViewModels/AuthViewModel.swift b/legacy/swift-ios/The Trash/ViewModels/AuthViewModel.swift similarity index 100% rename from The Trash/ViewModels/AuthViewModel.swift rename to legacy/swift-ios/The Trash/ViewModels/AuthViewModel.swift diff --git a/The Trash/ViewModels/CurrentUserViewModel.swift b/legacy/swift-ios/The Trash/ViewModels/CurrentUserViewModel.swift similarity index 100% rename from The Trash/ViewModels/CurrentUserViewModel.swift rename to legacy/swift-ios/The Trash/ViewModels/CurrentUserViewModel.swift diff --git a/The Trash/ViewModels/ProfileViewModel.swift b/legacy/swift-ios/The Trash/ViewModels/ProfileViewModel.swift similarity index 100% rename from The Trash/ViewModels/ProfileViewModel.swift rename to legacy/swift-ios/The Trash/ViewModels/ProfileViewModel.swift diff --git a/The Trash/ViewModels/TrashViewModel.swift b/legacy/swift-ios/The Trash/ViewModels/TrashViewModel.swift similarity index 100% rename from The Trash/ViewModels/TrashViewModel.swift rename to legacy/swift-ios/The Trash/ViewModels/TrashViewModel.swift diff --git a/The Trash/Views/Account/AccountComponents.swift b/legacy/swift-ios/The Trash/Views/Account/AccountComponents.swift similarity index 100% rename from The Trash/Views/Account/AccountComponents.swift rename to legacy/swift-ios/The Trash/Views/Account/AccountComponents.swift diff --git a/The Trash/Views/Account/AccountSettingsView.swift b/legacy/swift-ios/The Trash/Views/Account/AccountSettingsView.swift similarity index 100% rename from The Trash/Views/Account/AccountSettingsView.swift rename to legacy/swift-ios/The Trash/Views/Account/AccountSettingsView.swift diff --git a/The Trash/Views/Account/AccountView.swift b/legacy/swift-ios/The Trash/Views/Account/AccountView.swift similarity index 100% rename from The Trash/Views/Account/AccountView.swift rename to legacy/swift-ios/The Trash/Views/Account/AccountView.swift diff --git a/The Trash/Views/Account/BindEmailSheet.swift b/legacy/swift-ios/The Trash/Views/Account/BindEmailSheet.swift similarity index 100% rename from The Trash/Views/Account/BindEmailSheet.swift rename to legacy/swift-ios/The Trash/Views/Account/BindEmailSheet.swift diff --git a/The Trash/Views/Account/BindPhoneSheet.swift b/legacy/swift-ios/The Trash/Views/Account/BindPhoneSheet.swift similarity index 100% rename from The Trash/Views/Account/BindPhoneSheet.swift rename to legacy/swift-ios/The Trash/Views/Account/BindPhoneSheet.swift diff --git a/The Trash/Views/Account/ChangePasswordSheet.swift b/legacy/swift-ios/The Trash/Views/Account/ChangePasswordSheet.swift similarity index 100% rename from The Trash/Views/Account/ChangePasswordSheet.swift rename to legacy/swift-ios/The Trash/Views/Account/ChangePasswordSheet.swift diff --git a/The Trash/Views/Account/FloatingToast.swift b/legacy/swift-ios/The Trash/Views/Account/FloatingToast.swift similarity index 100% rename from The Trash/Views/Account/FloatingToast.swift rename to legacy/swift-ios/The Trash/Views/Account/FloatingToast.swift diff --git a/The Trash/Views/Account/UpgradeGuestSheet.swift b/legacy/swift-ios/The Trash/Views/Account/UpgradeGuestSheet.swift similarity index 100% rename from The Trash/Views/Account/UpgradeGuestSheet.swift rename to legacy/swift-ios/The Trash/Views/Account/UpgradeGuestSheet.swift diff --git a/The Trash/Views/Admin/AdminLogsView.swift b/legacy/swift-ios/The Trash/Views/Admin/AdminLogsView.swift similarity index 100% rename from The Trash/Views/Admin/AdminLogsView.swift rename to legacy/swift-ios/The Trash/Views/Admin/AdminLogsView.swift diff --git a/The Trash/Views/Admin/CommunityAdminDashboard.swift b/legacy/swift-ios/The Trash/Views/Admin/CommunityAdminDashboard.swift similarity index 100% rename from The Trash/Views/Admin/CommunityAdminDashboard.swift rename to legacy/swift-ios/The Trash/Views/Admin/CommunityAdminDashboard.swift diff --git a/The Trash/Views/Admin/CommunityMembersListView.swift b/legacy/swift-ios/The Trash/Views/Admin/CommunityMembersListView.swift similarity index 100% rename from The Trash/Views/Admin/CommunityMembersListView.swift rename to legacy/swift-ios/The Trash/Views/Admin/CommunityMembersListView.swift diff --git a/The Trash/Views/Admin/EditCommunityInfoView.swift b/legacy/swift-ios/The Trash/Views/Admin/EditCommunityInfoView.swift similarity index 100% rename from The Trash/Views/Admin/EditCommunityInfoView.swift rename to legacy/swift-ios/The Trash/Views/Admin/EditCommunityInfoView.swift diff --git a/The Trash/Views/Admin/GrantAchievementToMemberView.swift b/legacy/swift-ios/The Trash/Views/Admin/GrantAchievementToMemberView.swift similarity index 100% rename from The Trash/Views/Admin/GrantAchievementToMemberView.swift rename to legacy/swift-ios/The Trash/Views/Admin/GrantAchievementToMemberView.swift diff --git a/The Trash/Views/Admin/GrantCreditsView.swift b/legacy/swift-ios/The Trash/Views/Admin/GrantCreditsView.swift similarity index 100% rename from The Trash/Views/Admin/GrantCreditsView.swift rename to legacy/swift-ios/The Trash/Views/Admin/GrantCreditsView.swift diff --git a/The Trash/Views/Admin/ManageAchievementsView.swift b/legacy/swift-ios/The Trash/Views/Admin/ManageAchievementsView.swift similarity index 100% rename from The Trash/Views/Admin/ManageAchievementsView.swift rename to legacy/swift-ios/The Trash/Views/Admin/ManageAchievementsView.swift diff --git a/The Trash/Views/Arena/ArenaHubView.swift b/legacy/swift-ios/The Trash/Views/Arena/ArenaHubView.swift similarity index 100% rename from The Trash/Views/Arena/ArenaHubView.swift rename to legacy/swift-ios/The Trash/Views/Arena/ArenaHubView.swift diff --git a/The Trash/Views/Arena/ArenaSharedComponents.swift b/legacy/swift-ios/The Trash/Views/Arena/ArenaSharedComponents.swift similarity index 100% rename from The Trash/Views/Arena/ArenaSharedComponents.swift rename to legacy/swift-ios/The Trash/Views/Arena/ArenaSharedComponents.swift diff --git a/The Trash/Views/Arena/ArenaView.swift b/legacy/swift-ios/The Trash/Views/Arena/ArenaView.swift similarity index 100% rename from The Trash/Views/Arena/ArenaView.swift rename to legacy/swift-ios/The Trash/Views/Arena/ArenaView.swift diff --git a/The Trash/Views/Arena/ChallengeAcceptView.swift b/legacy/swift-ios/The Trash/Views/Arena/ChallengeAcceptView.swift similarity index 100% rename from The Trash/Views/Arena/ChallengeAcceptView.swift rename to legacy/swift-ios/The Trash/Views/Arena/ChallengeAcceptView.swift diff --git a/The Trash/Views/Arena/ChallengeInviteSheet.swift b/legacy/swift-ios/The Trash/Views/Arena/ChallengeInviteSheet.swift similarity index 100% rename from The Trash/Views/Arena/ChallengeInviteSheet.swift rename to legacy/swift-ios/The Trash/Views/Arena/ChallengeInviteSheet.swift diff --git a/The Trash/Views/Arena/ChallengeListView.swift b/legacy/swift-ios/The Trash/Views/Arena/ChallengeListView.swift similarity index 100% rename from The Trash/Views/Arena/ChallengeListView.swift rename to legacy/swift-ios/The Trash/Views/Arena/ChallengeListView.swift diff --git a/The Trash/Views/Arena/DailyChallengeView.swift b/legacy/swift-ios/The Trash/Views/Arena/DailyChallengeView.swift similarity index 100% rename from The Trash/Views/Arena/DailyChallengeView.swift rename to legacy/swift-ios/The Trash/Views/Arena/DailyChallengeView.swift diff --git a/The Trash/Views/Arena/DailyChallengeViewModel.swift b/legacy/swift-ios/The Trash/Views/Arena/DailyChallengeViewModel.swift similarity index 100% rename from The Trash/Views/Arena/DailyChallengeViewModel.swift rename to legacy/swift-ios/The Trash/Views/Arena/DailyChallengeViewModel.swift diff --git a/The Trash/Views/Arena/DailyLeaderboardView.swift b/legacy/swift-ios/The Trash/Views/Arena/DailyLeaderboardView.swift similarity index 100% rename from The Trash/Views/Arena/DailyLeaderboardView.swift rename to legacy/swift-ios/The Trash/Views/Arena/DailyLeaderboardView.swift diff --git a/The Trash/Views/Arena/DuelView.swift b/legacy/swift-ios/The Trash/Views/Arena/DuelView.swift similarity index 100% rename from The Trash/Views/Arena/DuelView.swift rename to legacy/swift-ios/The Trash/Views/Arena/DuelView.swift diff --git a/The Trash/Views/Arena/DuelViewModel.swift b/legacy/swift-ios/The Trash/Views/Arena/DuelViewModel.swift similarity index 100% rename from The Trash/Views/Arena/DuelViewModel.swift rename to legacy/swift-ios/The Trash/Views/Arena/DuelViewModel.swift diff --git a/The Trash/Views/Arena/SpeedSortView.swift b/legacy/swift-ios/The Trash/Views/Arena/SpeedSortView.swift similarity index 100% rename from The Trash/Views/Arena/SpeedSortView.swift rename to legacy/swift-ios/The Trash/Views/Arena/SpeedSortView.swift diff --git a/The Trash/Views/Arena/SpeedSortViewModel.swift b/legacy/swift-ios/The Trash/Views/Arena/SpeedSortViewModel.swift similarity index 100% rename from The Trash/Views/Arena/SpeedSortViewModel.swift rename to legacy/swift-ios/The Trash/Views/Arena/SpeedSortViewModel.swift diff --git a/The Trash/Views/Arena/StreakLeaderboardView.swift b/legacy/swift-ios/The Trash/Views/Arena/StreakLeaderboardView.swift similarity index 100% rename from The Trash/Views/Arena/StreakLeaderboardView.swift rename to legacy/swift-ios/The Trash/Views/Arena/StreakLeaderboardView.swift diff --git a/The Trash/Views/Arena/StreakModeView.swift b/legacy/swift-ios/The Trash/Views/Arena/StreakModeView.swift similarity index 100% rename from The Trash/Views/Arena/StreakModeView.swift rename to legacy/swift-ios/The Trash/Views/Arena/StreakModeView.swift diff --git a/The Trash/Views/Arena/StreakModeViewModel.swift b/legacy/swift-ios/The Trash/Views/Arena/StreakModeViewModel.swift similarity index 100% rename from The Trash/Views/Arena/StreakModeViewModel.swift rename to legacy/swift-ios/The Trash/Views/Arena/StreakModeViewModel.swift diff --git a/The Trash/Views/Auth/LoginView.swift b/legacy/swift-ios/The Trash/Views/Auth/LoginView.swift similarity index 100% rename from The Trash/Views/Auth/LoginView.swift rename to legacy/swift-ios/The Trash/Views/Auth/LoginView.swift diff --git a/The Trash/Views/Auth/ReportView.swift b/legacy/swift-ios/The Trash/Views/Auth/ReportView.swift similarity index 100% rename from The Trash/Views/Auth/ReportView.swift rename to legacy/swift-ios/The Trash/Views/Auth/ReportView.swift diff --git a/The Trash/Views/Community/CommunityComponents.swift b/legacy/swift-ios/The Trash/Views/Community/CommunityComponents.swift similarity index 100% rename from The Trash/Views/Community/CommunityComponents.swift rename to legacy/swift-ios/The Trash/Views/Community/CommunityComponents.swift diff --git a/The Trash/Views/Community/CommunityDetailView.swift b/legacy/swift-ios/The Trash/Views/Community/CommunityDetailView.swift similarity index 100% rename from The Trash/Views/Community/CommunityDetailView.swift rename to legacy/swift-ios/The Trash/Views/Community/CommunityDetailView.swift diff --git a/The Trash/Views/Community/CommunityView.swift b/legacy/swift-ios/The Trash/Views/Community/CommunityView.swift similarity index 100% rename from The Trash/Views/Community/CommunityView.swift rename to legacy/swift-ios/The Trash/Views/Community/CommunityView.swift diff --git a/The Trash/Views/Community/Components/EnhancedEventCard.swift b/legacy/swift-ios/The Trash/Views/Community/Components/EnhancedEventCard.swift similarity index 100% rename from The Trash/Views/Community/Components/EnhancedEventCard.swift rename to legacy/swift-ios/The Trash/Views/Community/Components/EnhancedEventCard.swift diff --git a/The Trash/Views/Community/CreateCommunitySheet.swift b/legacy/swift-ios/The Trash/Views/Community/CreateCommunitySheet.swift similarity index 100% rename from The Trash/Views/Community/CreateCommunitySheet.swift rename to legacy/swift-ios/The Trash/Views/Community/CreateCommunitySheet.swift diff --git a/The Trash/Views/Community/CreateEventSheet.swift b/legacy/swift-ios/The Trash/Views/Community/CreateEventSheet.swift similarity index 100% rename from The Trash/Views/Community/CreateEventSheet.swift rename to legacy/swift-ios/The Trash/Views/Community/CreateEventSheet.swift diff --git a/The Trash/Views/Community/EventsMapView.swift b/legacy/swift-ios/The Trash/Views/Community/EventsMapView.swift similarity index 100% rename from The Trash/Views/Community/EventsMapView.swift rename to legacy/swift-ios/The Trash/Views/Community/EventsMapView.swift diff --git a/The Trash/Views/Community/EventsView.swift b/legacy/swift-ios/The Trash/Views/Community/EventsView.swift similarity index 100% rename from The Trash/Views/Community/EventsView.swift rename to legacy/swift-ios/The Trash/Views/Community/EventsView.swift diff --git a/The Trash/Views/Community/GroupsView.swift b/legacy/swift-ios/The Trash/Views/Community/GroupsView.swift similarity index 100% rename from The Trash/Views/Community/GroupsView.swift rename to legacy/swift-ios/The Trash/Views/Community/GroupsView.swift diff --git a/The Trash/Views/Community/LocationPickerSheet.swift b/legacy/swift-ios/The Trash/Views/Community/LocationPickerSheet.swift similarity index 100% rename from The Trash/Views/Community/LocationPickerSheet.swift rename to legacy/swift-ios/The Trash/Views/Community/LocationPickerSheet.swift diff --git a/The Trash/Views/Leaderboard/LeaderboardComponents.swift b/legacy/swift-ios/The Trash/Views/Leaderboard/LeaderboardComponents.swift similarity index 100% rename from The Trash/Views/Leaderboard/LeaderboardComponents.swift rename to legacy/swift-ios/The Trash/Views/Leaderboard/LeaderboardComponents.swift diff --git a/The Trash/Views/Leaderboard/LeaderboardView.swift b/legacy/swift-ios/The Trash/Views/Leaderboard/LeaderboardView.swift similarity index 100% rename from The Trash/Views/Leaderboard/LeaderboardView.swift rename to legacy/swift-ios/The Trash/Views/Leaderboard/LeaderboardView.swift diff --git a/The Trash/Views/Profile/AchievementsListView.swift b/legacy/swift-ios/The Trash/Views/Profile/AchievementsListView.swift similarity index 100% rename from The Trash/Views/Profile/AchievementsListView.swift rename to legacy/swift-ios/The Trash/Views/Profile/AchievementsListView.swift diff --git a/The Trash/Views/Profile/BadgeAchievementsHubView.swift b/legacy/swift-ios/The Trash/Views/Profile/BadgeAchievementsHubView.swift similarity index 100% rename from The Trash/Views/Profile/BadgeAchievementsHubView.swift rename to legacy/swift-ios/The Trash/Views/Profile/BadgeAchievementsHubView.swift diff --git a/The Trash/Views/Profile/BadgePickerView.swift b/legacy/swift-ios/The Trash/Views/Profile/BadgePickerView.swift similarity index 100% rename from The Trash/Views/Profile/BadgePickerView.swift rename to legacy/swift-ios/The Trash/Views/Profile/BadgePickerView.swift diff --git a/The Trash/Views/Shared/AccountButton.swift b/legacy/swift-ios/The Trash/Views/Shared/AccountButton.swift similarity index 100% rename from The Trash/Views/Shared/AccountButton.swift rename to legacy/swift-ios/The Trash/Views/Shared/AccountButton.swift diff --git a/The Trash/Views/Shared/AchievementToastView.swift b/legacy/swift-ios/The Trash/Views/Shared/AchievementToastView.swift similarity index 100% rename from The Trash/Views/Shared/AchievementToastView.swift rename to legacy/swift-ios/The Trash/Views/Shared/AchievementToastView.swift diff --git a/The Trash/Views/Shared/EmptyStateView.swift b/legacy/swift-ios/The Trash/Views/Shared/EmptyStateView.swift similarity index 100% rename from The Trash/Views/Shared/EmptyStateView.swift rename to legacy/swift-ios/The Trash/Views/Shared/EmptyStateView.swift diff --git a/The Trash/Views/Shared/FloatingActionButton.swift b/legacy/swift-ios/The Trash/Views/Shared/FloatingActionButton.swift similarity index 100% rename from The Trash/Views/Shared/FloatingActionButton.swift rename to legacy/swift-ios/The Trash/Views/Shared/FloatingActionButton.swift diff --git a/The Trash/Views/Shared/RewardView.swift b/legacy/swift-ios/The Trash/Views/Shared/RewardView.swift similarity index 100% rename from The Trash/Views/Shared/RewardView.swift rename to legacy/swift-ios/The Trash/Views/Shared/RewardView.swift diff --git a/The Trash/Views/Shared/TrashHistoryView.swift b/legacy/swift-ios/The Trash/Views/Shared/TrashHistoryView.swift similarity index 100% rename from The Trash/Views/Shared/TrashHistoryView.swift rename to legacy/swift-ios/The Trash/Views/Shared/TrashHistoryView.swift diff --git a/The Trash/Views/Shared/UserAvatarView.swift b/legacy/swift-ios/The Trash/Views/Shared/UserAvatarView.swift similarity index 100% rename from The Trash/Views/Shared/UserAvatarView.swift rename to legacy/swift-ios/The Trash/Views/Shared/UserAvatarView.swift diff --git a/The Trash/Views/Verify/CameraView.swift b/legacy/swift-ios/The Trash/Views/Verify/CameraView.swift similarity index 100% rename from The Trash/Views/Verify/CameraView.swift rename to legacy/swift-ios/The Trash/Views/Verify/CameraView.swift diff --git a/The Trash/Views/Verify/VerifyComponents.swift b/legacy/swift-ios/The Trash/Views/Verify/VerifyComponents.swift similarity index 100% rename from The Trash/Views/Verify/VerifyComponents.swift rename to legacy/swift-ios/The Trash/Views/Verify/VerifyComponents.swift diff --git a/The Trash/Views/Verify/VerifyView.swift b/legacy/swift-ios/The Trash/Views/Verify/VerifyView.swift similarity index 100% rename from The Trash/Views/Verify/VerifyView.swift rename to legacy/swift-ios/The Trash/Views/Verify/VerifyView.swift diff --git a/legacy/swift-ios/The Trash/migrations/20260217052438_remote_schema.sql b/legacy/swift-ios/The Trash/migrations/20260217052438_remote_schema.sql new file mode 100644 index 0000000..2b744db --- /dev/null +++ b/legacy/swift-ios/The Trash/migrations/20260217052438_remote_schema.sql @@ -0,0 +1,4133 @@ + + + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + + +COMMENT ON SCHEMA "public" IS 'standard public schema'; + + + +CREATE EXTENSION IF NOT EXISTS "pg_graphql" WITH SCHEMA "graphql"; + + + + + + +CREATE EXTENSION IF NOT EXISTS "pg_stat_statements" WITH SCHEMA "extensions"; + + + + + + +CREATE EXTENSION IF NOT EXISTS "pgcrypto" WITH SCHEMA "extensions"; + + + + + + +CREATE EXTENSION IF NOT EXISTS "supabase_vault" WITH SCHEMA "vault"; + + + + + + +CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA "extensions"; + + + + + + +CREATE OR REPLACE FUNCTION "public"."accept_arena_challenge"("p_challenge_id" "uuid") RETURNS json + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +DECLARE + v_user_id UUID; + v_challenge RECORD; + v_questions JSON; +BEGIN + v_user_id := auth.uid(); + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + -- Get challenge + SELECT * INTO v_challenge + FROM public.arena_challenges + WHERE id = p_challenge_id; + + IF v_challenge IS NULL THEN + RAISE EXCEPTION 'Challenge not found'; + END IF; + + IF v_challenge.opponent_id != v_user_id AND v_challenge.challenger_id != v_user_id THEN + RAISE EXCEPTION 'Not your challenge'; + END IF; + + IF v_challenge.status != 'pending' THEN + RAISE EXCEPTION 'Challenge is no longer pending (status: %)', v_challenge.status; + END IF; + + -- Check expiry + IF v_challenge.expires_at < timezone('utc', now()) THEN + UPDATE public.arena_challenges SET status = 'expired' WHERE id = p_challenge_id; + RAISE EXCEPTION 'Challenge has expired'; + END IF; + + -- Accept + UPDATE public.arena_challenges + SET status = 'accepted' + WHERE id = p_challenge_id; + + -- Get questions in order + SELECT json_agg(q ORDER BY ord.ordinality) + INTO v_questions + FROM unnest(v_challenge.question_ids) WITH ORDINALITY AS ord(qid, ordinality) + JOIN public.quiz_questions q ON q.id = ord.qid; + + RETURN json_build_object( + 'challenge_id', p_challenge_id, + 'channel_name', v_challenge.channel_name, + 'questions', COALESCE(v_questions, '[]'::json), + 'challenger_id', v_challenge.challenger_id, + 'opponent_id', v_challenge.opponent_id + ); +END; +$$; + + +ALTER FUNCTION "public"."accept_arena_challenge"("p_challenge_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."apply_to_join_community"("p_community_id" "text", "p_message" "text" DEFAULT NULL::"text") RETURNS json + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +DECLARE + v_user_id UUID := auth.uid(); + v_requires_approval BOOLEAN; + v_community_name TEXT; +BEGIN + -- 检查是否登录 + IF v_user_id IS NULL THEN + RETURN json_build_object('success', false, 'message', 'Not authenticated'); + END IF; + + -- 检查社区是否存在并获取设置 + SELECT requires_approval, name INTO v_requires_approval, v_community_name + FROM public.communities + WHERE id = p_community_id AND is_active = true; + + IF NOT FOUND THEN + RETURN json_build_object('success', false, 'message', 'Community not found'); + END IF; + + -- 检查是否已经是成员 + IF EXISTS ( + SELECT 1 FROM public.user_community_memberships + WHERE user_id = v_user_id AND community_id = p_community_id + ) THEN + RETURN json_build_object('success', false, 'message', 'Already a member'); + END IF; + + -- 如果不需要审批,直接加入 + IF NOT v_requires_approval THEN + INSERT INTO public.user_community_memberships (user_id, community_id, status) + VALUES (v_user_id, p_community_id, 'member'); + + UPDATE public.communities + SET member_count = member_count + 1, updated_at = NOW() + WHERE id = p_community_id; + + RETURN json_build_object( + 'success', true, + 'message', 'Joined successfully', + 'requires_approval', false + ); + END IF; + + -- 需要审批:创建申请 + INSERT INTO public.community_join_applications (community_id, user_id, message) + VALUES (p_community_id, v_user_id, p_message) + ON CONFLICT (community_id, user_id) + DO UPDATE SET + status = 'pending', + message = EXCLUDED.message, + updated_at = NOW(); + + RETURN json_build_object( + 'success', true, + 'message', 'Application submitted', + 'requires_approval', true + ); +END; +$$; + + +ALTER FUNCTION "public"."apply_to_join_community"("p_community_id" "text", "p_message" "text") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."apply_to_join_community"("p_community_id" "text", "p_message" "text") IS '申请加入社区(如需审批则创建申请)'; + + + +CREATE OR REPLACE FUNCTION "public"."calculate_distance_km"("lat1" numeric, "lon1" numeric, "lat2" numeric, "lon2" numeric) RETURNS numeric + LANGUAGE "plpgsql" IMMUTABLE + SET "search_path" TO 'public', 'pg_temp' + AS $$ +DECLARE + R CONSTANT DECIMAL := 6371; -- 地球半径(公里) + dlat DECIMAL; + dlon DECIMAL; + a DECIMAL; + c DECIMAL; +BEGIN + dlat := radians(lat2 - lat1); + dlon := radians(lon2 - lon1); + a := sin(dlat/2) * sin(dlat/2) + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon/2) * sin(dlon/2); + c := 2 * atan2(sqrt(a), sqrt(1-a)); + RETURN R * c; +END; +$$; + + +ALTER FUNCTION "public"."calculate_distance_km"("lat1" numeric, "lon1" numeric, "lat2" numeric, "lon2" numeric) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."can_user_create_community"() RETURNS json + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +DECLARE + v_user_id UUID := auth.uid(); + v_count INTEGER; + v_max_allowed INTEGER := 3; +BEGIN + IF v_user_id IS NULL THEN + RETURN json_build_object('allowed', false, 'reason', 'Not authenticated', 'current_count', 0, 'max_allowed', v_max_allowed); + END IF; + + SELECT COUNT(*) INTO v_count + FROM public.communities + WHERE created_by = v_user_id; + + IF v_count >= v_max_allowed THEN + RETURN json_build_object('allowed', false, 'reason', 'Maximum community limit reached', 'current_count', v_count, 'max_allowed', v_max_allowed); + END IF; + + RETURN json_build_object('allowed', true, 'reason', NULL, 'current_count', v_count, 'max_allowed', v_max_allowed); +END; +$$; + + +ALTER FUNCTION "public"."can_user_create_community"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."can_user_create_event"() RETURNS json + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +DECLARE + v_user_id UUID := auth.uid(); + v_count INTEGER; + v_max_allowed INTEGER := 7; + v_week_start TIMESTAMPTZ; +BEGIN + IF v_user_id IS NULL THEN + RETURN json_build_object('allowed', false, 'reason', 'Not authenticated', 'current_count', 0, 'max_allowed', v_max_allowed); + END IF; + + -- Calculate start of current week (Monday) + v_week_start := date_trunc('week', NOW()); + + SELECT COUNT(*) INTO v_count + FROM public.community_events + WHERE created_by = v_user_id + AND created_at >= v_week_start; + + IF v_count >= v_max_allowed THEN + RETURN json_build_object('allowed', false, 'reason', 'Weekly event limit reached', 'current_count', v_count, 'max_allowed', v_max_allowed); + END IF; + + RETURN json_build_object('allowed', true, 'reason', NULL, 'current_count', v_count, 'max_allowed', v_max_allowed); +END; +$$; + + +ALTER FUNCTION "public"."can_user_create_event"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."can_view_community_roster"("p_community_id" "text", "p_user_id" "uuid") RETURNS boolean + LANGUAGE "plpgsql" STABLE SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $_$ +DECLARE + v_result BOOLEAN := FALSE; +BEGIN + IF p_community_id IS NULL OR p_user_id IS NULL THEN + RETURN FALSE; + END IF; + + IF to_regclass('public.user_community_memberships') IS NOT NULL THEN + EXECUTE $sql$ + SELECT EXISTS ( + SELECT 1 + FROM public.user_community_memberships m + WHERE m.community_id = $1 + AND m.user_id = $2 + AND m.status IN ('member', 'admin') + ) + $sql$ + INTO v_result + USING p_community_id, p_user_id; + + RETURN v_result; + END IF; + + IF to_regclass('public.user_community_membership') IS NOT NULL THEN + EXECUTE $sql$ + SELECT EXISTS ( + SELECT 1 + FROM public.user_community_membership m + WHERE m.community_id = $1 + AND m.user_id = $2 + AND m.status IN ('member', 'admin') + ) + $sql$ + INTO v_result + USING p_community_id, p_user_id; + + RETURN v_result; + END IF; + + RETURN FALSE; +END; +$_$; + + +ALTER FUNCTION "public"."can_view_community_roster"("p_community_id" "text", "p_user_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."cancel_event_registration"("p_event_id" "uuid") RETURNS json + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +DECLARE + v_user_id UUID; +BEGIN + v_user_id := public.current_user_id(); + IF v_user_id IS NULL THEN + RETURN json_build_object('success', false, 'message', 'Not authenticated'); + END IF; + + UPDATE public.event_registrations + SET status = 'cancelled' + WHERE event_id = p_event_id + AND user_id = v_user_id + AND status = 'registered'; + + IF NOT FOUND THEN + RETURN json_build_object('success', false, 'message', 'Registration not found'); + END IF; + + UPDATE public.community_events + SET participant_count = GREATEST(0, COALESCE(participant_count, 0) - 1), + updated_at = NOW() + WHERE id = p_event_id; + + RETURN json_build_object('success', true, 'message', 'Registration cancelled'); +END; +$$; + + +ALTER FUNCTION "public"."cancel_event_registration"("p_event_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."check_and_grant_achievement"("p_trigger_key" "text") RETURNS json + LANGUAGE "plpgsql" SECURITY DEFINER + AS $$ +DECLARE + v_user_id UUID := auth.uid(); + v_achievement RECORD; + v_profile RECORD; + v_already_has BOOLEAN; + v_qualifies BOOLEAN := false; + v_auth_email TEXT; + v_email_confirmed_at TIMESTAMPTZ; +BEGIN + IF v_user_id IS NULL THEN + RETURN json_build_object('granted', false, 'reason', 'Not authenticated'); + END IF; + + SELECT * INTO v_achievement FROM public.achievements + WHERE trigger_key = p_trigger_key AND community_id IS NULL; + + IF NOT FOUND THEN + RETURN json_build_object('granted', false, 'reason', 'Achievement not found'); + END IF; + + SELECT EXISTS ( + SELECT 1 FROM public.user_achievements + WHERE user_id = v_user_id AND achievement_id = v_achievement.id + ) INTO v_already_has; + + IF v_already_has THEN + RETURN json_build_object('granted', false, 'reason', 'Already earned'); + END IF; + + SELECT * INTO v_profile FROM public.profiles WHERE id = v_user_id; + + CASE p_trigger_key + WHEN 'first_scan' THEN + v_qualifies := COALESCE(v_profile.total_scans, 0) >= 1; + WHEN 'scans_10' THEN + v_qualifies := COALESCE(v_profile.total_scans, 0) >= 10; + WHEN 'scans_50' THEN + v_qualifies := COALESCE(v_profile.total_scans, 0) >= 50; + WHEN 'credits_100' THEN + v_qualifies := COALESCE(v_profile.credits, 0) >= 100; + WHEN 'credits_500' THEN + v_qualifies := COALESCE(v_profile.credits, 0) >= 500; + WHEN 'credits_2000' THEN + v_qualifies := COALESCE(v_profile.credits, 0) >= 2000; + WHEN 'join_community' THEN + v_qualifies := EXISTS ( + SELECT 1 FROM public.user_community_memberships + WHERE user_id = v_user_id AND status IN ('member', 'admin') + ); + WHEN 'arena_win' THEN + v_qualifies := true; + WHEN 'ucsd_email' THEN + SELECT email, email_confirmed_at INTO v_auth_email, v_email_confirmed_at + FROM auth.users + WHERE id = v_user_id; + v_qualifies := v_email_confirmed_at IS NOT NULL + AND v_auth_email ILIKE '%@ucsd.edu'; + ELSE + v_qualifies := false; + END CASE; + + IF NOT v_qualifies THEN + RETURN json_build_object('granted', false, 'reason', 'Not qualified'); + END IF; + + INSERT INTO public.user_achievements (user_id, achievement_id) + VALUES (v_user_id, v_achievement.id); + + RETURN json_build_object( + 'granted', true, + 'achievement_id', v_achievement.id, + 'name', v_achievement.name, + 'description', v_achievement.description, + 'icon_name', v_achievement.icon_name, + 'rarity', v_achievement.rarity + ); +END; +$$; + + +ALTER FUNCTION "public"."check_and_grant_achievement"("p_trigger_key" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."complete_arena_challenge"("p_challenge_id" "uuid") RETURNS json + LANGUAGE "plpgsql" SECURITY DEFINER + AS $$ +DECLARE + v_user_id UUID; + v_challenge RECORD; + v_challenger_correct INT; + v_opponent_correct INT; + v_challenger_score INT; + v_opponent_score INT; + v_winner_id UUID; + v_challenger_points INT; + v_opponent_points INT; + v_total_questions INT; + v_challenger_answers INT; + v_opponent_answers INT; +BEGIN + v_user_id := auth.uid(); + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + -- Lock row to prevent concurrent completion from double-awarding credits. + SELECT * INTO v_challenge + FROM public.arena_challenges + WHERE id = p_challenge_id + FOR UPDATE; + + IF v_challenge IS NULL THEN + RAISE EXCEPTION 'Challenge not found'; + END IF; + + IF v_challenge.challenger_id != v_user_id AND v_challenge.opponent_id != v_user_id THEN + RAISE EXCEPTION 'Not your challenge'; + END IF; + + IF v_challenge.status = 'completed' THEN + RETURN json_build_object( + 'challenge_id', p_challenge_id, + 'challenger_score', v_challenge.challenger_score, + 'opponent_score', v_challenge.opponent_score, + 'winner_id', v_challenge.winner_id, + 'already_completed', true + ); + END IF; + + IF v_challenge.status NOT IN ('accepted', 'in_progress') THEN + RAISE EXCEPTION 'Challenge is not active (status: %)', v_challenge.status; + END IF; + + v_total_questions := COALESCE(array_length(v_challenge.question_ids, 1), 0); + IF v_total_questions <= 0 THEN + RAISE EXCEPTION 'Challenge has no questions'; + END IF; + + SELECT COUNT(*) INTO v_challenger_answers + FROM public.arena_challenge_answers + WHERE challenge_id = p_challenge_id AND user_id = v_challenge.challenger_id; + + SELECT COUNT(*) INTO v_opponent_answers + FROM public.arena_challenge_answers + WHERE challenge_id = p_challenge_id AND user_id = v_challenge.opponent_id; + + IF v_challenger_answers < v_total_questions OR v_opponent_answers < v_total_questions THEN + RAISE EXCEPTION 'Challenge not complete yet'; + END IF; + + SELECT COUNT(*) FILTER (WHERE is_correct) INTO v_challenger_correct + FROM public.arena_challenge_answers + WHERE challenge_id = p_challenge_id AND user_id = v_challenge.challenger_id; + + SELECT COUNT(*) FILTER (WHERE is_correct) INTO v_opponent_correct + FROM public.arena_challenge_answers + WHERE challenge_id = p_challenge_id AND user_id = v_challenge.opponent_id; + + v_challenger_score := v_challenger_correct * 20; + v_opponent_score := v_opponent_correct * 20; + + IF v_challenger_score > v_opponent_score THEN + v_winner_id := v_challenge.challenger_id; + ELSIF v_opponent_score > v_challenger_score THEN + v_winner_id := v_challenge.opponent_id; + ELSE + v_winner_id := NULL; + END IF; + + IF v_winner_id IS NULL THEN + v_challenger_points := 30; + v_opponent_points := 30; + ELSIF v_winner_id = v_challenge.challenger_id THEN + v_challenger_points := 50; + v_opponent_points := 10; + ELSE + v_challenger_points := 10; + v_opponent_points := 50; + END IF; + + UPDATE public.arena_challenges + SET + status = 'completed', + challenger_score = v_challenger_score, + opponent_score = v_opponent_score, + winner_id = v_winner_id, + completed_at = timezone('utc', now()) + WHERE id = p_challenge_id; + + UPDATE public.profiles SET credits = credits + v_challenger_points WHERE id = v_challenge.challenger_id; + UPDATE public.profiles SET credits = credits + v_opponent_points WHERE id = v_challenge.opponent_id; + + RETURN json_build_object( + 'challenge_id', p_challenge_id, + 'challenger_score', v_challenger_score, + 'opponent_score', v_opponent_score, + 'winner_id', v_winner_id, + 'challenger_points', v_challenger_points, + 'opponent_points', v_opponent_points, + 'already_completed', false + ); +END; +$$; + + +ALTER FUNCTION "public"."complete_arena_challenge"("p_challenge_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."create_arena_challenge"("p_opponent_id" "uuid") RETURNS json + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +DECLARE + v_user_id UUID; + v_challenge_id UUID; + v_question_ids UUID[]; + v_channel_name TEXT; +BEGIN + v_user_id := auth.uid(); + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + IF v_user_id = p_opponent_id THEN + RAISE EXCEPTION 'Cannot challenge yourself'; + END IF; + + -- Expire stale pending challenges first + UPDATE public.arena_challenges + SET status = 'expired' + WHERE status = 'pending' + AND expires_at < timezone('utc', now()); + + -- Check for existing pending challenge to this opponent + IF EXISTS ( + SELECT 1 FROM public.arena_challenges + WHERE challenger_id = v_user_id + AND opponent_id = p_opponent_id + AND status = 'pending' + ) THEN + RAISE EXCEPTION 'You already have a pending challenge to this player'; + END IF; + + -- Select 10 random questions + SELECT ARRAY( + SELECT q.id + FROM public.quiz_questions q + WHERE q.is_active = true + ORDER BY random() + LIMIT 10 + ) INTO v_question_ids; + + IF array_length(v_question_ids, 1) < 10 THEN + RAISE EXCEPTION 'Not enough questions available'; + END IF; + + v_challenge_id := gen_random_uuid(); + v_channel_name := 'duel:' || v_challenge_id::text; + + INSERT INTO public.arena_challenges ( + id, challenger_id, opponent_id, status, question_ids, channel_name, + expires_at + ) VALUES ( + v_challenge_id, v_user_id, p_opponent_id, 'pending', v_question_ids, v_channel_name, + timezone('utc', now()) + INTERVAL '1 minute' + ); + + RETURN json_build_object( + 'challenge_id', v_challenge_id, + 'channel_name', v_channel_name, + 'status', 'pending' + ); +END; +$$; + + +ALTER FUNCTION "public"."create_arena_challenge"("p_opponent_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."create_community"("p_id" "text", "p_name" "text", "p_city" "text", "p_state" "text", "p_description" "text" DEFAULT NULL::"text", "p_latitude" numeric DEFAULT NULL::numeric, "p_longitude" numeric DEFAULT NULL::numeric) RETURNS json + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +DECLARE + v_user_id UUID := auth.uid(); + v_can_create json; + v_community_id TEXT; +BEGIN + IF v_user_id IS NULL THEN + RETURN json_build_object('success', false, 'message', 'Not authenticated'); + END IF; + + -- Check limit + v_can_create := public.can_user_create_community(); + IF NOT (v_can_create->>'allowed')::boolean THEN + RETURN json_build_object('success', false, 'message', v_can_create->>'reason'); + END IF; + + -- Check if ID already exists + IF EXISTS (SELECT 1 FROM public.communities WHERE id = p_id) THEN + RETURN json_build_object('success', false, 'message', 'Community ID already exists'); + END IF; + + -- Create community + INSERT INTO public.communities (id, name, city, state, description, latitude, longitude, created_by, member_count) + VALUES (p_id, p_name, p_city, p_state, p_description, p_latitude, p_longitude, v_user_id, 1) + RETURNING id INTO v_community_id; + + -- Auto-join creator as admin + INSERT INTO public.user_community_memberships (user_id, community_id, status) + VALUES (v_user_id, v_community_id, 'admin'); + + RETURN json_build_object('success', true, 'message', 'Community created', 'community_id', v_community_id); +END; +$$; + + +ALTER FUNCTION "public"."create_community"("p_id" "text", "p_name" "text", "p_city" "text", "p_state" "text", "p_description" "text", "p_latitude" numeric, "p_longitude" numeric) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."create_event"("p_title" "text", "p_description" "text", "p_category" "text", "p_event_date" timestamp with time zone, "p_location" "text", "p_latitude" numeric, "p_longitude" numeric, "p_max_participants" integer DEFAULT 50, "p_community_id" "text" DEFAULT NULL::"text", "p_icon_name" "text" DEFAULT 'calendar'::"text") RETURNS json + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +DECLARE + v_user_id UUID := auth.uid(); + v_can_create json; + v_event_id UUID; + v_organizer TEXT; + v_is_personal BOOLEAN; +BEGIN + IF v_user_id IS NULL THEN + RETURN json_build_object('success', false, 'message', 'Not authenticated'); + END IF; + + -- Check limit + v_can_create := public.can_user_create_event(); + IF NOT (v_can_create->>'allowed')::boolean THEN + RETURN json_build_object('success', false, 'message', v_can_create->>'reason'); + END IF; + + -- Determine if personal or community event + v_is_personal := (p_community_id IS NULL); + + -- Get organizer name + IF v_is_personal THEN + SELECT COALESCE(username, email, 'Anonymous') INTO v_organizer + FROM public.profiles + WHERE id = v_user_id; + ELSE + -- 🔥 CHANGED: Only admins can create community events + IF NOT EXISTS ( + SELECT 1 FROM public.user_community_memberships + WHERE user_id = v_user_id AND community_id = p_community_id AND status = 'admin' + ) THEN + RETURN json_build_object('success', false, 'message', 'Only community admins can create community events'); + END IF; + + SELECT name INTO v_organizer + FROM public.communities + WHERE id = p_community_id; + END IF; + + -- Create event + INSERT INTO public.community_events ( + community_id, title, description, organizer, category, event_date, + location, latitude, longitude, max_participants, icon_name, + created_by, is_personal + ) + VALUES ( + p_community_id, p_title, p_description, v_organizer, p_category, p_event_date, + p_location, p_latitude, p_longitude, p_max_participants, p_icon_name, + v_user_id, v_is_personal + ) + RETURNING id INTO v_event_id; + + RETURN json_build_object('success', true, 'message', 'Event created', 'event_id', v_event_id); +END; +$$; + + +ALTER FUNCTION "public"."create_event"("p_title" "text", "p_description" "text", "p_category" "text", "p_event_date" timestamp with time zone, "p_location" "text", "p_latitude" numeric, "p_longitude" numeric, "p_max_participants" integer, "p_community_id" "text", "p_icon_name" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."current_user_id"() RETURNS "uuid" + LANGUAGE "sql" STABLE SECURITY DEFINER + SET "search_path" TO 'public' + AS $$ + SELECT auth.uid(); +$$; + + +ALTER FUNCTION "public"."current_user_id"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."decline_arena_challenge"("p_challenge_id" "uuid") RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +DECLARE + v_user_id UUID; + v_challenge RECORD; +BEGIN + v_user_id := auth.uid(); + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + SELECT * INTO v_challenge + FROM public.arena_challenges + WHERE id = p_challenge_id; + + IF v_challenge IS NULL THEN + RAISE EXCEPTION 'Challenge not found'; + END IF; + + IF v_challenge.challenger_id != v_user_id AND v_challenge.opponent_id != v_user_id THEN + RAISE EXCEPTION 'Not your challenge'; + END IF; + + IF v_challenge.status NOT IN ('pending', 'accepted') THEN + RAISE EXCEPTION 'Cannot decline challenge in status: %', v_challenge.status; + END IF; + + IF v_challenge.challenger_id = v_user_id THEN + UPDATE public.arena_challenges SET status = 'cancelled' WHERE id = p_challenge_id; + ELSE + UPDATE public.arena_challenges SET status = 'declined' WHERE id = p_challenge_id; + END IF; +END; +$$; + + +ALTER FUNCTION "public"."decline_arena_challenge"("p_challenge_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."find_friends_leaderboard"("p_emails" "text"[] DEFAULT ARRAY[]::"text"[], "p_phones" "text"[] DEFAULT ARRAY[]::"text"[]) RETURNS TABLE("id" "uuid", "username" "text", "credits" integer, "email" "text", "phone" "text") + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'auth' + AS $$ +BEGIN + RETURN QUERY + WITH normalized_emails AS ( + SELECT DISTINCT LOWER(TRIM(e)) AS email + FROM unnest(COALESCE(p_emails, ARRAY[]::TEXT[])) AS e + WHERE TRIM(e) <> '' + ), + normalized_phones AS ( + SELECT DISTINCT public.normalize_phone_number(raw_phone) AS phone + FROM unnest(COALESCE(p_phones, ARRAY[]::TEXT[])) AS raw_phone + CROSS JOIN LATERAL public.normalize_phone_number(raw_phone) + WHERE public.normalize_phone_number(raw_phone) IS NOT NULL + ), + profiles_with_auth AS ( + SELECT + p.id, + COALESCE(p.username, 'Anonymous')::TEXT AS username, + COALESCE(p.credits, 0) AS credits, + u.email::TEXT AS email, + u.phone::TEXT AS phone, + public.normalize_phone_number(u.phone) AS normalized_phone + FROM public.profiles p + JOIN auth.users u ON u.id = p.id + ) + SELECT + pa.id, + pa.username, + pa.credits, + pa.email, + pa.phone + FROM profiles_with_auth pa + WHERE ( + EXISTS ( + SELECT 1 + FROM normalized_emails ne + WHERE ne.email = LOWER(pa.email) + ) + OR ( + pa.normalized_phone IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM normalized_phones np + WHERE np.phone = pa.normalized_phone + ) + ) + ); +END; +$$; + + +ALTER FUNCTION "public"."find_friends_leaderboard"("p_emails" "text"[], "p_phones" "text"[]) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_admin_action_logs"("p_community_id" "text", "p_limit" integer DEFAULT 50) RETURNS TABLE("id" "uuid", "admin_username" "text", "action_type" "text", "target_username" "text", "details" "jsonb", "created_at" timestamp with time zone) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +BEGIN + -- 检查权限 + IF NOT public.is_community_admin(p_community_id, auth.uid()) THEN + RAISE EXCEPTION 'Permission denied: Only admins can view action logs'; + END IF; + + RETURN QUERY + SELECT + l.id, + COALESCE(admin_p.username, 'Unknown')::TEXT AS admin_username, + l.action_type, + COALESCE(target_p.username, NULL)::TEXT AS target_username, + l.details, + l.created_at + FROM public.admin_action_logs l + LEFT JOIN public.profiles admin_p ON l.admin_id = admin_p.id + LEFT JOIN public.profiles target_p ON l.target_user_id = target_p.id + WHERE l.community_id = p_community_id + ORDER BY l.created_at DESC + LIMIT p_limit; +END; +$$; + + +ALTER FUNCTION "public"."get_admin_action_logs"("p_community_id" "text", "p_limit" integer) OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."get_admin_action_logs"("p_community_id" "text", "p_limit" integer) IS '获取管理员操作日志(仅管理员)'; + + + +CREATE OR REPLACE FUNCTION "public"."get_challenge_questions"("p_challenge_id" "uuid") RETURNS json + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +DECLARE + v_user_id UUID; + v_challenge RECORD; + v_questions JSON; +BEGIN + v_user_id := auth.uid(); + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + SELECT * INTO v_challenge + FROM public.arena_challenges + WHERE id = p_challenge_id; + + IF v_challenge IS NULL THEN + RAISE EXCEPTION 'Challenge not found'; + END IF; + + IF v_challenge.challenger_id != v_user_id AND v_challenge.opponent_id != v_user_id THEN + RAISE EXCEPTION 'Not your challenge'; + END IF; + + IF v_challenge.status NOT IN ('accepted', 'in_progress') THEN + RAISE EXCEPTION 'Challenge is not ready for play'; + END IF; + + -- Get questions in order + SELECT json_agg(q ORDER BY ord.ordinality) + INTO v_questions + FROM unnest(v_challenge.question_ids) WITH ORDINALITY AS ord(qid, ordinality) + JOIN public.quiz_questions q ON q.id = ord.qid; + + RETURN json_build_object( + 'challenge_id', p_challenge_id, + 'channel_name', v_challenge.channel_name, + 'questions', COALESCE(v_questions, '[]'::json), + 'challenger_id', v_challenge.challenger_id, + 'opponent_id', v_challenge.opponent_id + ); +END; +$$; + + +ALTER FUNCTION "public"."get_challenge_questions"("p_challenge_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_communities_by_city"("p_city" "text") RETURNS TABLE("id" "text", "name" "text", "city" "text", "state" "text", "description" "text", "member_count" integer, "latitude" double precision, "longitude" double precision, "is_member" boolean) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +DECLARE + v_user_id UUID; +BEGIN + v_user_id := public.current_user_id(); + + RETURN QUERY + SELECT + c.id, + c.name, + c.city, + c.state, + c.description, + COALESCE(c.member_count, 0), + c.latitude::DOUBLE PRECISION, + c.longitude::DOUBLE PRECISION, + EXISTS ( + SELECT 1 + FROM public.user_community_memberships m + WHERE m.community_id = c.id + AND m.user_id = v_user_id + AND m.status IN ('member', 'admin') + ) + FROM public.communities c + WHERE c.city = p_city + AND c.is_active = true + ORDER BY c.member_count DESC, c.name ASC; +END; +$$; + + +ALTER FUNCTION "public"."get_communities_by_city"("p_city" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_community_events"("p_community_id" "text") RETURNS TABLE("id" "uuid", "title" "text", "description" "text", "organizer" "text", "category" "text", "event_date" timestamp with time zone, "location" "text", "latitude" numeric, "longitude" numeric, "icon_name" "text", "max_participants" integer, "participant_count" integer, "community_id" "text", "community_name" "text", "distance_km" numeric, "is_registered" boolean, "is_personal" boolean) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +BEGIN + RETURN QUERY + SELECT + e.id, + e.title, + e.description, + e.organizer, + e.category, + e.event_date, + e.location, + e.latitude, + e.longitude, + e.icon_name, + e.max_participants, + e.participant_count, + e.community_id, + c.name as community_name, + 0::DECIMAL as distance_km, + EXISTS ( + SELECT 1 FROM public.event_registrations r + WHERE r.event_id = e.id AND r.user_id = auth.uid() AND r.status = 'registered' + ) as is_registered, + COALESCE(e.is_personal, false) as is_personal + FROM public.community_events e + LEFT JOIN public.communities c ON e.community_id = c.id + WHERE e.community_id = p_community_id + ORDER BY e.event_date DESC; +END; +$$; + + +ALTER FUNCTION "public"."get_community_events"("p_community_id" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_community_leaderboard"("p_community_id" "text", "p_limit" integer DEFAULT 100) RETURNS TABLE("id" "uuid", "username" "text", "credits" integer, "community_name" "text", "achievement_icon" "text") + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +BEGIN + RETURN QUERY + SELECT + p.id, + COALESCE(p.username, 'Anonymous')::TEXT AS username, + COALESCE(p.credits, 0) AS credits, + c.name AS community_name, + a.icon_name AS achievement_icon + FROM public.user_community_memberships cm + JOIN public.profiles p ON p.id = cm.user_id + JOIN public.communities c ON c.id = cm.community_id + LEFT JOIN public.achievements a ON a.id = p.selected_achievement_id + WHERE cm.community_id = p_community_id + AND cm.status IN ('member', 'admin') + ORDER BY COALESCE(p.credits, 0) DESC, p.username ASC + LIMIT LEAST(GREATEST(COALESCE(p_limit, 100), 1), 500); +END; +$$; + + +ALTER FUNCTION "public"."get_community_leaderboard"("p_community_id" "text", "p_limit" integer) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_community_members_admin"("p_community_id" "text") RETURNS TABLE("user_id" "uuid", "username" "text", "credits" integer, "status" "text", "joined_at" timestamp with time zone, "is_admin" boolean) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +BEGIN + -- 检查权限 + IF NOT public.is_community_admin(p_community_id, auth.uid()) THEN + RAISE EXCEPTION 'Permission denied: Only admins can view member details'; + END IF; + + RETURN QUERY + SELECT + m.user_id, + COALESCE(p.username, 'Anonymous')::TEXT AS username, + COALESCE(p.credits, 0) AS credits, + m.status, + m.joined_at, + (m.status = 'admin') AS is_admin + FROM public.user_community_memberships m + LEFT JOIN public.profiles p ON m.user_id = p.id + WHERE m.community_id = p_community_id + AND m.status IN ('member', 'admin') + ORDER BY + CASE WHEN m.status = 'admin' THEN 0 ELSE 1 END, + m.joined_at; +END; +$$; + + +ALTER FUNCTION "public"."get_community_members_admin"("p_community_id" "text") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."get_community_members_admin"("p_community_id" "text") IS '获取社区成员列表(管理员视图,含详细信息)'; + + + +CREATE OR REPLACE FUNCTION "public"."get_community_members_for_grant"("p_community_id" "text", "p_achievement_id" "uuid") RETURNS TABLE("user_id" "uuid", "username" "text", "already_has" boolean) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +DECLARE + v_admin_id UUID; +BEGIN + v_admin_id := public.current_user_id(); + IF v_admin_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + IF NOT public.is_community_admin(p_community_id, v_admin_id) THEN + RAISE EXCEPTION 'Only community admins can view this list'; + END IF; + + RETURN QUERY + SELECT + m.user_id, + COALESCE(p.username, 'Anonymous')::TEXT AS username, + EXISTS ( + SELECT 1 + FROM public.user_achievements ua + WHERE ua.user_id = m.user_id + AND ua.achievement_id = p_achievement_id + ) AS already_has + FROM public.user_community_memberships m + JOIN public.profiles p ON p.id = m.user_id + WHERE m.community_id = p_community_id + AND m.status IN ('member', 'admin') + ORDER BY p.username ASC; +END; +$$; + + +ALTER FUNCTION "public"."get_community_members_for_grant"("p_community_id" "text", "p_achievement_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_daily_challenge"() RETURNS json + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +DECLARE + v_user_id UUID; + v_today DATE; + v_challenge_id UUID; + v_question_ids UUID[]; + v_already_played BOOLEAN; + v_questions JSON; +BEGIN + v_user_id := auth.uid(); + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + v_today := (timezone('utc', now()))::date; + + -- Try to get existing challenge for today + SELECT id, question_ids INTO v_challenge_id, v_question_ids + FROM public.daily_challenges + WHERE challenge_date = v_today; + + -- Create if not exists + IF v_challenge_id IS NULL THEN + -- Pick 10 random questions + SELECT ARRAY( + SELECT q.id + FROM public.quiz_questions q + WHERE q.is_active = true + ORDER BY random() + LIMIT 10 + ) INTO v_question_ids; + + INSERT INTO public.daily_challenges (challenge_date, question_ids) + VALUES (v_today, v_question_ids) + ON CONFLICT (challenge_date) DO UPDATE SET challenge_date = EXCLUDED.challenge_date + RETURNING id INTO v_challenge_id; + + -- Re-read in case of race condition + SELECT question_ids INTO v_question_ids + FROM public.daily_challenges + WHERE id = v_challenge_id; + END IF; + + -- Check if user already played today + SELECT EXISTS( + SELECT 1 FROM public.daily_challenge_results + WHERE user_id = v_user_id AND challenge_date = v_today + ) INTO v_already_played; + + -- Get questions in order (using unnest with ordinality to preserve array order) + SELECT json_agg(q ORDER BY ord.ordinality) + INTO v_questions + FROM unnest(v_question_ids) WITH ORDINALITY AS ord(qid, ordinality) + JOIN public.quiz_questions q ON q.id = ord.qid; + + RETURN json_build_object( + 'challenge_id', v_challenge_id, + 'challenge_date', v_today, + 'already_played', v_already_played, + 'questions', COALESCE(v_questions, '[]'::json) + ); +END; +$$; + + +ALTER FUNCTION "public"."get_daily_challenge"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_daily_leaderboard"("p_date" "date" DEFAULT NULL::"date", "p_limit" integer DEFAULT 50) RETURNS TABLE("rank" bigint, "user_id" "uuid", "display_name" "text", "score" integer, "correct_count" integer, "time_seconds" numeric, "max_combo" integer) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +DECLARE + v_date DATE; +BEGIN + v_date := COALESCE(p_date, (timezone('utc', now()))::date); + + RETURN QUERY + SELECT + ROW_NUMBER() OVER (ORDER BY dr.score DESC, dr.time_seconds ASC) AS rank, + dr.user_id, + COALESCE(p.username, 'Anonymous') AS display_name, + dr.score, + dr.correct_count, + dr.time_seconds, + dr.max_combo + FROM public.daily_challenge_results dr + JOIN public.profiles p ON p.id = dr.user_id + WHERE dr.challenge_date = v_date + ORDER BY dr.score DESC, dr.time_seconds ASC + LIMIT p_limit; +END; +$$; + + +ALTER FUNCTION "public"."get_daily_leaderboard"("p_date" "date", "p_limit" integer) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_event_participants"("p_event_id" "uuid") RETURNS TABLE("user_id" "uuid", "username" "text", "credits" integer, "registered_at" timestamp with time zone) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +BEGIN + RETURN QUERY + SELECT + r.user_id, + COALESCE(p.username, 'Anonymous')::TEXT AS username, + COALESCE(p.credits, 0) AS credits, + r.registered_at + FROM public.event_registrations r + LEFT JOIN public.profiles p ON r.user_id = p.id + WHERE r.event_id = p_event_id + ORDER BY r.registered_at; +END; +$$; + + +ALTER FUNCTION "public"."get_event_participants"("p_event_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_my_achievements"() RETURNS TABLE("user_achievement_id" "uuid", "achievement_id" "uuid", "name" "text", "description" "text", "icon_name" "text", "community_id" "text", "community_name" "text", "granted_at" timestamp with time zone, "is_equipped" boolean, "rarity" "text") + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +DECLARE + v_user_id UUID; +BEGIN + v_user_id := public.current_user_id(); + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + RETURN QUERY + SELECT + ua.id AS user_achievement_id, + a.id AS achievement_id, + a.name, + a.description, + a.icon_name, + a.community_id, + c.name AS community_name, + ua.granted_at, + (p.selected_achievement_id = a.id) AS is_equipped, + COALESCE(a.rarity, 'common') AS rarity + FROM public.user_achievements ua + JOIN public.achievements a ON a.id = ua.achievement_id + LEFT JOIN public.communities c ON c.id = a.community_id + LEFT JOIN public.profiles p ON p.id = ua.user_id + WHERE ua.user_id = v_user_id + ORDER BY ua.granted_at DESC; +END; +$$; + + +ALTER FUNCTION "public"."get_my_achievements"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_my_challenges"("p_status" "text" DEFAULT NULL::"text") RETURNS json + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +DECLARE + v_user_id UUID; + v_result JSON; +BEGIN + v_user_id := auth.uid(); + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + -- Expire old pending challenges + UPDATE public.arena_challenges + SET status = 'expired' + WHERE status = 'pending' + AND expires_at < timezone('utc', now()); + + SELECT json_agg(row_to_json(t)) + INTO v_result + FROM ( + SELECT + ac.id, + ac.challenger_id, + ac.opponent_id, + ac.status, + ac.challenger_score, + ac.opponent_score, + ac.winner_id, + ac.channel_name, + ac.created_at, + ac.expires_at, + ac.started_at, + ac.completed_at, + cp.username AS challenger_name, + op.username AS opponent_name + FROM public.arena_challenges ac + JOIN public.profiles cp ON cp.id = ac.challenger_id + JOIN public.profiles op ON op.id = ac.opponent_id + WHERE (ac.challenger_id = v_user_id OR ac.opponent_id = v_user_id) + AND (p_status IS NULL OR ac.status = p_status) + ORDER BY ac.created_at DESC + LIMIT 50 + ) t; + + RETURN COALESCE(v_result, '[]'::json); +END; +$$; + + +ALTER FUNCTION "public"."get_my_challenges"("p_status" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_my_communities"() RETURNS TABLE("id" "text", "name" "text", "city" "text", "state" "text", "description" "text", "member_count" integer, "joined_at" timestamp with time zone, "status" "text") + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +BEGIN + RETURN QUERY + SELECT + c.id, + c.name, + c.city, + c.state, + c.description, + c.member_count, + m.joined_at, + m.status + FROM public.user_community_memberships m + JOIN public.communities c ON m.community_id = c.id + WHERE m.user_id = auth.uid() AND m.status IN ('member', 'admin') + ORDER BY m.joined_at DESC; +END; +$$; + + +ALTER FUNCTION "public"."get_my_communities"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_my_registrations"() RETURNS TABLE("registration_id" "uuid", "event_id" "uuid", "event_title" "text", "event_date" timestamp with time zone, "event_location" "text", "event_category" "text", "community_name" "text", "registration_status" "text", "registered_at" timestamp with time zone) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +DECLARE + v_user_id UUID; +BEGIN + v_user_id := public.current_user_id(); + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + RETURN QUERY + SELECT + r.id AS registration_id, + e.id AS event_id, + e.title AS event_title, + e.event_date, + e.location AS event_location, + e.category AS event_category, + COALESCE(c.name, 'Personal') AS community_name, + r.status AS registration_status, + r.registered_at + FROM public.event_registrations r + JOIN public.community_events e ON e.id = r.event_id + LEFT JOIN public.communities c ON c.id = e.community_id + WHERE r.user_id = v_user_id + ORDER BY e.event_date DESC; +END; +$$; + + +ALTER FUNCTION "public"."get_my_registrations"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_nearby_events"("p_latitude" numeric, "p_longitude" numeric, "p_max_distance_km" numeric DEFAULT 50, "p_category" "text" DEFAULT NULL::"text", "p_only_joined_communities" boolean DEFAULT false, "p_sort_by" "text" DEFAULT 'date'::"text") RETURNS TABLE("id" "uuid", "title" "text", "description" "text", "organizer" "text", "category" "text", "event_date" timestamp with time zone, "location" "text", "latitude" numeric, "longitude" numeric, "icon_name" "text", "max_participants" integer, "participant_count" integer, "community_id" "text", "community_name" "text", "distance_km" numeric, "is_registered" boolean, "is_personal" boolean) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +DECLARE + v_user_id UUID := auth.uid(); + v_lat_range DECIMAL; + v_lon_range DECIMAL; +BEGIN + -- Calculate rough bounding box (1 deg approx 111km) + -- Adding a small buffer (1.1 factor) to be safe + v_lat_range := (p_max_distance_km / 111.0) * 1.1; + -- Longitude degrees shrink as we move away from equator, but using 111km is safe as a lower bound for the 'degree width' in denominator, + -- meaning we might over-select, which is fine for a pre-filter. + -- To be more precise: v_lon_range := (p_max_distance_km / (111.0 * cos(radians(p_latitude)))) * 1.1; + -- For simplicity and speed in SQL without complex math in declaration: + v_lon_range := (p_max_distance_km / 50.0) * 1.1; -- Very generous box to avoid complex cos() logic issues at poles + + RETURN QUERY + SELECT + e.id, + e.title, + e.description, + e.organizer, + e.category, + e.event_date, + e.location, + e.latitude, + e.longitude, + e.icon_name, + e.max_participants, + e.participant_count, + e.community_id, + c.name AS community_name, + public.calculate_distance_km(p_latitude, p_longitude, e.latitude, e.longitude) AS distance_km, + EXISTS ( + SELECT 1 FROM public.event_registrations r + WHERE r.event_id = e.id AND r.user_id = v_user_id AND r.status = 'registered' + ) AS is_registered, + COALESCE(e.is_personal, false) AS is_personal + FROM public.community_events e + LEFT JOIN public.communities c ON e.community_id = c.id + WHERE e.status = 'upcoming' + AND e.event_date >= NOW() + -- Bounding Box Pre-filter + AND e.latitude BETWEEN (p_latitude - v_lat_range) AND (p_latitude + v_lat_range) + AND e.longitude BETWEEN (p_longitude - v_lon_range) AND (p_longitude + v_lon_range) + -- Primary Filter + AND public.calculate_distance_km(p_latitude, p_longitude, e.latitude, e.longitude) <= p_max_distance_km + AND (p_category IS NULL OR e.category = p_category) + AND ( + NOT p_only_joined_communities + OR e.is_personal = true + OR EXISTS ( + SELECT 1 FROM public.user_community_memberships m + WHERE m.community_id = e.community_id AND m.user_id = v_user_id AND m.status IN ('member', 'admin') + ) + ) + ORDER BY + CASE WHEN p_sort_by = 'date' THEN e.event_date END ASC, + CASE WHEN p_sort_by = 'distance' THEN public.calculate_distance_km(p_latitude, p_longitude, e.latitude, e.longitude) END ASC, + CASE WHEN p_sort_by = 'popularity' THEN e.participant_count END DESC; +END; +$$; + + +ALTER FUNCTION "public"."get_nearby_events"("p_latitude" numeric, "p_longitude" numeric, "p_max_distance_km" numeric, "p_category" "text", "p_only_joined_communities" boolean, "p_sort_by" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_pending_applications"("p_community_id" "text") RETURNS TABLE("id" "uuid", "user_id" "uuid", "username" "text", "user_credits" integer, "message" "text", "created_at" timestamp with time zone) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +BEGIN + -- 检查权限 + IF NOT public.is_community_admin(p_community_id, auth.uid()) THEN + RAISE EXCEPTION 'Permission denied: Only admins can view applications'; + END IF; + + RETURN QUERY + SELECT + a.id, + a.user_id, + COALESCE(p.username, 'Anonymous')::TEXT AS username, + COALESCE(p.credits, 0) AS user_credits, + a.message, + a.created_at + FROM public.community_join_applications a + LEFT JOIN public.profiles p ON a.user_id = p.id + WHERE a.community_id = p_community_id + AND a.status = 'pending' + ORDER BY a.created_at; +END; +$$; + + +ALTER FUNCTION "public"."get_pending_applications"("p_community_id" "text") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."get_pending_applications"("p_community_id" "text") IS '获取社区待审批的加入申请(仅管理员)'; + + +SET default_tablespace = ''; + +SET default_table_access_method = "heap"; + + +CREATE TABLE IF NOT EXISTS "public"."quiz_questions" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "image_url" "text" NOT NULL, + "correct_category" "text" NOT NULL, + "item_name" "text", + "created_at" timestamp with time zone DEFAULT "timezone"('utc'::"text", "now"()), + "is_active" boolean DEFAULT true +); + + +ALTER TABLE "public"."quiz_questions" OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_quiz_questions"() RETURNS SETOF "public"."quiz_questions" + LANGUAGE "sql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ + SELECT * FROM public.get_quiz_questions_batch(10); +$$; + + +ALTER FUNCTION "public"."get_quiz_questions"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_quiz_questions_batch"("p_limit" integer DEFAULT 10) RETURNS SETOF "public"."quiz_questions" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +BEGIN + RETURN QUERY + SELECT * + FROM public.quiz_questions + WHERE is_active = true + ORDER BY random() + LIMIT p_limit; +END; +$$; + + +ALTER FUNCTION "public"."get_quiz_questions_batch"("p_limit" integer) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_streak_leaderboard"("p_limit" integer DEFAULT 20) RETURNS TABLE("user_id" "uuid", "display_name" "text", "best_streak" integer, "total_games" bigint) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +BEGIN + RETURN QUERY + SELECT + sr.user_id, + COALESCE(p.username, 'Anonymous') AS display_name, + MAX(sr.streak_count) AS best_streak, + COUNT(sr.id) AS total_games + FROM public.streak_records sr + JOIN public.profiles p ON p.id = sr.user_id + GROUP BY sr.user_id, p.username + ORDER BY best_streak DESC, total_games DESC + LIMIT p_limit; +END; +$$; + + +ALTER FUNCTION "public"."get_streak_leaderboard"("p_limit" integer) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."grant_event_credits"("p_event_id" "uuid", "p_user_ids" "uuid"[], "p_credits_per_user" integer, "p_reason" "text") RETURNS json + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +DECLARE + v_admin_id UUID := auth.uid(); + v_community_id TEXT; + v_user_id UUID; + v_granted_count INTEGER := 0; +BEGIN + -- 获取活动所属社区 + SELECT community_id INTO v_community_id + FROM public.community_events + WHERE id = p_event_id; + + IF NOT FOUND THEN + RETURN json_build_object('success', false, 'message', 'Event not found'); + END IF; + + -- 检查权限(必须是社区管理员或活动创建者) + IF NOT ( + public.is_community_admin(v_community_id, v_admin_id) OR + EXISTS (SELECT 1 FROM public.community_events WHERE id = p_event_id AND created_by = v_admin_id) + ) THEN + RETURN json_build_object('success', false, 'message', 'Permission denied'); + END IF; + + -- 验证积分数量 + IF p_credits_per_user <= 0 OR p_credits_per_user > 1000 THEN + RETURN json_build_object('success', false, 'message', 'Invalid credit amount (must be 1-1000)'); + END IF; + + -- 为每个用户发放积分 + FOREACH v_user_id IN ARRAY p_user_ids LOOP + -- 检查用户是否报名了该活动 + IF EXISTS ( + SELECT 1 FROM public.event_registrations + WHERE event_id = p_event_id AND user_id = v_user_id + ) THEN + -- 增加积分 + UPDATE public.profiles + SET credits = credits + p_credits_per_user + WHERE id = v_user_id; + + -- 记录发放历史 + INSERT INTO public.credit_grants (user_id, granted_by, community_id, event_id, amount, reason) + VALUES (v_user_id, v_admin_id, v_community_id, p_event_id, p_credits_per_user, p_reason); + + v_granted_count := v_granted_count + 1; + END IF; + END LOOP; + + -- 记录管理员操作日志 + INSERT INTO public.admin_action_logs (community_id, admin_id, action_type, target_event_id, details) + VALUES (v_community_id, v_admin_id, 'grant_credits', p_event_id, + json_build_object( + 'user_count', v_granted_count, + 'credits_per_user', p_credits_per_user, + 'total_credits', v_granted_count * p_credits_per_user, + 'reason', p_reason + )); + + RETURN json_build_object( + 'success', true, + 'message', format('Credits granted to %s users', v_granted_count), + 'granted_count', v_granted_count + ); +END; +$$; + + +ALTER FUNCTION "public"."grant_event_credits"("p_event_id" "uuid", "p_user_ids" "uuid"[], "p_credits_per_user" integer, "p_reason" "text") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."grant_event_credits"("p_event_id" "uuid", "p_user_ids" "uuid"[], "p_credits_per_user" integer, "p_reason" "text") IS '为活动参与者批量发放积分(仅管理员)'; + + + +CREATE OR REPLACE FUNCTION "public"."handle_community_member_count"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +BEGIN + IF (TG_OP = 'INSERT') THEN + -- Only count if status is 'member' or 'admin' + IF NEW.status IN ('member', 'admin') THEN + UPDATE public.communities + SET member_count = member_count + 1, updated_at = NOW() + WHERE id = NEW.community_id; + END IF; + RETURN NEW; + ELSIF (TG_OP = 'DELETE') THEN + -- Only decrement if status was 'member' or 'admin' + IF OLD.status IN ('member', 'admin') THEN + UPDATE public.communities + SET member_count = GREATEST(0, member_count - 1), updated_at = NOW() + WHERE id = OLD.community_id; + END IF; + RETURN OLD; + ELSIF (TG_OP = 'UPDATE') THEN + -- Handle status changes (e.g. pending -> member) + -- Case 1: Becoming a member + IF OLD.status NOT IN ('member', 'admin') AND NEW.status IN ('member', 'admin') THEN + UPDATE public.communities + SET member_count = member_count + 1, updated_at = NOW() + WHERE id = NEW.community_id; + -- Case 2: No longer a member (e.g. banned/left but kept record?) - usually DELETE is used, but covering bases + ELSIF OLD.status IN ('member', 'admin') AND NEW.status NOT IN ('member', 'admin') THEN + UPDATE public.communities + SET member_count = GREATEST(0, member_count - 1), updated_at = NOW() + WHERE id = NEW.community_id; + END IF; + RETURN NEW; + END IF; + RETURN NULL; +END; +$$; + + +ALTER FUNCTION "public"."handle_community_member_count"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."handle_event_participant_count"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +BEGIN + IF (TG_OP = 'INSERT') THEN + IF NEW.status = 'registered' THEN + UPDATE public.community_events + SET participant_count = participant_count + 1 + WHERE id = NEW.event_id; + END IF; + RETURN NEW; + ELSIF (TG_OP = 'DELETE') THEN + IF OLD.status = 'registered' THEN + UPDATE public.community_events + SET participant_count = GREATEST(0, participant_count - 1) + WHERE id = OLD.event_id; + END IF; + RETURN OLD; + ELSIF (TG_OP = 'UPDATE') THEN + -- Case 1: Becoming registered + IF OLD.status != 'registered' AND NEW.status = 'registered' THEN + UPDATE public.community_events + SET participant_count = participant_count + 1 + WHERE id = NEW.event_id; + -- Case 2: No longer registered + ELSIF OLD.status = 'registered' AND NEW.status != 'registered' THEN + UPDATE public.community_events + SET participant_count = GREATEST(0, participant_count - 1) + WHERE id = NEW.event_id; + END IF; + RETURN NEW; + END IF; + RETURN NULL; +END; +$$; + + +ALTER FUNCTION "public"."handle_event_participant_count"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."handle_new_user"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +begin + insert into public.profiles (id, email, phone, credits) + values (new.id, new.email, new.phone, 0); + return new; +end; +$$; + + +ALTER FUNCTION "public"."handle_new_user"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."handle_user_update"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +BEGIN + UPDATE public.profiles + SET + email = NEW.email, + phone = NEW.phone + -- 如果你有 updated_at 字段,可以加上: , updated_at = NOW() + WHERE id = NEW.id; + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."handle_user_update"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."increment_credits"("amount" integer) RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +BEGIN + UPDATE public.profiles + SET credits = credits + amount + where id = auth.uid(); +END; +$$; + + +ALTER FUNCTION "public"."increment_credits"("amount" integer) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."increment_total_scans"() RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +BEGIN + UPDATE public.profiles + SET total_scans = COALESCE(total_scans, 0) + 1 + WHERE id = auth.uid(); +END; +$$; + + +ALTER FUNCTION "public"."increment_total_scans"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."is_community_admin"("p_community_id" "text", "p_user_id" "uuid" DEFAULT "auth"."uid"()) RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +BEGIN + RETURN EXISTS ( + SELECT 1 FROM public.user_community_memberships + WHERE community_id = p_community_id + AND user_id = p_user_id + AND status = 'admin' + ); +END; +$$; + + +ALTER FUNCTION "public"."is_community_admin"("p_community_id" "text", "p_user_id" "uuid") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."is_community_admin"("p_community_id" "text", "p_user_id" "uuid") IS '检查用户是否是社区管理员'; + + + +CREATE OR REPLACE FUNCTION "public"."join_community"("p_community_id" "text") RETURNS json + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +DECLARE + v_user_id UUID := auth.uid(); + v_existing RECORD; +BEGIN + IF v_user_id IS NULL THEN + RETURN json_build_object('success', false, 'message', 'Not authenticated'); + END IF; + + -- 检查社区是否存在 + IF NOT EXISTS (SELECT 1 FROM public.communities WHERE id = p_community_id AND is_active = true) THEN + RETURN json_build_object('success', false, 'message', 'Community not found'); + END IF; + + -- 检查是否已加入 + SELECT * INTO v_existing FROM public.user_community_memberships + WHERE user_id = v_user_id AND community_id = p_community_id; + + IF FOUND THEN + IF v_existing.status = 'member' THEN + RETURN json_build_object('success', false, 'message', 'Already a member'); + ELSIF v_existing.status = 'banned' THEN + RETURN json_build_object('success', false, 'message', 'You are banned from this community'); + ELSE + -- 重新激活 + UPDATE public.user_community_memberships + SET status = 'member', joined_at = NOW() + WHERE id = v_existing.id; + END IF; + ELSE + -- 新加入 + INSERT INTO public.user_community_memberships (user_id, community_id, status) + VALUES (v_user_id, p_community_id, 'member'); + END IF; + + -- 更新社区成员数 + UPDATE public.communities + SET member_count = member_count + 1, updated_at = NOW() + WHERE id = p_community_id; + + RETURN json_build_object('success', true, 'message', 'Joined community successfully'); +END; +$$; + + +ALTER FUNCTION "public"."join_community"("p_community_id" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."leave_community"("p_community_id" "text") RETURNS json + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +DECLARE + v_user_id UUID; + v_deleted_count INTEGER; +BEGIN + v_user_id := public.current_user_id(); + IF v_user_id IS NULL THEN + RETURN json_build_object('success', false, 'message', 'Not authenticated'); + END IF; + + DELETE FROM public.user_community_memberships + WHERE user_id = v_user_id + AND community_id = p_community_id + AND status IN ('member', 'admin'); + + GET DIAGNOSTICS v_deleted_count = ROW_COUNT; + + IF COALESCE(v_deleted_count, 0) = 0 THEN + RETURN json_build_object('success', false, 'message', 'Not a member of this community'); + END IF; + + UPDATE public.communities + SET member_count = GREATEST(0, COALESCE(member_count, 0) - v_deleted_count), + updated_at = NOW() + WHERE id = p_community_id; + + RETURN json_build_object('success', true, 'message', 'Left community successfully'); +END; +$$; + + +ALTER FUNCTION "public"."leave_community"("p_community_id" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."normalize_phone_number"("p_input" "text") RETURNS "text" + LANGUAGE "plpgsql" IMMUTABLE SECURITY DEFINER + SET "search_path" TO 'public' + AS $$ +DECLARE + digits TEXT; +BEGIN + IF p_input IS NULL THEN + RETURN NULL; + END IF; + + digits := regexp_replace(p_input, '[^0-9]', '', 'g'); + + IF digits IS NULL OR digits = '' THEN + RETURN NULL; + END IF; + + IF length(digits) = 10 THEN + RETURN '+1' || digits; + ELSIF length(digits) = 11 AND left(digits, 1) = '1' THEN + RETURN '+' || digits; + ELSE + RETURN '+' || digits; + END IF; +END; +$$; + + +ALTER FUNCTION "public"."normalize_phone_number"("p_input" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."protect_sensitive_profile_fields"() RETURNS "trigger" + LANGUAGE "plpgsql" + SET "search_path" TO 'public', 'pg_temp' + AS $$ +BEGIN + -- 仅限制普通通过 API 访问的用户,不限制 Service Role 或 Postgres Admin + IF auth.role() = 'authenticated' THEN + IF NEW.credits IS DISTINCT FROM OLD.credits OR + NEW.status IS DISTINCT FROM OLD.status OR + NEW.banned_until IS DISTINCT FROM OLD.banned_until THEN + RAISE EXCEPTION 'Permission denied: Cannot modify sensitive fields (credits/status/ban).'; + END IF; + END IF; + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."protect_sensitive_profile_fields"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."register_for_event"("p_event_id" "uuid") RETURNS json + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +DECLARE + v_user_id UUID; + v_event RECORD; + v_existing RECORD; +BEGIN + v_user_id := public.current_user_id(); + IF v_user_id IS NULL THEN + RETURN json_build_object('success', false, 'message', 'Not authenticated'); + END IF; + + SELECT * INTO v_event + FROM public.community_events + WHERE id = p_event_id + FOR UPDATE; + + IF NOT FOUND THEN + RETURN json_build_object('success', false, 'message', 'Event not found'); + END IF; + + IF v_event.status NOT IN ('upcoming', 'ongoing') THEN + RETURN json_build_object('success', false, 'message', 'Event is not open for registration'); + END IF; + + IF v_event.max_participants IS NOT NULL + AND COALESCE(v_event.participant_count, 0) >= v_event.max_participants THEN + RETURN json_build_object('success', false, 'message', 'Event is full'); + END IF; + + SELECT * INTO v_existing + FROM public.event_registrations + WHERE event_id = p_event_id + AND user_id = v_user_id; + + IF FOUND THEN + IF v_existing.status = 'registered' THEN + RETURN json_build_object('success', false, 'message', 'Already registered'); + ELSIF v_existing.status = 'cancelled' THEN + UPDATE public.event_registrations + SET status = 'registered', + registered_at = NOW() + WHERE id = v_existing.id; + ELSE + RETURN json_build_object('success', false, 'message', 'Cannot register for this event'); + END IF; + ELSE + INSERT INTO public.event_registrations (event_id, user_id, status, registered_at) + VALUES (p_event_id, v_user_id, 'registered', NOW()); + END IF; + + UPDATE public.community_events + SET participant_count = COALESCE(participant_count, 0) + 1, + updated_at = NOW() + WHERE id = p_event_id; + + RETURN json_build_object('success', true, 'message', 'Registration successful'); +END; +$$; + + +ALTER FUNCTION "public"."register_for_event"("p_event_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."remove_community_member"("p_community_id" "text", "p_user_id" "uuid", "p_reason" "text" DEFAULT NULL::"text") RETURNS json + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +DECLARE + v_admin_id UUID := auth.uid(); + v_username TEXT; +BEGIN + -- 检查权限 + IF NOT public.is_community_admin(p_community_id, v_admin_id) THEN + RETURN json_build_object('success', false, 'message', 'Permission denied'); + END IF; + + -- 不能移除管理员 + IF public.is_community_admin(p_community_id, p_user_id) THEN + RETURN json_build_object('success', false, 'message', 'Cannot remove admin'); + END IF; + + -- 获取用户名 + SELECT username INTO v_username FROM public.profiles WHERE id = p_user_id; + + -- 删除成员 + DELETE FROM public.user_community_memberships + WHERE community_id = p_community_id AND user_id = p_user_id; + + IF NOT FOUND THEN + RETURN json_build_object('success', false, 'message', 'User is not a member'); + END IF; + + -- 更新成员数 + UPDATE public.communities + SET member_count = GREATEST(0, member_count - 1), updated_at = NOW() + WHERE id = p_community_id; + + -- 记录日志 + INSERT INTO public.admin_action_logs (community_id, admin_id, action_type, target_user_id, details) + VALUES (p_community_id, v_admin_id, 'remove_member', p_user_id, + json_build_object('username', v_username, 'reason', p_reason)); + + RETURN json_build_object('success', true, 'message', 'Member removed'); +END; +$$; + + +ALTER FUNCTION "public"."remove_community_member"("p_community_id" "text", "p_user_id" "uuid", "p_reason" "text") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."remove_community_member"("p_community_id" "text", "p_user_id" "uuid", "p_reason" "text") IS '移除社区成员(仅管理员)'; + + + +CREATE OR REPLACE FUNCTION "public"."review_join_application"("p_application_id" "uuid", "p_approve" boolean, "p_rejection_reason" "text" DEFAULT NULL::"text") RETURNS json + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +DECLARE + v_admin_id UUID := auth.uid(); + v_community_id TEXT; + v_user_id UUID; + v_username TEXT; +BEGIN + -- 获取申请信息 + SELECT community_id, user_id INTO v_community_id, v_user_id + FROM public.community_join_applications + WHERE id = p_application_id AND status = 'pending'; + + IF NOT FOUND THEN + RETURN json_build_object('success', false, 'message', 'Application not found'); + END IF; + + -- 检查权限 + IF NOT public.is_community_admin(v_community_id, v_admin_id) THEN + RETURN json_build_object('success', false, 'message', 'Permission denied'); + END IF; + + -- 获取用户名(用于日志) + SELECT username INTO v_username FROM public.profiles WHERE id = v_user_id; + + IF p_approve THEN + -- 批准:更新申请状态并添加为成员 + UPDATE public.community_join_applications + SET status = 'approved', + reviewed_by = v_admin_id, + reviewed_at = NOW(), + updated_at = NOW() + WHERE id = p_application_id; + + INSERT INTO public.user_community_memberships (user_id, community_id, status) + VALUES (v_user_id, v_community_id, 'member') + ON CONFLICT (user_id, community_id) DO NOTHING; + + UPDATE public.communities + SET member_count = member_count + 1, updated_at = NOW() + WHERE id = v_community_id; + + -- 记录日志 + INSERT INTO public.admin_action_logs (community_id, admin_id, action_type, target_user_id, details) + VALUES (v_community_id, v_admin_id, 'approve_member', v_user_id, + json_build_object('username', v_username)); + + RETURN json_build_object('success', true, 'message', 'Application approved'); + ELSE + -- 拒绝:更新申请状态 + UPDATE public.community_join_applications + SET status = 'rejected', + reviewed_by = v_admin_id, + reviewed_at = NOW(), + rejection_reason = p_rejection_reason, + updated_at = NOW() + WHERE id = p_application_id; + + -- 记录日志 + INSERT INTO public.admin_action_logs (community_id, admin_id, action_type, target_user_id, details) + VALUES (v_community_id, v_admin_id, 'reject_member', v_user_id, + json_build_object('username', v_username, 'reason', p_rejection_reason)); + + RETURN json_build_object('success', true, 'message', 'Application rejected'); + END IF; +END; +$$; + + +ALTER FUNCTION "public"."review_join_application"("p_application_id" "uuid", "p_approve" boolean, "p_rejection_reason" "text") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."review_join_application"("p_application_id" "uuid", "p_approve" boolean, "p_rejection_reason" "text") IS '审批社区加入申请(仅管理员)'; + + + +CREATE OR REPLACE FUNCTION "public"."set_primary_achievement"("achievement_id" "uuid") RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +DECLARE + v_user_id UUID; +BEGIN + v_user_id := public.current_user_id(); + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + IF achievement_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 + FROM public.user_achievements ua + WHERE ua.user_id = v_user_id + AND ua.achievement_id = set_primary_achievement.achievement_id + ) THEN + RAISE EXCEPTION 'User does not own this achievement'; + END IF; + + UPDATE public.profiles + SET selected_achievement_id = set_primary_achievement.achievement_id + WHERE id = v_user_id; +END; +$$; + + +ALTER FUNCTION "public"."set_primary_achievement"("achievement_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."submit_daily_challenge"("p_score" integer, "p_correct_count" integer, "p_time_seconds" numeric, "p_max_combo" integer) RETURNS json + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +DECLARE + v_user_id UUID; + v_today DATE; + v_result_id UUID; + v_points INT; +BEGIN + v_user_id := auth.uid(); + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + v_today := (timezone('utc', now()))::date; + + -- Check challenge exists for today + IF NOT EXISTS (SELECT 1 FROM public.daily_challenges WHERE challenge_date = v_today) THEN + RAISE EXCEPTION 'No daily challenge for today'; + END IF; + + -- Check not already played + IF EXISTS (SELECT 1 FROM public.daily_challenge_results WHERE user_id = v_user_id AND challenge_date = v_today) THEN + RAISE EXCEPTION 'Already completed today''s challenge'; + END IF; + + -- Insert result + INSERT INTO public.daily_challenge_results (user_id, challenge_date, score, correct_count, time_seconds, max_combo) + VALUES (v_user_id, v_today, p_score, p_correct_count, p_time_seconds, p_max_combo) + RETURNING id INTO v_result_id; + + -- Award points (same as score) + v_points := p_score; + IF v_points > 0 THEN + UPDATE public.profiles + SET credits = credits + v_points + WHERE id = v_user_id; + END IF; + + RETURN json_build_object( + 'result_id', v_result_id, + 'points_awarded', v_points + ); +END; +$$; + + +ALTER FUNCTION "public"."submit_daily_challenge"("p_score" integer, "p_correct_count" integer, "p_time_seconds" numeric, "p_max_combo" integer) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."submit_duel_answer"("p_challenge_id" "uuid", "p_question_index" integer, "p_selected_category" "text", "p_answer_time_ms" integer) RETURNS json + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +DECLARE + v_user_id UUID; + v_challenge RECORD; + v_question_id UUID; + v_correct_category TEXT; + v_is_correct BOOLEAN; +BEGIN + v_user_id := auth.uid(); + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + -- Get challenge + SELECT * INTO v_challenge + FROM public.arena_challenges + WHERE id = p_challenge_id; + + IF v_challenge IS NULL THEN + RAISE EXCEPTION 'Challenge not found'; + END IF; + + IF v_challenge.challenger_id != v_user_id AND v_challenge.opponent_id != v_user_id THEN + RAISE EXCEPTION 'Not your challenge'; + END IF; + + IF v_challenge.status NOT IN ('accepted', 'in_progress') THEN + RAISE EXCEPTION 'Challenge is not active (status: %)', v_challenge.status; + END IF; + + -- Update to in_progress if needed + IF v_challenge.status = 'accepted' THEN + UPDATE public.arena_challenges + SET status = 'in_progress', started_at = timezone('utc', now()) + WHERE id = p_challenge_id AND status = 'accepted'; + END IF; + + -- Get the question at this index + v_question_id := v_challenge.question_ids[p_question_index + 1]; -- 1-indexed array + + IF v_question_id IS NULL THEN + RAISE EXCEPTION 'Invalid question index: %', p_question_index; + END IF; + + -- Get correct answer + SELECT correct_category INTO v_correct_category + FROM public.quiz_questions + WHERE id = v_question_id; + + v_is_correct := (p_selected_category = v_correct_category); + + -- Insert answer (upsert to handle retries) + INSERT INTO public.arena_challenge_answers ( + challenge_id, user_id, question_index, selected_category, is_correct, answer_time_ms + ) VALUES ( + p_challenge_id, v_user_id, p_question_index, p_selected_category, v_is_correct, p_answer_time_ms + ) + ON CONFLICT (challenge_id, user_id, question_index) DO NOTHING; + + RETURN json_build_object( + 'is_correct', v_is_correct, + 'correct_category', v_correct_category, + 'question_index', p_question_index + ); +END; +$$; + + +ALTER FUNCTION "public"."submit_duel_answer"("p_challenge_id" "uuid", "p_question_index" integer, "p_selected_category" "text", "p_answer_time_ms" integer) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."submit_streak_record"("p_streak_count" integer) RETURNS "uuid" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +DECLARE + v_user_id UUID; + v_record_id UUID; + v_points INT; +BEGIN + v_user_id := auth.uid(); + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + -- Insert streak record + INSERT INTO public.streak_records (user_id, streak_count) + VALUES (v_user_id, p_streak_count) + RETURNING id INTO v_record_id; + + -- Award points: 5 per correct answer + v_points := p_streak_count * 5; + IF v_points > 0 THEN + UPDATE public.profiles + SET credits = credits + v_points + WHERE id = v_user_id; + END IF; + + RETURN v_record_id; +END; +$$; + + +ALTER FUNCTION "public"."submit_streak_record"("p_streak_count" integer) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."update_community_info"("p_community_id" "text", "p_description" "text" DEFAULT NULL::"text", "p_welcome_message" "text" DEFAULT NULL::"text", "p_rules" "text" DEFAULT NULL::"text", "p_requires_approval" boolean DEFAULT NULL::boolean) RETURNS json + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +DECLARE + v_admin_id UUID := auth.uid(); +BEGIN + -- 检查权限 + IF NOT public.is_community_admin(p_community_id, v_admin_id) THEN + RETURN json_build_object('success', false, 'message', 'Permission denied'); + END IF; + + -- 更新社区信息 + UPDATE public.communities + SET + description = COALESCE(p_description, description), + welcome_message = COALESCE(p_welcome_message, welcome_message), + rules = COALESCE(p_rules, rules), + requires_approval = COALESCE(p_requires_approval, requires_approval), + updated_at = NOW() + WHERE id = p_community_id; + + -- 记录日志 + INSERT INTO public.admin_action_logs (community_id, admin_id, action_type, details) + VALUES (p_community_id, v_admin_id, 'edit_community', + json_build_object( + 'description', p_description, + 'welcome_message', p_welcome_message, + 'requires_approval', p_requires_approval + )); + + RETURN json_build_object('success', true, 'message', 'Community updated'); +END; +$$; + + +ALTER FUNCTION "public"."update_community_info"("p_community_id" "text", "p_description" "text", "p_welcome_message" "text", "p_rules" "text", "p_requires_approval" boolean) OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."update_community_info"("p_community_id" "text", "p_description" "text", "p_welcome_message" "text", "p_rules" "text", "p_requires_approval" boolean) IS '更新社区信息(仅管理员)'; + + + +CREATE OR REPLACE FUNCTION "public"."update_user_location"("p_city" "text", "p_state" "text", "p_latitude" double precision, "p_longitude" double precision) RETURNS json + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +DECLARE + v_user_id UUID; +BEGIN + v_user_id := public.current_user_id(); + IF v_user_id IS NULL THEN + RETURN json_build_object('success', false, 'message', 'Not authenticated'); + END IF; + + UPDATE public.profiles + SET location_city = p_city, + location_state = p_state, + location_latitude = p_latitude, + location_longitude = p_longitude + WHERE id = v_user_id; + + RETURN json_build_object('success', true, 'message', 'Location updated'); +END; +$$; + + +ALTER FUNCTION "public"."update_user_location"("p_city" "text", "p_state" "text", "p_latitude" double precision, "p_longitude" double precision) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."update_user_location"("p_city" "text", "p_state" "text", "p_latitude" numeric, "p_longitude" numeric) RETURNS json + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$ +DECLARE + v_user_id UUID := auth.uid(); +BEGIN + IF v_user_id IS NULL THEN + RETURN json_build_object('success', false, 'message', 'Not authenticated'); + END IF; + + UPDATE public.profiles + SET + location_city = p_city, + location_state = p_state, + location_latitude = p_latitude, + location_longitude = p_longitude + WHERE id = v_user_id; + + RETURN json_build_object('success', true, 'message', 'Location updated'); +END; +$$; + + +ALTER FUNCTION "public"."update_user_location"("p_city" "text", "p_state" "text", "p_latitude" numeric, "p_longitude" numeric) OWNER TO "postgres"; + + +CREATE TABLE IF NOT EXISTS "public"."achievements" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "community_id" "text", + "name" "text" NOT NULL, + "description" "text", + "icon_name" "text" NOT NULL, + "created_by" "uuid", + "created_at" timestamp with time zone DEFAULT "now"(), + "points" integer DEFAULT 0, + "is_hidden" boolean DEFAULT false, + "rarity" "text" DEFAULT 'common'::"text", + "trigger_key" "text", + CONSTRAINT "achievements_rarity_check" CHECK (("rarity" = ANY (ARRAY['common'::"text", 'rare'::"text", 'epic'::"text", 'legendary'::"text"]))) +); + + +ALTER TABLE "public"."achievements" OWNER TO "postgres"; + + +CREATE TABLE IF NOT EXISTS "public"."admin_action_logs" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "community_id" "text" NOT NULL, + "admin_id" "uuid" NOT NULL, + "action_type" "text" NOT NULL, + "target_user_id" "uuid", + "target_event_id" "uuid", + "details" "jsonb", + "created_at" timestamp with time zone DEFAULT "timezone"('utc'::"text", "now"()), + CONSTRAINT "admin_action_logs_action_type_check" CHECK (("action_type" = ANY (ARRAY['approve_member'::"text", 'reject_member'::"text", 'remove_member'::"text", 'grant_credits'::"text", 'edit_community'::"text", 'edit_event'::"text", 'delete_event'::"text", 'pin_post'::"text", 'delete_post'::"text"]))) +); + + +ALTER TABLE "public"."admin_action_logs" OWNER TO "postgres"; + + +COMMENT ON TABLE "public"."admin_action_logs" IS '管理员操作日志,用于审计'; + + + +CREATE TABLE IF NOT EXISTS "public"."arena_challenge_answers" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "challenge_id" "uuid" NOT NULL, + "user_id" "uuid" NOT NULL, + "question_index" integer NOT NULL, + "selected_category" "text" NOT NULL, + "is_correct" boolean NOT NULL, + "answer_time_ms" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT "timezone"('utc'::"text", "now"()) +); + + +ALTER TABLE "public"."arena_challenge_answers" OWNER TO "postgres"; + + +CREATE TABLE IF NOT EXISTS "public"."arena_challenges" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "challenger_id" "uuid" NOT NULL, + "opponent_id" "uuid" NOT NULL, + "status" "text" DEFAULT 'pending'::"text" NOT NULL, + "question_ids" "uuid"[] NOT NULL, + "channel_name" "text", + "challenger_score" integer DEFAULT 0, + "opponent_score" integer DEFAULT 0, + "winner_id" "uuid", + "created_at" timestamp with time zone DEFAULT "timezone"('utc'::"text", "now"()), + "expires_at" timestamp with time zone DEFAULT ("timezone"('utc'::"text", "now"()) + '00:01:00'::interval), + "started_at" timestamp with time zone, + "completed_at" timestamp with time zone, + CONSTRAINT "arena_challenges_status_check" CHECK (("status" = ANY (ARRAY['pending'::"text", 'accepted'::"text", 'in_progress'::"text", 'completed'::"text", 'expired'::"text", 'declined'::"text", 'cancelled'::"text"]))) +); + + +ALTER TABLE "public"."arena_challenges" OWNER TO "postgres"; + + +CREATE TABLE IF NOT EXISTS "public"."communities" ( + "id" "text" NOT NULL, + "name" "text" NOT NULL, + "city" "text" NOT NULL, + "state" "text", + "country" "text" DEFAULT 'US'::"text", + "description" "text", + "logo_url" "text", + "latitude" numeric(10,8), + "longitude" numeric(11,8), + "member_count" integer DEFAULT 0, + "is_active" boolean DEFAULT true, + "created_at" timestamp with time zone DEFAULT "timezone"('utc'::"text", "now"()), + "updated_at" timestamp with time zone DEFAULT "timezone"('utc'::"text", "now"()), + "created_by" "uuid", + "requires_approval" boolean DEFAULT false, + "welcome_message" "text", + "rules" "text", + "tags" "text"[], + "is_private" boolean DEFAULT false +); + + +ALTER TABLE "public"."communities" OWNER TO "postgres"; + + +COMMENT ON COLUMN "public"."communities"."requires_approval" IS '是否需要管理员审批才能加入'; + + + +COMMENT ON COLUMN "public"."communities"."is_private" IS '私密社区不会出现在公开列表中'; + + + +CREATE TABLE IF NOT EXISTS "public"."community_events" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "community_id" "text", + "title" "text" NOT NULL, + "description" "text", + "organizer" "text" NOT NULL, + "category" "text" NOT NULL, + "event_date" timestamp with time zone NOT NULL, + "location" "text" NOT NULL, + "latitude" numeric(10,8) NOT NULL, + "longitude" numeric(11,8) NOT NULL, + "image_url" "text", + "icon_name" "text" DEFAULT 'calendar'::"text", + "max_participants" integer DEFAULT 100, + "participant_count" integer DEFAULT 0, + "credits_reward" integer DEFAULT 10, + "status" "text" DEFAULT 'upcoming'::"text", + "created_at" timestamp with time zone DEFAULT "timezone"('utc'::"text", "now"()), + "updated_at" timestamp with time zone DEFAULT "timezone"('utc'::"text", "now"()), + "created_by" "uuid", + "is_personal" boolean DEFAULT false, + CONSTRAINT "community_events_category_check" CHECK (("category" = ANY (ARRAY['cleanup'::"text", 'workshop'::"text", 'competition'::"text", 'education'::"text", 'other'::"text"]))), + CONSTRAINT "community_events_status_check" CHECK (("status" = ANY (ARRAY['upcoming'::"text", 'ongoing'::"text", 'completed'::"text", 'cancelled'::"text"]))) +); + + +ALTER TABLE "public"."community_events" OWNER TO "postgres"; + + +CREATE TABLE IF NOT EXISTS "public"."community_join_applications" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "community_id" "text" NOT NULL, + "user_id" "uuid" NOT NULL, + "status" "text" DEFAULT 'pending'::"text", + "message" "text", + "rejection_reason" "text", + "reviewed_by" "uuid", + "reviewed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT "timezone"('utc'::"text", "now"()), + "updated_at" timestamp with time zone DEFAULT "timezone"('utc'::"text", "now"()), + CONSTRAINT "community_join_applications_status_check" CHECK (("status" = ANY (ARRAY['pending'::"text", 'approved'::"text", 'rejected'::"text"]))) +); + + +ALTER TABLE "public"."community_join_applications" OWNER TO "postgres"; + + +COMMENT ON TABLE "public"."community_join_applications" IS '社区加入申请表'; + + + +CREATE TABLE IF NOT EXISTS "public"."credit_grants" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "user_id" "uuid" NOT NULL, + "granted_by" "uuid" NOT NULL, + "community_id" "text", + "event_id" "uuid", + "amount" integer NOT NULL, + "reason" "text" NOT NULL, + "created_at" timestamp with time zone DEFAULT "timezone"('utc'::"text", "now"()), + CONSTRAINT "credit_grants_amount_check" CHECK (("amount" > 0)) +); + + +ALTER TABLE "public"."credit_grants" OWNER TO "postgres"; + + +COMMENT ON TABLE "public"."credit_grants" IS '管理员手动发放积分的记录'; + + + +CREATE TABLE IF NOT EXISTS "public"."daily_challenge_results" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "user_id" "uuid" NOT NULL, + "challenge_date" "date" NOT NULL, + "score" integer DEFAULT 0 NOT NULL, + "correct_count" integer DEFAULT 0 NOT NULL, + "time_seconds" numeric DEFAULT 0 NOT NULL, + "max_combo" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT "timezone"('utc'::"text", "now"()) +); + + +ALTER TABLE "public"."daily_challenge_results" OWNER TO "postgres"; + + +CREATE TABLE IF NOT EXISTS "public"."daily_challenges" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "challenge_date" "date" NOT NULL, + "question_ids" "uuid"[] NOT NULL, + "created_at" timestamp with time zone DEFAULT "timezone"('utc'::"text", "now"()) +); + + +ALTER TABLE "public"."daily_challenges" OWNER TO "postgres"; + + +CREATE TABLE IF NOT EXISTS "public"."event_registrations" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "event_id" "uuid" NOT NULL, + "user_id" "uuid" NOT NULL, + "status" "text" DEFAULT 'registered'::"text", + "registered_at" timestamp with time zone DEFAULT "timezone"('utc'::"text", "now"()), + "attended_at" timestamp with time zone, + "credits_earned" integer DEFAULT 0, + CONSTRAINT "event_registrations_status_check" CHECK (("status" = ANY (ARRAY['registered'::"text", 'attended'::"text", 'cancelled'::"text", 'no_show'::"text"]))) +); + + +ALTER TABLE "public"."event_registrations" OWNER TO "postgres"; + + +CREATE TABLE IF NOT EXISTS "public"."feedback_logs" ( + "id" bigint NOT NULL, + "created_at" timestamp with time zone DEFAULT "timezone"('utc'::"text", "now"()) NOT NULL, + "user_id" "uuid", + "predicted_label" "text", + "predicted_category" "text", + "user_correction" "text", + "user_comment" "text", + "image_path" "text" +); + + +ALTER TABLE "public"."feedback_logs" OWNER TO "postgres"; + + +ALTER TABLE "public"."feedback_logs" ALTER COLUMN "id" ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME "public"."feedback_logs_id_seq" + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + +CREATE TABLE IF NOT EXISTS "public"."profiles" ( + "id" "uuid" NOT NULL, + "phone" "text", + "email" "text", + "credits" integer DEFAULT 0, + "username" "text", + "status" "text" DEFAULT 'active'::"text", + "banned_until" timestamp with time zone, + "location_city" "text", + "location_state" "text", + "location_latitude" numeric(10,8), + "location_longitude" numeric(11,8), + "selected_achievement_id" "uuid", + "total_scans" integer DEFAULT 0 +); + + +ALTER TABLE "public"."profiles" OWNER TO "postgres"; + + +CREATE TABLE IF NOT EXISTS "public"."streak_records" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "user_id" "uuid" NOT NULL, + "streak_count" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT "timezone"('utc'::"text", "now"()) +); + + +ALTER TABLE "public"."streak_records" OWNER TO "postgres"; + + +CREATE TABLE IF NOT EXISTS "public"."user_achievements" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "user_id" "uuid", + "achievement_id" "uuid", + "community_id" "text", + "granted_at" timestamp with time zone DEFAULT "now"(), + "granted_by" "uuid" +); + + +ALTER TABLE "public"."user_achievements" OWNER TO "postgres"; + + +CREATE TABLE IF NOT EXISTS "public"."user_community_memberships" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "user_id" "uuid" NOT NULL, + "community_id" "text" NOT NULL, + "status" "text" DEFAULT 'member'::"text", + "joined_at" timestamp with time zone DEFAULT "timezone"('utc'::"text", "now"()), + CONSTRAINT "user_community_memberships_status_check" CHECK (("status" = ANY (ARRAY['pending'::"text", 'member'::"text", 'admin'::"text", 'banned'::"text"]))) +); + + +ALTER TABLE "public"."user_community_memberships" OWNER TO "postgres"; + + +ALTER TABLE ONLY "public"."achievements" + ADD CONSTRAINT "achievements_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."achievements" + ADD CONSTRAINT "achievements_trigger_key_key" UNIQUE ("trigger_key"); + + + +ALTER TABLE ONLY "public"."admin_action_logs" + ADD CONSTRAINT "admin_action_logs_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."arena_challenge_answers" + ADD CONSTRAINT "arena_challenge_answers_challenge_id_user_id_question_index_key" UNIQUE ("challenge_id", "user_id", "question_index"); + + + +ALTER TABLE ONLY "public"."arena_challenge_answers" + ADD CONSTRAINT "arena_challenge_answers_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."arena_challenges" + ADD CONSTRAINT "arena_challenges_channel_name_key" UNIQUE ("channel_name"); + + + +ALTER TABLE ONLY "public"."arena_challenges" + ADD CONSTRAINT "arena_challenges_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."communities" + ADD CONSTRAINT "communities_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."community_events" + ADD CONSTRAINT "community_events_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."community_join_applications" + ADD CONSTRAINT "community_join_applications_community_id_user_id_key" UNIQUE ("community_id", "user_id"); + + + +ALTER TABLE ONLY "public"."community_join_applications" + ADD CONSTRAINT "community_join_applications_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."credit_grants" + ADD CONSTRAINT "credit_grants_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."daily_challenge_results" + ADD CONSTRAINT "daily_challenge_results_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."daily_challenge_results" + ADD CONSTRAINT "daily_challenge_results_user_id_challenge_date_key" UNIQUE ("user_id", "challenge_date"); + + + +ALTER TABLE ONLY "public"."daily_challenges" + ADD CONSTRAINT "daily_challenges_challenge_date_key" UNIQUE ("challenge_date"); + + + +ALTER TABLE ONLY "public"."daily_challenges" + ADD CONSTRAINT "daily_challenges_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."event_registrations" + ADD CONSTRAINT "event_registrations_event_id_user_id_key" UNIQUE ("event_id", "user_id"); + + + +ALTER TABLE ONLY "public"."event_registrations" + ADD CONSTRAINT "event_registrations_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."feedback_logs" + ADD CONSTRAINT "feedback_logs_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."profiles" + ADD CONSTRAINT "profiles_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."quiz_questions" + ADD CONSTRAINT "quiz_questions_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."streak_records" + ADD CONSTRAINT "streak_records_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."user_achievements" + ADD CONSTRAINT "user_achievements_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."user_achievements" + ADD CONSTRAINT "user_achievements_user_id_achievement_id_key" UNIQUE ("user_id", "achievement_id"); + + + +ALTER TABLE ONLY "public"."user_community_memberships" + ADD CONSTRAINT "user_community_memberships_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."user_community_memberships" + ADD CONSTRAINT "user_community_memberships_user_id_community_id_key" UNIQUE ("user_id", "community_id"); + + + +CREATE INDEX "idx_admin_logs_admin" ON "public"."admin_action_logs" USING "btree" ("admin_id"); + + + +CREATE INDEX "idx_admin_logs_community" ON "public"."admin_action_logs" USING "btree" ("community_id", "created_at" DESC); + + + +CREATE INDEX "idx_applications_community" ON "public"."community_join_applications" USING "btree" ("community_id", "status"); + + + +CREATE INDEX "idx_applications_status" ON "public"."community_join_applications" USING "btree" ("status"); + + + +CREATE INDEX "idx_applications_user" ON "public"."community_join_applications" USING "btree" ("user_id"); + + + +CREATE INDEX "idx_challenge_answers_challenge" ON "public"."arena_challenge_answers" USING "btree" ("challenge_id", "user_id"); + + + +CREATE INDEX "idx_challenges_challenger" ON "public"."arena_challenges" USING "btree" ("challenger_id", "status"); + + + +CREATE INDEX "idx_challenges_channel" ON "public"."arena_challenges" USING "btree" ("channel_name"); + + + +CREATE INDEX "idx_challenges_opponent" ON "public"."arena_challenges" USING "btree" ("opponent_id", "status"); + + + +CREATE INDEX "idx_challenges_status" ON "public"."arena_challenges" USING "btree" ("status"); + + + +CREATE UNIQUE INDEX "idx_challenges_unique_pending" ON "public"."arena_challenges" USING "btree" ("challenger_id", "opponent_id") WHERE ("status" = 'pending'::"text"); + + + +CREATE INDEX "idx_communities_city" ON "public"."communities" USING "btree" ("city"); + + + +CREATE INDEX "idx_communities_created_by" ON "public"."communities" USING "btree" ("created_by"); + + + +CREATE INDEX "idx_communities_is_active" ON "public"."communities" USING "btree" ("is_active"); + + + +CREATE INDEX "idx_communities_location" ON "public"."communities" USING "btree" ("latitude", "longitude"); + + + +CREATE INDEX "idx_communities_state" ON "public"."communities" USING "btree" ("state"); + + + +CREATE INDEX "idx_credit_grants_community" ON "public"."credit_grants" USING "btree" ("community_id"); + + + +CREATE INDEX "idx_credit_grants_event" ON "public"."credit_grants" USING "btree" ("event_id"); + + + +CREATE INDEX "idx_credit_grants_user" ON "public"."credit_grants" USING "btree" ("user_id"); + + + +CREATE INDEX "idx_daily_challenges_date" ON "public"."daily_challenges" USING "btree" ("challenge_date" DESC); + + + +CREATE INDEX "idx_daily_results_date" ON "public"."daily_challenge_results" USING "btree" ("challenge_date", "score" DESC); + + + +CREATE INDEX "idx_daily_results_user" ON "public"."daily_challenge_results" USING "btree" ("user_id"); + + + +CREATE INDEX "idx_events_category" ON "public"."community_events" USING "btree" ("category"); + + + +CREATE INDEX "idx_events_community" ON "public"."community_events" USING "btree" ("community_id"); + + + +CREATE INDEX "idx_events_created_by" ON "public"."community_events" USING "btree" ("created_by"); + + + +CREATE INDEX "idx_events_date" ON "public"."community_events" USING "btree" ("event_date"); + + + +CREATE INDEX "idx_events_is_personal" ON "public"."community_events" USING "btree" ("is_personal"); + + + +CREATE INDEX "idx_events_location" ON "public"."community_events" USING "btree" ("latitude", "longitude"); + + + +CREATE INDEX "idx_events_status" ON "public"."community_events" USING "btree" ("status"); + + + +CREATE INDEX "idx_memberships_community" ON "public"."user_community_memberships" USING "btree" ("community_id"); + + + +CREATE INDEX "idx_memberships_status" ON "public"."user_community_memberships" USING "btree" ("status"); + + + +CREATE INDEX "idx_memberships_user" ON "public"."user_community_memberships" USING "btree" ("user_id"); + + + +CREATE INDEX "idx_profiles_coordinates" ON "public"."profiles" USING "btree" ("location_latitude", "location_longitude"); + + + +CREATE INDEX "idx_profiles_location" ON "public"."profiles" USING "btree" ("location_city", "location_state"); + + + +CREATE INDEX "idx_registrations_event" ON "public"."event_registrations" USING "btree" ("event_id"); + + + +CREATE INDEX "idx_registrations_status" ON "public"."event_registrations" USING "btree" ("status"); + + + +CREATE INDEX "idx_registrations_user" ON "public"."event_registrations" USING "btree" ("user_id"); + + + +CREATE INDEX "idx_streak_records_streak_count" ON "public"."streak_records" USING "btree" ("streak_count" DESC); + + + +CREATE INDEX "idx_streak_records_user_id" ON "public"."streak_records" USING "btree" ("user_id"); + + + +CREATE OR REPLACE TRIGGER "ensure_profile_security" BEFORE UPDATE ON "public"."profiles" FOR EACH ROW EXECUTE FUNCTION "public"."protect_sensitive_profile_fields"(); + + + +CREATE OR REPLACE TRIGGER "on_community_member_change" AFTER INSERT OR DELETE OR UPDATE ON "public"."user_community_memberships" FOR EACH ROW EXECUTE FUNCTION "public"."handle_community_member_count"(); + + + +CREATE OR REPLACE TRIGGER "on_event_registration_change" AFTER INSERT OR DELETE OR UPDATE ON "public"."event_registrations" FOR EACH ROW EXECUTE FUNCTION "public"."handle_event_participant_count"(); + + + +ALTER TABLE ONLY "public"."achievements" + ADD CONSTRAINT "achievements_community_id_fkey" FOREIGN KEY ("community_id") REFERENCES "public"."communities"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."achievements" + ADD CONSTRAINT "achievements_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "auth"."users"("id"); + + + +ALTER TABLE ONLY "public"."admin_action_logs" + ADD CONSTRAINT "admin_action_logs_admin_id_fkey" FOREIGN KEY ("admin_id") REFERENCES "auth"."users"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."admin_action_logs" + ADD CONSTRAINT "admin_action_logs_community_id_fkey" FOREIGN KEY ("community_id") REFERENCES "public"."communities"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."admin_action_logs" + ADD CONSTRAINT "admin_action_logs_target_user_id_fkey" FOREIGN KEY ("target_user_id") REFERENCES "auth"."users"("id"); + + + +ALTER TABLE ONLY "public"."arena_challenge_answers" + ADD CONSTRAINT "arena_challenge_answers_challenge_id_fkey" FOREIGN KEY ("challenge_id") REFERENCES "public"."arena_challenges"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."arena_challenge_answers" + ADD CONSTRAINT "arena_challenge_answers_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."profiles"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."arena_challenges" + ADD CONSTRAINT "arena_challenges_challenger_id_fkey" FOREIGN KEY ("challenger_id") REFERENCES "public"."profiles"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."arena_challenges" + ADD CONSTRAINT "arena_challenges_opponent_id_fkey" FOREIGN KEY ("opponent_id") REFERENCES "public"."profiles"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."arena_challenges" + ADD CONSTRAINT "arena_challenges_winner_id_fkey" FOREIGN KEY ("winner_id") REFERENCES "public"."profiles"("id"); + + + +ALTER TABLE ONLY "public"."communities" + ADD CONSTRAINT "communities_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "auth"."users"("id") ON DELETE SET NULL; + + + +ALTER TABLE ONLY "public"."community_events" + ADD CONSTRAINT "community_events_community_id_fkey" FOREIGN KEY ("community_id") REFERENCES "public"."communities"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."community_events" + ADD CONSTRAINT "community_events_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "auth"."users"("id") ON DELETE SET NULL; + + + +ALTER TABLE ONLY "public"."community_join_applications" + ADD CONSTRAINT "community_join_applications_community_id_fkey" FOREIGN KEY ("community_id") REFERENCES "public"."communities"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."community_join_applications" + ADD CONSTRAINT "community_join_applications_reviewed_by_fkey" FOREIGN KEY ("reviewed_by") REFERENCES "auth"."users"("id"); + + + +ALTER TABLE ONLY "public"."community_join_applications" + ADD CONSTRAINT "community_join_applications_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."credit_grants" + ADD CONSTRAINT "credit_grants_community_id_fkey" FOREIGN KEY ("community_id") REFERENCES "public"."communities"("id") ON DELETE SET NULL; + + + +ALTER TABLE ONLY "public"."credit_grants" + ADD CONSTRAINT "credit_grants_granted_by_fkey" FOREIGN KEY ("granted_by") REFERENCES "auth"."users"("id"); + + + +ALTER TABLE ONLY "public"."credit_grants" + ADD CONSTRAINT "credit_grants_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."daily_challenge_results" + ADD CONSTRAINT "daily_challenge_results_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."profiles"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."event_registrations" + ADD CONSTRAINT "event_registrations_event_id_fkey" FOREIGN KEY ("event_id") REFERENCES "public"."community_events"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."event_registrations" + ADD CONSTRAINT "event_registrations_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."feedback_logs" + ADD CONSTRAINT "feedback_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."profiles" + ADD CONSTRAINT "profiles_id_fkey" FOREIGN KEY ("id") REFERENCES "auth"."users"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."profiles" + ADD CONSTRAINT "profiles_selected_achievement_id_fkey" FOREIGN KEY ("selected_achievement_id") REFERENCES "public"."achievements"("id") ON DELETE SET NULL; + + + +ALTER TABLE ONLY "public"."streak_records" + ADD CONSTRAINT "streak_records_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."profiles"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."user_achievements" + ADD CONSTRAINT "user_achievements_achievement_id_fkey" FOREIGN KEY ("achievement_id") REFERENCES "public"."achievements"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."user_achievements" + ADD CONSTRAINT "user_achievements_community_id_fkey" FOREIGN KEY ("community_id") REFERENCES "public"."communities"("id") ON DELETE SET NULL; + + + +ALTER TABLE ONLY "public"."user_achievements" + ADD CONSTRAINT "user_achievements_granted_by_fkey" FOREIGN KEY ("granted_by") REFERENCES "auth"."users"("id"); + + + +ALTER TABLE ONLY "public"."user_achievements" + ADD CONSTRAINT "user_achievements_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."user_community_memberships" + ADD CONSTRAINT "user_community_memberships_community_id_fkey" FOREIGN KEY ("community_id") REFERENCES "public"."communities"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."user_community_memberships" + ADD CONSTRAINT "user_community_memberships_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE CASCADE; + + + +CREATE POLICY "Achievements insert (admins)" ON "public"."achievements" FOR INSERT TO "authenticated" WITH CHECK ((("community_id" IS NOT NULL) AND (EXISTS ( SELECT 1 + FROM "public"."user_community_memberships" "m" + WHERE (("m"."community_id" = "achievements"."community_id") AND ("m"."user_id" = "public"."current_user_id"()) AND ("m"."status" = 'admin'::"text")))))); + + + +CREATE POLICY "Achievements readable (auth)" ON "public"."achievements" FOR SELECT TO "authenticated" USING (true); + + + +CREATE POLICY "Achievements update (admins)" ON "public"."achievements" FOR UPDATE TO "authenticated" USING ((("community_id" IS NOT NULL) AND (EXISTS ( SELECT 1 + FROM "public"."user_community_memberships" "m" + WHERE (("m"."community_id" = "achievements"."community_id") AND ("m"."user_id" = "public"."current_user_id"()) AND ("m"."status" = 'admin'::"text")))))) WITH CHECK ((("community_id" IS NOT NULL) AND (EXISTS ( SELECT 1 + FROM "public"."user_community_memberships" "m" + WHERE (("m"."community_id" = "achievements"."community_id") AND ("m"."user_id" = "public"."current_user_id"()) AND ("m"."status" = 'admin'::"text")))))); + + + +CREATE POLICY "Admins can view action logs" ON "public"."admin_action_logs" FOR SELECT TO "authenticated" USING ("public"."is_community_admin"("community_id", "public"."current_user_id"())); + + + +CREATE POLICY "Communities delete own" ON "public"."communities" FOR DELETE TO "authenticated" USING ((("created_by" = "public"."current_user_id"()) OR (EXISTS ( SELECT 1 + FROM "public"."user_community_memberships" "m" + WHERE (("m"."community_id" = "communities"."id") AND ("m"."user_id" = "public"."current_user_id"()) AND ("m"."status" = 'admin'::"text")))))); + + + +CREATE POLICY "Communities insert (authenticated)" ON "public"."communities" FOR INSERT TO "authenticated" WITH CHECK ((("created_by" = "public"."current_user_id"()) AND ("public"."current_user_id"() IS NOT NULL))); + + + +CREATE POLICY "Communities readable (authenticated)" ON "public"."communities" FOR SELECT TO "authenticated" USING ((("public"."current_user_id"() IS NOT NULL) AND ((COALESCE("is_private", false) = false) OR ("created_by" = "public"."current_user_id"()) OR (EXISTS ( SELECT 1 + FROM "public"."user_community_memberships" "m" + WHERE (("m"."community_id" = "communities"."id") AND ("m"."user_id" = "public"."current_user_id"()) AND ("m"."status" = ANY (ARRAY['member'::"text", 'admin'::"text"])))))))); + + + +CREATE POLICY "Communities update own" ON "public"."communities" FOR UPDATE TO "authenticated" USING ((("created_by" = "public"."current_user_id"()) OR (EXISTS ( SELECT 1 + FROM "public"."user_community_memberships" "m" + WHERE (("m"."community_id" = "communities"."id") AND ("m"."user_id" = "public"."current_user_id"()) AND ("m"."status" = 'admin'::"text")))))) WITH CHECK ((("created_by" = "public"."current_user_id"()) OR (EXISTS ( SELECT 1 + FROM "public"."user_community_memberships" "m" + WHERE (("m"."community_id" = "communities"."id") AND ("m"."user_id" = "public"."current_user_id"()) AND ("m"."status" = 'admin'::"text")))))); + + + +CREATE POLICY "Daily challenges are readable by authenticated users" ON "public"."daily_challenges" FOR SELECT TO "authenticated" USING (true); + + + +CREATE POLICY "Daily results are readable by authenticated users" ON "public"."daily_challenge_results" FOR SELECT TO "authenticated" USING (true); + + + +CREATE POLICY "Enable read access for all users" ON "public"."feedback_logs" FOR SELECT USING (true); + + + +CREATE POLICY "Events are viewable by everyone" ON "public"."community_events" FOR SELECT USING (true); + + + +CREATE POLICY "Events delete (owner-or-admin)" ON "public"."community_events" FOR DELETE TO "authenticated" USING ((("created_by" = "public"."current_user_id"()) OR (("community_id" IS NOT NULL) AND (EXISTS ( SELECT 1 + FROM "public"."user_community_memberships" "m" + WHERE (("m"."community_id" = "m"."community_id") AND ("m"."user_id" = "public"."current_user_id"()) AND ("m"."status" = 'admin'::"text"))))))); + + + +CREATE POLICY "Events insert (authenticated)" ON "public"."community_events" FOR INSERT TO "authenticated" WITH CHECK ((("created_by" = "public"."current_user_id"()) AND (("is_personal" IS TRUE) OR ("community_id" IS NULL) OR (EXISTS ( SELECT 1 + FROM "public"."user_community_memberships" "m" + WHERE (("m"."community_id" = "m"."community_id") AND ("m"."user_id" = "public"."current_user_id"()) AND ("m"."status" = ANY (ARRAY['member'::"text", 'admin'::"text"])))))))); + + + +CREATE POLICY "Events readable (members)" ON "public"."community_events" FOR SELECT TO "authenticated" USING ((("public"."current_user_id"() IS NOT NULL) AND ((("is_personal" IS TRUE) AND ("created_by" = "public"."current_user_id"())) OR (("is_personal" IS NOT TRUE) AND (("community_id" IS NULL) OR (EXISTS ( SELECT 1 + FROM ("public"."communities" "c" + LEFT JOIN "public"."user_community_memberships" "m" ON ((("m"."community_id" = "c"."id") AND ("m"."user_id" = "public"."current_user_id"())))) + WHERE (("c"."id" = "community_events"."community_id") AND ((COALESCE("c"."is_private", false) = false) OR ("m"."status" = ANY (ARRAY['member'::"text", 'admin'::"text"])) OR ("c"."created_by" = "public"."current_user_id"())))))))))); + + + +CREATE POLICY "Events update (owner-or-admin)" ON "public"."community_events" FOR UPDATE TO "authenticated" USING ((("created_by" = "public"."current_user_id"()) OR (("community_id" IS NOT NULL) AND (EXISTS ( SELECT 1 + FROM "public"."user_community_memberships" "m" + WHERE (("m"."community_id" = "m"."community_id") AND ("m"."user_id" = "public"."current_user_id"()) AND ("m"."status" = 'admin'::"text"))))))) WITH CHECK ((("created_by" = "public"."current_user_id"()) OR (("community_id" IS NOT NULL) AND (EXISTS ( SELECT 1 + FROM "public"."user_community_memberships" "m" + WHERE (("m"."community_id" = "m"."community_id") AND ("m"."user_id" = "public"."current_user_id"()) AND ("m"."status" = 'admin'::"text"))))))); + + + +CREATE POLICY "Feedback readable" ON "public"."feedback_logs" FOR SELECT TO "authenticated" USING (("user_id" = "public"."current_user_id"())); + + + +CREATE POLICY "Feedback self-manage" ON "public"."feedback_logs" TO "authenticated" USING (("user_id" = "public"."current_user_id"())) WITH CHECK (("user_id" = "public"."current_user_id"())); + + + +CREATE POLICY "Membership roster visibility" ON "public"."user_community_memberships" FOR SELECT TO "authenticated" USING ((("user_id" = "auth"."uid"()) OR "public"."can_view_community_roster"("community_id", "auth"."uid"()))); + + + +CREATE POLICY "Membership self-management" ON "public"."user_community_memberships" TO "authenticated" USING (("user_id" = "public"."current_user_id"())) WITH CHECK (("user_id" = "public"."current_user_id"())); + + + +CREATE POLICY "Public profiles are viewable by everyone." ON "public"."profiles" FOR SELECT USING (true); + + + +CREATE POLICY "Quiz questions are readable by authenticated users" ON "public"."quiz_questions" FOR SELECT TO "authenticated" USING (("is_active" = true)); + + + +CREATE POLICY "Registrations readable (owner)" ON "public"."event_registrations" FOR SELECT TO "authenticated" USING (("user_id" = "public"."current_user_id"())); + + + +CREATE POLICY "Registrations self-management" ON "public"."event_registrations" TO "authenticated" USING (("user_id" = "public"."current_user_id"())) WITH CHECK (("user_id" = "public"."current_user_id"())); + + + +CREATE POLICY "Streak records are readable by authenticated users" ON "public"."streak_records" FOR SELECT TO "authenticated" USING (true); + + + +CREATE POLICY "User achievements grant" ON "public"."user_achievements" FOR INSERT TO "authenticated" WITH CHECK ((EXISTS ( SELECT 1 + FROM ("public"."achievements" "a" + LEFT JOIN "public"."user_community_memberships" "m" ON ((("m"."community_id" = "a"."community_id") AND ("m"."user_id" = "public"."current_user_id"())))) + WHERE (("a"."id" = "user_achievements"."achievement_id") AND (("a"."community_id" IS NULL) OR ("m"."status" = 'admin'::"text")))))); + + + +CREATE POLICY "User achievements readable" ON "public"."user_achievements" FOR SELECT TO "authenticated" USING ((("user_id" = "public"."current_user_id"()) OR (("community_id" IS NOT NULL) AND (EXISTS ( SELECT 1 + FROM "public"."user_community_memberships" "m" + WHERE (("m"."community_id" = "user_achievements"."community_id") AND ("m"."user_id" = "public"."current_user_id"()) AND ("m"."status" = 'admin'::"text"))))))); + + + +CREATE POLICY "Users can insert their own daily results" ON "public"."daily_challenge_results" FOR INSERT TO "authenticated" WITH CHECK (("user_id" = "public"."current_user_id"())); + + + +CREATE POLICY "Users can insert their own profile." ON "public"."profiles" FOR INSERT WITH CHECK (("auth"."uid"() = "id")); + + + +CREATE POLICY "Users can insert their own streak records" ON "public"."streak_records" FOR INSERT TO "authenticated" WITH CHECK (("user_id" = "public"."current_user_id"())); + + + +CREATE POLICY "Users can update own profile." ON "public"."profiles" FOR UPDATE USING (("auth"."uid"() = "id")); + + + +CREATE POLICY "Users can view answers for their challenges" ON "public"."arena_challenge_answers" FOR SELECT TO "authenticated" USING ((EXISTS ( SELECT 1 + FROM "public"."arena_challenges" "ac" + WHERE (("ac"."id" = "arena_challenge_answers"."challenge_id") AND (("ac"."challenger_id" = "public"."current_user_id"()) OR ("ac"."opponent_id" = "public"."current_user_id"())))))); + + + +CREATE POLICY "Users can view own applications" ON "public"."community_join_applications" FOR SELECT TO "authenticated" USING ((("public"."current_user_id"() = "user_id") OR "public"."is_community_admin"("community_id", "public"."current_user_id"()))); + + + +CREATE POLICY "Users can view own credit grants" ON "public"."credit_grants" FOR SELECT TO "authenticated" USING ((("public"."current_user_id"() = "user_id") OR (("community_id" IS NOT NULL) AND "public"."is_community_admin"("community_id", "public"."current_user_id"())))); + + + +CREATE POLICY "Users can view their own challenges" ON "public"."arena_challenges" FOR SELECT TO "authenticated" USING ((("public"."current_user_id"() = "challenger_id") OR ("public"."current_user_id"() = "opponent_id"))); + + + +ALTER TABLE "public"."achievements" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."admin_action_logs" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."arena_challenge_answers" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."arena_challenges" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."communities" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."community_events" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."community_join_applications" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."credit_grants" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."daily_challenge_results" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."daily_challenges" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."event_registrations" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."feedback_logs" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."profiles" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."quiz_questions" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."streak_records" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."user_achievements" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."user_community_memberships" ENABLE ROW LEVEL SECURITY; + + + + +ALTER PUBLICATION "supabase_realtime" OWNER TO "postgres"; + + + + + + +GRANT USAGE ON SCHEMA "public" TO "postgres"; +GRANT USAGE ON SCHEMA "public" TO "anon"; +GRANT USAGE ON SCHEMA "public" TO "authenticated"; +GRANT USAGE ON SCHEMA "public" TO "service_role"; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +GRANT ALL ON FUNCTION "public"."accept_arena_challenge"("p_challenge_id" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."accept_arena_challenge"("p_challenge_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."accept_arena_challenge"("p_challenge_id" "uuid") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."apply_to_join_community"("p_community_id" "text", "p_message" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."apply_to_join_community"("p_community_id" "text", "p_message" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."apply_to_join_community"("p_community_id" "text", "p_message" "text") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."calculate_distance_km"("lat1" numeric, "lon1" numeric, "lat2" numeric, "lon2" numeric) TO "anon"; +GRANT ALL ON FUNCTION "public"."calculate_distance_km"("lat1" numeric, "lon1" numeric, "lat2" numeric, "lon2" numeric) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."calculate_distance_km"("lat1" numeric, "lon1" numeric, "lat2" numeric, "lon2" numeric) TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."can_user_create_community"() TO "anon"; +GRANT ALL ON FUNCTION "public"."can_user_create_community"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."can_user_create_community"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."can_user_create_event"() TO "anon"; +GRANT ALL ON FUNCTION "public"."can_user_create_event"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."can_user_create_event"() TO "service_role"; + + + +REVOKE ALL ON FUNCTION "public"."can_view_community_roster"("p_community_id" "text", "p_user_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."can_view_community_roster"("p_community_id" "text", "p_user_id" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."can_view_community_roster"("p_community_id" "text", "p_user_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."can_view_community_roster"("p_community_id" "text", "p_user_id" "uuid") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."cancel_event_registration"("p_event_id" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."cancel_event_registration"("p_event_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."cancel_event_registration"("p_event_id" "uuid") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."check_and_grant_achievement"("p_trigger_key" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."check_and_grant_achievement"("p_trigger_key" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."check_and_grant_achievement"("p_trigger_key" "text") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."complete_arena_challenge"("p_challenge_id" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."complete_arena_challenge"("p_challenge_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."complete_arena_challenge"("p_challenge_id" "uuid") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."create_arena_challenge"("p_opponent_id" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."create_arena_challenge"("p_opponent_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."create_arena_challenge"("p_opponent_id" "uuid") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."create_community"("p_id" "text", "p_name" "text", "p_city" "text", "p_state" "text", "p_description" "text", "p_latitude" numeric, "p_longitude" numeric) TO "anon"; +GRANT ALL ON FUNCTION "public"."create_community"("p_id" "text", "p_name" "text", "p_city" "text", "p_state" "text", "p_description" "text", "p_latitude" numeric, "p_longitude" numeric) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."create_community"("p_id" "text", "p_name" "text", "p_city" "text", "p_state" "text", "p_description" "text", "p_latitude" numeric, "p_longitude" numeric) TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."create_event"("p_title" "text", "p_description" "text", "p_category" "text", "p_event_date" timestamp with time zone, "p_location" "text", "p_latitude" numeric, "p_longitude" numeric, "p_max_participants" integer, "p_community_id" "text", "p_icon_name" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."create_event"("p_title" "text", "p_description" "text", "p_category" "text", "p_event_date" timestamp with time zone, "p_location" "text", "p_latitude" numeric, "p_longitude" numeric, "p_max_participants" integer, "p_community_id" "text", "p_icon_name" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."create_event"("p_title" "text", "p_description" "text", "p_category" "text", "p_event_date" timestamp with time zone, "p_location" "text", "p_latitude" numeric, "p_longitude" numeric, "p_max_participants" integer, "p_community_id" "text", "p_icon_name" "text") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."current_user_id"() TO "anon"; +GRANT ALL ON FUNCTION "public"."current_user_id"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."current_user_id"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."decline_arena_challenge"("p_challenge_id" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."decline_arena_challenge"("p_challenge_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."decline_arena_challenge"("p_challenge_id" "uuid") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."find_friends_leaderboard"("p_emails" "text"[], "p_phones" "text"[]) TO "anon"; +GRANT ALL ON FUNCTION "public"."find_friends_leaderboard"("p_emails" "text"[], "p_phones" "text"[]) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."find_friends_leaderboard"("p_emails" "text"[], "p_phones" "text"[]) TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_admin_action_logs"("p_community_id" "text", "p_limit" integer) TO "anon"; +GRANT ALL ON FUNCTION "public"."get_admin_action_logs"("p_community_id" "text", "p_limit" integer) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_admin_action_logs"("p_community_id" "text", "p_limit" integer) TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_challenge_questions"("p_challenge_id" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."get_challenge_questions"("p_challenge_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_challenge_questions"("p_challenge_id" "uuid") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_communities_by_city"("p_city" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."get_communities_by_city"("p_city" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_communities_by_city"("p_city" "text") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_community_events"("p_community_id" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."get_community_events"("p_community_id" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_community_events"("p_community_id" "text") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_community_leaderboard"("p_community_id" "text", "p_limit" integer) TO "anon"; +GRANT ALL ON FUNCTION "public"."get_community_leaderboard"("p_community_id" "text", "p_limit" integer) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_community_leaderboard"("p_community_id" "text", "p_limit" integer) TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_community_members_admin"("p_community_id" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."get_community_members_admin"("p_community_id" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_community_members_admin"("p_community_id" "text") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_community_members_for_grant"("p_community_id" "text", "p_achievement_id" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."get_community_members_for_grant"("p_community_id" "text", "p_achievement_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_community_members_for_grant"("p_community_id" "text", "p_achievement_id" "uuid") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_daily_challenge"() TO "anon"; +GRANT ALL ON FUNCTION "public"."get_daily_challenge"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_daily_challenge"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_daily_leaderboard"("p_date" "date", "p_limit" integer) TO "anon"; +GRANT ALL ON FUNCTION "public"."get_daily_leaderboard"("p_date" "date", "p_limit" integer) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_daily_leaderboard"("p_date" "date", "p_limit" integer) TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_event_participants"("p_event_id" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."get_event_participants"("p_event_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_event_participants"("p_event_id" "uuid") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_my_achievements"() TO "anon"; +GRANT ALL ON FUNCTION "public"."get_my_achievements"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_my_achievements"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_my_challenges"("p_status" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."get_my_challenges"("p_status" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_my_challenges"("p_status" "text") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_my_communities"() TO "anon"; +GRANT ALL ON FUNCTION "public"."get_my_communities"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_my_communities"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_my_registrations"() TO "anon"; +GRANT ALL ON FUNCTION "public"."get_my_registrations"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_my_registrations"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_nearby_events"("p_latitude" numeric, "p_longitude" numeric, "p_max_distance_km" numeric, "p_category" "text", "p_only_joined_communities" boolean, "p_sort_by" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."get_nearby_events"("p_latitude" numeric, "p_longitude" numeric, "p_max_distance_km" numeric, "p_category" "text", "p_only_joined_communities" boolean, "p_sort_by" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_nearby_events"("p_latitude" numeric, "p_longitude" numeric, "p_max_distance_km" numeric, "p_category" "text", "p_only_joined_communities" boolean, "p_sort_by" "text") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_pending_applications"("p_community_id" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."get_pending_applications"("p_community_id" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_pending_applications"("p_community_id" "text") TO "service_role"; + + + +GRANT ALL ON TABLE "public"."quiz_questions" TO "anon"; +GRANT ALL ON TABLE "public"."quiz_questions" TO "authenticated"; +GRANT ALL ON TABLE "public"."quiz_questions" TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_quiz_questions"() TO "anon"; +GRANT ALL ON FUNCTION "public"."get_quiz_questions"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_quiz_questions"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_quiz_questions_batch"("p_limit" integer) TO "anon"; +GRANT ALL ON FUNCTION "public"."get_quiz_questions_batch"("p_limit" integer) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_quiz_questions_batch"("p_limit" integer) TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_streak_leaderboard"("p_limit" integer) TO "anon"; +GRANT ALL ON FUNCTION "public"."get_streak_leaderboard"("p_limit" integer) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_streak_leaderboard"("p_limit" integer) TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."grant_event_credits"("p_event_id" "uuid", "p_user_ids" "uuid"[], "p_credits_per_user" integer, "p_reason" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."grant_event_credits"("p_event_id" "uuid", "p_user_ids" "uuid"[], "p_credits_per_user" integer, "p_reason" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."grant_event_credits"("p_event_id" "uuid", "p_user_ids" "uuid"[], "p_credits_per_user" integer, "p_reason" "text") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."handle_community_member_count"() TO "anon"; +GRANT ALL ON FUNCTION "public"."handle_community_member_count"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."handle_community_member_count"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."handle_event_participant_count"() TO "anon"; +GRANT ALL ON FUNCTION "public"."handle_event_participant_count"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."handle_event_participant_count"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."handle_new_user"() TO "anon"; +GRANT ALL ON FUNCTION "public"."handle_new_user"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."handle_new_user"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."handle_user_update"() TO "anon"; +GRANT ALL ON FUNCTION "public"."handle_user_update"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."handle_user_update"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."increment_credits"("amount" integer) TO "anon"; +GRANT ALL ON FUNCTION "public"."increment_credits"("amount" integer) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."increment_credits"("amount" integer) TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."increment_total_scans"() TO "anon"; +GRANT ALL ON FUNCTION "public"."increment_total_scans"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."increment_total_scans"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."is_community_admin"("p_community_id" "text", "p_user_id" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."is_community_admin"("p_community_id" "text", "p_user_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."is_community_admin"("p_community_id" "text", "p_user_id" "uuid") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."join_community"("p_community_id" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."join_community"("p_community_id" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."join_community"("p_community_id" "text") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."leave_community"("p_community_id" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."leave_community"("p_community_id" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."leave_community"("p_community_id" "text") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."normalize_phone_number"("p_input" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."normalize_phone_number"("p_input" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."normalize_phone_number"("p_input" "text") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."protect_sensitive_profile_fields"() TO "anon"; +GRANT ALL ON FUNCTION "public"."protect_sensitive_profile_fields"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."protect_sensitive_profile_fields"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."register_for_event"("p_event_id" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."register_for_event"("p_event_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."register_for_event"("p_event_id" "uuid") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."remove_community_member"("p_community_id" "text", "p_user_id" "uuid", "p_reason" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."remove_community_member"("p_community_id" "text", "p_user_id" "uuid", "p_reason" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."remove_community_member"("p_community_id" "text", "p_user_id" "uuid", "p_reason" "text") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."review_join_application"("p_application_id" "uuid", "p_approve" boolean, "p_rejection_reason" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."review_join_application"("p_application_id" "uuid", "p_approve" boolean, "p_rejection_reason" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."review_join_application"("p_application_id" "uuid", "p_approve" boolean, "p_rejection_reason" "text") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."set_primary_achievement"("achievement_id" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."set_primary_achievement"("achievement_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."set_primary_achievement"("achievement_id" "uuid") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."submit_daily_challenge"("p_score" integer, "p_correct_count" integer, "p_time_seconds" numeric, "p_max_combo" integer) TO "anon"; +GRANT ALL ON FUNCTION "public"."submit_daily_challenge"("p_score" integer, "p_correct_count" integer, "p_time_seconds" numeric, "p_max_combo" integer) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."submit_daily_challenge"("p_score" integer, "p_correct_count" integer, "p_time_seconds" numeric, "p_max_combo" integer) TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."submit_duel_answer"("p_challenge_id" "uuid", "p_question_index" integer, "p_selected_category" "text", "p_answer_time_ms" integer) TO "anon"; +GRANT ALL ON FUNCTION "public"."submit_duel_answer"("p_challenge_id" "uuid", "p_question_index" integer, "p_selected_category" "text", "p_answer_time_ms" integer) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."submit_duel_answer"("p_challenge_id" "uuid", "p_question_index" integer, "p_selected_category" "text", "p_answer_time_ms" integer) TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."submit_streak_record"("p_streak_count" integer) TO "anon"; +GRANT ALL ON FUNCTION "public"."submit_streak_record"("p_streak_count" integer) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."submit_streak_record"("p_streak_count" integer) TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."update_community_info"("p_community_id" "text", "p_description" "text", "p_welcome_message" "text", "p_rules" "text", "p_requires_approval" boolean) TO "anon"; +GRANT ALL ON FUNCTION "public"."update_community_info"("p_community_id" "text", "p_description" "text", "p_welcome_message" "text", "p_rules" "text", "p_requires_approval" boolean) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."update_community_info"("p_community_id" "text", "p_description" "text", "p_welcome_message" "text", "p_rules" "text", "p_requires_approval" boolean) TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."update_user_location"("p_city" "text", "p_state" "text", "p_latitude" double precision, "p_longitude" double precision) TO "anon"; +GRANT ALL ON FUNCTION "public"."update_user_location"("p_city" "text", "p_state" "text", "p_latitude" double precision, "p_longitude" double precision) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."update_user_location"("p_city" "text", "p_state" "text", "p_latitude" double precision, "p_longitude" double precision) TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."update_user_location"("p_city" "text", "p_state" "text", "p_latitude" numeric, "p_longitude" numeric) TO "anon"; +GRANT ALL ON FUNCTION "public"."update_user_location"("p_city" "text", "p_state" "text", "p_latitude" numeric, "p_longitude" numeric) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."update_user_location"("p_city" "text", "p_state" "text", "p_latitude" numeric, "p_longitude" numeric) TO "service_role"; + + + + + + + + + + + + + + + + + + +GRANT ALL ON TABLE "public"."achievements" TO "anon"; +GRANT ALL ON TABLE "public"."achievements" TO "authenticated"; +GRANT ALL ON TABLE "public"."achievements" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."admin_action_logs" TO "anon"; +GRANT ALL ON TABLE "public"."admin_action_logs" TO "authenticated"; +GRANT ALL ON TABLE "public"."admin_action_logs" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."arena_challenge_answers" TO "anon"; +GRANT ALL ON TABLE "public"."arena_challenge_answers" TO "authenticated"; +GRANT ALL ON TABLE "public"."arena_challenge_answers" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."arena_challenges" TO "anon"; +GRANT ALL ON TABLE "public"."arena_challenges" TO "authenticated"; +GRANT ALL ON TABLE "public"."arena_challenges" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."communities" TO "anon"; +GRANT ALL ON TABLE "public"."communities" TO "authenticated"; +GRANT ALL ON TABLE "public"."communities" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."community_events" TO "anon"; +GRANT ALL ON TABLE "public"."community_events" TO "authenticated"; +GRANT ALL ON TABLE "public"."community_events" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."community_join_applications" TO "anon"; +GRANT ALL ON TABLE "public"."community_join_applications" TO "authenticated"; +GRANT ALL ON TABLE "public"."community_join_applications" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."credit_grants" TO "anon"; +GRANT ALL ON TABLE "public"."credit_grants" TO "authenticated"; +GRANT ALL ON TABLE "public"."credit_grants" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."daily_challenge_results" TO "anon"; +GRANT ALL ON TABLE "public"."daily_challenge_results" TO "authenticated"; +GRANT ALL ON TABLE "public"."daily_challenge_results" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."daily_challenges" TO "anon"; +GRANT ALL ON TABLE "public"."daily_challenges" TO "authenticated"; +GRANT ALL ON TABLE "public"."daily_challenges" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."event_registrations" TO "anon"; +GRANT ALL ON TABLE "public"."event_registrations" TO "authenticated"; +GRANT ALL ON TABLE "public"."event_registrations" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."feedback_logs" TO "anon"; +GRANT ALL ON TABLE "public"."feedback_logs" TO "authenticated"; +GRANT ALL ON TABLE "public"."feedback_logs" TO "service_role"; + + + +GRANT ALL ON SEQUENCE "public"."feedback_logs_id_seq" TO "anon"; +GRANT ALL ON SEQUENCE "public"."feedback_logs_id_seq" TO "authenticated"; +GRANT ALL ON SEQUENCE "public"."feedback_logs_id_seq" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."profiles" TO "anon"; +GRANT ALL ON TABLE "public"."profiles" TO "authenticated"; +GRANT ALL ON TABLE "public"."profiles" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."streak_records" TO "anon"; +GRANT ALL ON TABLE "public"."streak_records" TO "authenticated"; +GRANT ALL ON TABLE "public"."streak_records" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."user_achievements" TO "anon"; +GRANT ALL ON TABLE "public"."user_achievements" TO "authenticated"; +GRANT ALL ON TABLE "public"."user_achievements" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."user_community_memberships" TO "anon"; +GRANT ALL ON TABLE "public"."user_community_memberships" TO "authenticated"; +GRANT ALL ON TABLE "public"."user_community_memberships" TO "service_role"; + + + + + + + + + +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "postgres"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "anon"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "authenticated"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "service_role"; + + + + + + +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "postgres"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "anon"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "authenticated"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "service_role"; + + + + + + +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "postgres"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "anon"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "authenticated"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "service_role"; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +drop extension if exists "pg_net"; + +CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); + +CREATE TRIGGER on_auth_user_updated AFTER UPDATE ON auth.users FOR EACH ROW EXECUTE FUNCTION public.handle_user_update(); + + + create policy "Allow public select 1d1lroy_0" + on "storage"."objects" + as permissive + for select + to public +using ((bucket_id = 'feedback_images'::text)); + + + + create policy "Allow uploads 1d1lroy_0" + on "storage"."objects" + as permissive + for insert + to public +with check ((bucket_id = 'feedback_images'::text)); + + + diff --git a/legacy/swift-ios/The Trash/migrations/20260217133000_security_hardening_and_counter_fixes.sql b/legacy/swift-ios/The Trash/migrations/20260217133000_security_hardening_and_counter_fixes.sql new file mode 100644 index 0000000..d1256db --- /dev/null +++ b/legacy/swift-ios/The Trash/migrations/20260217133000_security_hardening_and_counter_fixes.sql @@ -0,0 +1,635 @@ +BEGIN; + +-- Fix lint/runtime issue: remove legacy reference to non-existent table +CREATE OR REPLACE FUNCTION public.can_view_community_roster(p_community_id text, p_user_id uuid) +RETURNS boolean +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path TO 'public', 'pg_temp' +AS $$ +BEGIN + IF p_community_id IS NULL OR p_user_id IS NULL THEN + RETURN FALSE; + END IF; + + RETURN EXISTS ( + SELECT 1 + FROM public.user_community_memberships m + WHERE m.community_id = p_community_id + AND m.user_id = p_user_id + AND m.status IN ('member', 'admin') + ); +END; +$$; + +-- Harden SECURITY DEFINER search_path +ALTER FUNCTION public.complete_arena_challenge(uuid) + SET search_path = public, pg_temp; + +-- Fix achievement grant logic and harden search_path +CREATE OR REPLACE FUNCTION public.check_and_grant_achievement(p_trigger_key text) +RETURNS json +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public', 'auth', 'pg_temp' +AS $$ +DECLARE + v_user_id UUID := auth.uid(); + v_achievement RECORD; + v_profile RECORD; + v_already_has BOOLEAN; + v_qualifies BOOLEAN := false; + v_auth_email TEXT; + v_email_confirmed_at TIMESTAMPTZ; +BEGIN + IF v_user_id IS NULL THEN + RETURN json_build_object('granted', false, 'reason', 'Not authenticated'); + END IF; + + SELECT * INTO v_achievement + FROM public.achievements + WHERE trigger_key = p_trigger_key AND community_id IS NULL; + + IF NOT FOUND THEN + RETURN json_build_object('granted', false, 'reason', 'Achievement not found'); + END IF; + + SELECT EXISTS ( + SELECT 1 FROM public.user_achievements + WHERE user_id = v_user_id AND achievement_id = v_achievement.id + ) INTO v_already_has; + + IF v_already_has THEN + RETURN json_build_object('granted', false, 'reason', 'Already earned'); + END IF; + + SELECT * INTO v_profile FROM public.profiles WHERE id = v_user_id; + + CASE p_trigger_key + WHEN 'first_scan' THEN + v_qualifies := COALESCE(v_profile.total_scans, 0) >= 1; + WHEN 'scans_10' THEN + v_qualifies := COALESCE(v_profile.total_scans, 0) >= 10; + WHEN 'scans_50' THEN + v_qualifies := COALESCE(v_profile.total_scans, 0) >= 50; + WHEN 'credits_100' THEN + v_qualifies := COALESCE(v_profile.credits, 0) >= 100; + WHEN 'credits_500' THEN + v_qualifies := COALESCE(v_profile.credits, 0) >= 500; + WHEN 'credits_2000' THEN + v_qualifies := COALESCE(v_profile.credits, 0) >= 2000; + WHEN 'join_community' THEN + v_qualifies := EXISTS ( + SELECT 1 FROM public.user_community_memberships + WHERE user_id = v_user_id AND status IN ('member', 'admin') + ); + WHEN 'arena_win' THEN + v_qualifies := EXISTS ( + SELECT 1 + FROM public.arena_challenges ac + WHERE ac.status = 'completed' + AND ac.winner_id = v_user_id + ); + WHEN 'ucsd_email' THEN + SELECT email, email_confirmed_at INTO v_auth_email, v_email_confirmed_at + FROM auth.users + WHERE id = v_user_id; + v_qualifies := v_email_confirmed_at IS NOT NULL + AND v_auth_email ILIKE '%@ucsd.edu'; + ELSE + v_qualifies := false; + END CASE; + + IF NOT v_qualifies THEN + RETURN json_build_object('granted', false, 'reason', 'Not qualified'); + END IF; + + INSERT INTO public.user_achievements (user_id, achievement_id) + VALUES (v_user_id, v_achievement.id) + ON CONFLICT (user_id, achievement_id) DO NOTHING; + + IF NOT FOUND THEN + RETURN json_build_object('granted', false, 'reason', 'Already earned'); + END IF; + + RETURN json_build_object( + 'granted', true, + 'achievement_id', v_achievement.id, + 'name', v_achievement.name, + 'description', v_achievement.description, + 'icon_name', v_achievement.icon_name, + 'rarity', v_achievement.rarity + ); +END; +$$; + +-- Remove duplicate counter updates from membership join flow +CREATE OR REPLACE FUNCTION public.apply_to_join_community(p_community_id text, p_message text DEFAULT NULL::text) +RETURNS json +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public', 'pg_temp' +AS $$ +DECLARE + v_user_id UUID := auth.uid(); + v_requires_approval BOOLEAN; + v_membership_status TEXT; +BEGIN + IF v_user_id IS NULL THEN + RETURN json_build_object('success', false, 'message', 'Not authenticated'); + END IF; + + SELECT requires_approval INTO v_requires_approval + FROM public.communities + WHERE id = p_community_id AND is_active = true; + + IF NOT FOUND THEN + RETURN json_build_object('success', false, 'message', 'Community not found'); + END IF; + + SELECT status INTO v_membership_status + FROM public.user_community_memberships + WHERE user_id = v_user_id AND community_id = p_community_id; + + IF FOUND THEN + IF v_membership_status IN ('member', 'admin') THEN + RETURN json_build_object('success', false, 'message', 'Already a member'); + ELSIF v_membership_status = 'banned' THEN + RETURN json_build_object('success', false, 'message', 'You are banned from this community'); + ELSE + RETURN json_build_object('success', false, 'message', 'Application already pending'); + END IF; + END IF; + + IF NOT v_requires_approval THEN + INSERT INTO public.user_community_memberships (user_id, community_id, status) + VALUES (v_user_id, p_community_id, 'member') + ON CONFLICT (user_id, community_id) DO UPDATE + SET status = 'member', joined_at = NOW(); + + RETURN json_build_object( + 'success', true, + 'message', 'Joined successfully', + 'requires_approval', false + ); + END IF; + + INSERT INTO public.community_join_applications (community_id, user_id, message) + VALUES (p_community_id, v_user_id, p_message) + ON CONFLICT (community_id, user_id) + DO UPDATE SET + status = 'pending', + message = EXCLUDED.message, + rejection_reason = NULL, + reviewed_by = NULL, + reviewed_at = NULL, + updated_at = NOW(); + + RETURN json_build_object( + 'success', true, + 'message', 'Application submitted', + 'requires_approval', true + ); +END; +$$; + +CREATE OR REPLACE FUNCTION public.create_community( + p_id text, + p_name text, + p_city text, + p_state text, + p_description text DEFAULT NULL::text, + p_latitude numeric DEFAULT NULL::numeric, + p_longitude numeric DEFAULT NULL::numeric +) +RETURNS json +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public', 'pg_temp' +AS $$ +DECLARE + v_user_id UUID := auth.uid(); + v_can_create json; + v_community_id TEXT; +BEGIN + IF v_user_id IS NULL THEN + RETURN json_build_object('success', false, 'message', 'Not authenticated'); + END IF; + + v_can_create := public.can_user_create_community(); + IF NOT (v_can_create->>'allowed')::boolean THEN + RETURN json_build_object('success', false, 'message', v_can_create->>'reason'); + END IF; + + IF EXISTS (SELECT 1 FROM public.communities WHERE id = p_id) THEN + RETURN json_build_object('success', false, 'message', 'Community ID already exists'); + END IF; + + INSERT INTO public.communities ( + id, name, city, state, description, latitude, longitude, created_by, member_count + ) + VALUES ( + p_id, p_name, p_city, p_state, p_description, p_latitude, p_longitude, v_user_id, 0 + ) + RETURNING id INTO v_community_id; + + INSERT INTO public.user_community_memberships (user_id, community_id, status) + VALUES (v_user_id, v_community_id, 'admin') + ON CONFLICT (user_id, community_id) DO UPDATE + SET status = 'admin', joined_at = NOW(); + + RETURN json_build_object('success', true, 'message', 'Community created', 'community_id', v_community_id); +END; +$$; + +CREATE OR REPLACE FUNCTION public.join_community(p_community_id text) +RETURNS json +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public', 'pg_temp' +AS $$ +DECLARE + v_user_id UUID := auth.uid(); + v_existing RECORD; +BEGIN + IF v_user_id IS NULL THEN + RETURN json_build_object('success', false, 'message', 'Not authenticated'); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM public.communities WHERE id = p_community_id AND is_active = true + ) THEN + RETURN json_build_object('success', false, 'message', 'Community not found'); + END IF; + + SELECT * INTO v_existing + FROM public.user_community_memberships + WHERE user_id = v_user_id AND community_id = p_community_id; + + IF FOUND THEN + IF v_existing.status IN ('member', 'admin') THEN + RETURN json_build_object('success', false, 'message', 'Already a member'); + ELSIF v_existing.status = 'banned' THEN + RETURN json_build_object('success', false, 'message', 'You are banned from this community'); + ELSE + UPDATE public.user_community_memberships + SET status = 'member', joined_at = NOW() + WHERE id = v_existing.id; + END IF; + ELSE + INSERT INTO public.user_community_memberships (user_id, community_id, status) + VALUES (v_user_id, p_community_id, 'member'); + END IF; + + RETURN json_build_object('success', true, 'message', 'Joined community successfully'); +END; +$$; + +CREATE OR REPLACE FUNCTION public.leave_community(p_community_id text) +RETURNS json +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public', 'pg_temp' +AS $$ +DECLARE + v_user_id UUID; + v_deleted_count INTEGER; +BEGIN + v_user_id := public.current_user_id(); + IF v_user_id IS NULL THEN + RETURN json_build_object('success', false, 'message', 'Not authenticated'); + END IF; + + DELETE FROM public.user_community_memberships + WHERE user_id = v_user_id + AND community_id = p_community_id + AND status IN ('member', 'admin'); + + GET DIAGNOSTICS v_deleted_count = ROW_COUNT; + + IF COALESCE(v_deleted_count, 0) = 0 THEN + RETURN json_build_object('success', false, 'message', 'Not a member of this community'); + END IF; + + RETURN json_build_object('success', true, 'message', 'Left community successfully'); +END; +$$; + +CREATE OR REPLACE FUNCTION public.review_join_application( + p_application_id uuid, + p_approve boolean, + p_rejection_reason text DEFAULT NULL::text +) +RETURNS json +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public', 'pg_temp' +AS $$ +DECLARE + v_admin_id UUID := auth.uid(); + v_community_id TEXT; + v_user_id UUID; + v_username TEXT; +BEGIN + SELECT community_id, user_id INTO v_community_id, v_user_id + FROM public.community_join_applications + WHERE id = p_application_id AND status = 'pending'; + + IF NOT FOUND THEN + RETURN json_build_object('success', false, 'message', 'Application not found'); + END IF; + + IF NOT public.is_community_admin(v_community_id, v_admin_id) THEN + RETURN json_build_object('success', false, 'message', 'Permission denied'); + END IF; + + SELECT username INTO v_username FROM public.profiles WHERE id = v_user_id; + + IF p_approve THEN + UPDATE public.community_join_applications + SET status = 'approved', + reviewed_by = v_admin_id, + reviewed_at = NOW(), + updated_at = NOW() + WHERE id = p_application_id; + + INSERT INTO public.user_community_memberships (user_id, community_id, status) + VALUES (v_user_id, v_community_id, 'member') + ON CONFLICT (user_id, community_id) DO UPDATE + SET status = 'member', joined_at = NOW(); + + INSERT INTO public.admin_action_logs (community_id, admin_id, action_type, target_user_id, details) + VALUES ( + v_community_id, + v_admin_id, + 'approve_member', + v_user_id, + json_build_object('username', v_username) + ); + + RETURN json_build_object('success', true, 'message', 'Application approved'); + ELSE + UPDATE public.community_join_applications + SET status = 'rejected', + reviewed_by = v_admin_id, + reviewed_at = NOW(), + rejection_reason = p_rejection_reason, + updated_at = NOW() + WHERE id = p_application_id; + + INSERT INTO public.admin_action_logs (community_id, admin_id, action_type, target_user_id, details) + VALUES ( + v_community_id, + v_admin_id, + 'reject_member', + v_user_id, + json_build_object('username', v_username, 'reason', p_rejection_reason) + ); + + RETURN json_build_object('success', true, 'message', 'Application rejected'); + END IF; +END; +$$; + +CREATE OR REPLACE FUNCTION public.remove_community_member( + p_community_id text, + p_user_id uuid, + p_reason text DEFAULT NULL::text +) +RETURNS json +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public', 'pg_temp' +AS $$ +DECLARE + v_admin_id UUID := auth.uid(); + v_username TEXT; +BEGIN + IF NOT public.is_community_admin(p_community_id, v_admin_id) THEN + RETURN json_build_object('success', false, 'message', 'Permission denied'); + END IF; + + IF public.is_community_admin(p_community_id, p_user_id) THEN + RETURN json_build_object('success', false, 'message', 'Cannot remove admin'); + END IF; + + SELECT username INTO v_username FROM public.profiles WHERE id = p_user_id; + + DELETE FROM public.user_community_memberships + WHERE community_id = p_community_id AND user_id = p_user_id; + + IF NOT FOUND THEN + RETURN json_build_object('success', false, 'message', 'User is not a member'); + END IF; + + INSERT INTO public.admin_action_logs (community_id, admin_id, action_type, target_user_id, details) + VALUES ( + p_community_id, + v_admin_id, + 'remove_member', + p_user_id, + json_build_object('username', v_username, 'reason', p_reason) + ); + + RETURN json_build_object('success', true, 'message', 'Member removed'); +END; +$$; + +CREATE OR REPLACE FUNCTION public.register_for_event(p_event_id uuid) +RETURNS json +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public', 'pg_temp' +AS $$ +DECLARE + v_user_id UUID; + v_event RECORD; + v_existing RECORD; +BEGIN + v_user_id := public.current_user_id(); + IF v_user_id IS NULL THEN + RETURN json_build_object('success', false, 'message', 'Not authenticated'); + END IF; + + SELECT * INTO v_event + FROM public.community_events + WHERE id = p_event_id + FOR UPDATE; + + IF NOT FOUND THEN + RETURN json_build_object('success', false, 'message', 'Event not found'); + END IF; + + IF v_event.status NOT IN ('upcoming', 'ongoing') THEN + RETURN json_build_object('success', false, 'message', 'Event is not open for registration'); + END IF; + + IF v_event.max_participants IS NOT NULL + AND COALESCE(v_event.participant_count, 0) >= v_event.max_participants THEN + RETURN json_build_object('success', false, 'message', 'Event is full'); + END IF; + + SELECT * INTO v_existing + FROM public.event_registrations + WHERE event_id = p_event_id + AND user_id = v_user_id; + + IF FOUND THEN + IF v_existing.status = 'registered' THEN + RETURN json_build_object('success', false, 'message', 'Already registered'); + ELSIF v_existing.status = 'cancelled' THEN + UPDATE public.event_registrations + SET status = 'registered', + registered_at = NOW() + WHERE id = v_existing.id; + ELSE + RETURN json_build_object('success', false, 'message', 'Cannot register for this event'); + END IF; + ELSE + INSERT INTO public.event_registrations (event_id, user_id, status, registered_at) + VALUES (p_event_id, v_user_id, 'registered', NOW()); + END IF; + + RETURN json_build_object('success', true, 'message', 'Registration successful'); +END; +$$; + +CREATE OR REPLACE FUNCTION public.cancel_event_registration(p_event_id uuid) +RETURNS json +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public', 'pg_temp' +AS $$ +DECLARE + v_user_id UUID; +BEGIN + v_user_id := public.current_user_id(); + IF v_user_id IS NULL THEN + RETURN json_build_object('success', false, 'message', 'Not authenticated'); + END IF; + + UPDATE public.event_registrations + SET status = 'cancelled' + WHERE event_id = p_event_id + AND user_id = v_user_id + AND status = 'registered'; + + IF NOT FOUND THEN + RETURN json_build_object('success', false, 'message', 'Registration not found'); + END IF; + + RETURN json_build_object('success', true, 'message', 'Registration cancelled'); +END; +$$; + +-- Remove broad read policies that leak data +DROP POLICY IF EXISTS "Enable read access for all users" ON public.feedback_logs; +DROP POLICY IF EXISTS "Public profiles are viewable by everyone." ON public.profiles; + +CREATE POLICY "Profiles readable (authenticated)" +ON public.profiles +FOR SELECT +TO authenticated +USING (true); + +-- Remove direct-write policies for sensitive tables +DROP POLICY IF EXISTS "Membership self-management" ON public.user_community_memberships; +DROP POLICY IF EXISTS "Registrations self-management" ON public.event_registrations; +DROP POLICY IF EXISTS "User achievements grant" ON public.user_achievements; + +CREATE POLICY "User achievements grant (admins only)" +ON public.user_achievements +FOR INSERT +TO authenticated +WITH CHECK ( + community_id IS NOT NULL + AND public.is_community_admin(community_id, public.current_user_id()) + AND EXISTS ( + SELECT 1 + FROM public.achievements a + WHERE a.id = user_achievements.achievement_id + AND a.community_id = user_achievements.community_id + ) +); + +-- Revoke direct DML access; force client writes through vetted RPCs +REVOKE INSERT, UPDATE, DELETE ON TABLE public.user_community_memberships FROM anon, authenticated; +REVOKE INSERT, UPDATE, DELETE ON TABLE public.event_registrations FROM anon, authenticated; +REVOKE INSERT ON TABLE public.user_achievements FROM anon; +REVOKE UPDATE, DELETE ON TABLE public.user_achievements FROM anon, authenticated; + +-- Reduce anonymous surface for mutating RPCs +REVOKE EXECUTE ON FUNCTION public.apply_to_join_community(text, text) FROM anon; +REVOKE EXECUTE ON FUNCTION public.cancel_event_registration(uuid) FROM anon; +REVOKE EXECUTE ON FUNCTION public.check_and_grant_achievement(text) FROM anon; +REVOKE EXECUTE ON FUNCTION public.complete_arena_challenge(uuid) FROM anon; +REVOKE EXECUTE ON FUNCTION public.create_community(text, text, text, text, text, numeric, numeric) FROM anon; +REVOKE EXECUTE ON FUNCTION public.create_event(text, text, text, timestamptz, text, numeric, numeric, integer, text, text) FROM anon; +REVOKE EXECUTE ON FUNCTION public.grant_event_credits(uuid, uuid[], integer, text) FROM anon; +REVOKE EXECUTE ON FUNCTION public.increment_credits(integer) FROM anon; +REVOKE EXECUTE ON FUNCTION public.increment_total_scans() FROM anon; +REVOKE EXECUTE ON FUNCTION public.join_community(text) FROM anon; +REVOKE EXECUTE ON FUNCTION public.leave_community(text) FROM anon; +REVOKE EXECUTE ON FUNCTION public.register_for_event(uuid) FROM anon; +REVOKE EXECUTE ON FUNCTION public.remove_community_member(text, uuid, text) FROM anon; +REVOKE EXECUTE ON FUNCTION public.review_join_application(uuid, boolean, text) FROM anon; +REVOKE EXECUTE ON FUNCTION public.submit_daily_challenge(integer, integer, numeric, integer) FROM anon; +REVOKE EXECUTE ON FUNCTION public.submit_duel_answer(uuid, integer, text, integer) FROM anon; +REVOKE EXECUTE ON FUNCTION public.submit_streak_record(integer) FROM anon; +REVOKE EXECUTE ON FUNCTION public.update_community_info(text, text, text, text, boolean) FROM anon; +REVOKE EXECUTE ON FUNCTION public.update_user_location(text, text, numeric, numeric) FROM anon; +REVOKE EXECUTE ON FUNCTION public.update_user_location(text, text, double precision, double precision) FROM anon; + +-- Harden default privilege baseline for future objects +ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon; +ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON SEQUENCES FROM authenticated; +ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon; +ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM authenticated; +ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON TABLES FROM anon; +ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON TABLES FROM authenticated; + +-- Reconcile denormalized counters after fixing double-update logic +UPDATE public.communities c +SET member_count = COALESCE(m.member_count, 0), + updated_at = NOW() +FROM ( + SELECT community_id, COUNT(*)::int AS member_count + FROM public.user_community_memberships + WHERE status IN ('member', 'admin') + GROUP BY community_id +) m +WHERE c.id = m.community_id; + +UPDATE public.communities c +SET member_count = 0, + updated_at = NOW() +WHERE NOT EXISTS ( + SELECT 1 + FROM public.user_community_memberships m + WHERE m.community_id = c.id + AND m.status IN ('member', 'admin') +); + +UPDATE public.community_events e +SET participant_count = COALESCE(r.participant_count, 0), + updated_at = NOW() +FROM ( + SELECT event_id, COUNT(*)::int AS participant_count + FROM public.event_registrations + WHERE status = 'registered' + GROUP BY event_id +) r +WHERE e.id = r.event_id; + +UPDATE public.community_events e +SET participant_count = 0, + updated_at = NOW() +WHERE NOT EXISTS ( + SELECT 1 + FROM public.event_registrations r + WHERE r.event_id = e.id + AND r.status = 'registered' +); + +COMMIT; diff --git a/legacy/swift-ios/The Trash/migrations/20260217143000_mask_friends_contact_fields.sql b/legacy/swift-ios/The Trash/migrations/20260217143000_mask_friends_contact_fields.sql new file mode 100644 index 0000000..fbcdea7 --- /dev/null +++ b/legacy/swift-ios/The Trash/migrations/20260217143000_mask_friends_contact_fields.sql @@ -0,0 +1,78 @@ +BEGIN; + +CREATE OR REPLACE FUNCTION public.find_friends_leaderboard( + p_emails text[] DEFAULT ARRAY[]::text[], + p_phones text[] DEFAULT ARRAY[]::text[] +) +RETURNS TABLE(id uuid, username text, credits integer, email text, phone text) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public', 'auth', 'pg_temp' +AS $$ +BEGIN + RETURN QUERY + WITH normalized_emails AS ( + SELECT DISTINCT lower(trim(e)) AS email + FROM unnest(COALESCE(p_emails, ARRAY[]::text[])) AS e + WHERE trim(e) <> '' + ), + normalized_phones AS ( + SELECT DISTINCT public.normalize_phone_number(raw_phone) AS phone + FROM unnest(COALESCE(p_phones, ARRAY[]::text[])) AS raw_phone + WHERE public.normalize_phone_number(raw_phone) IS NOT NULL + ), + profiles_with_auth AS ( + SELECT + p.id, + COALESCE(p.username, 'Anonymous')::text AS username, + COALESCE(p.credits, 0) AS credits, + u.email::text AS raw_email, + u.phone::text AS raw_phone, + public.normalize_phone_number(u.phone) AS normalized_phone, + regexp_replace( + COALESCE(public.normalize_phone_number(u.phone), ''), + '[^0-9]', + '', + 'g' + ) AS normalized_phone_digits + FROM public.profiles p + JOIN auth.users u ON u.id = p.id + ) + SELECT + pa.id, + pa.username, + pa.credits, + CASE + WHEN pa.raw_email IS NULL OR btrim(pa.raw_email) = '' THEN NULL + ELSE regexp_replace(lower(pa.raw_email), '(^.).*(@.*$)', '\1***\2') + END AS email, + CASE + WHEN pa.raw_phone IS NULL OR btrim(pa.raw_phone) = '' THEN NULL + WHEN pa.normalized_phone_digits IS NULL OR pa.normalized_phone_digits = '' THEN '+***' + WHEN length(pa.normalized_phone_digits) < 4 THEN '+***' + ELSE '+***' || right(pa.normalized_phone_digits, 4) + END AS phone + FROM profiles_with_auth pa + WHERE ( + EXISTS ( + SELECT 1 + FROM normalized_emails ne + WHERE ne.email = lower(pa.raw_email) + ) + OR ( + pa.normalized_phone IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM normalized_phones np + WHERE np.phone = pa.normalized_phone + ) + ) + ); +END; +$$; + +ALTER FUNCTION public.find_friends_leaderboard(text[], text[]) OWNER TO postgres; + +REVOKE EXECUTE ON FUNCTION public.find_friends_leaderboard(text[], text[]) FROM anon; + +COMMIT; diff --git a/legacy/swift-ios/The Trash/migrations/20260217150000_revoke_public_execute_sensitive_rpcs.sql b/legacy/swift-ios/The Trash/migrations/20260217150000_revoke_public_execute_sensitive_rpcs.sql new file mode 100644 index 0000000..9925b0d --- /dev/null +++ b/legacy/swift-ios/The Trash/migrations/20260217150000_revoke_public_execute_sensitive_rpcs.sql @@ -0,0 +1,28 @@ +BEGIN; + +-- Remove PUBLIC execute from sensitive mutating RPCs. +-- This closes the gap where revoking anon alone is insufficient because +-- PostgreSQL functions are executable by PUBLIC by default. +REVOKE EXECUTE ON FUNCTION public.apply_to_join_community(text, text) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION public.cancel_event_registration(uuid) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION public.check_and_grant_achievement(text) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION public.complete_arena_challenge(uuid) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION public.create_community(text, text, text, text, text, numeric, numeric) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION public.create_event(text, text, text, timestamptz, text, numeric, numeric, integer, text, text) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION public.find_friends_leaderboard(text[], text[]) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION public.grant_event_credits(uuid, uuid[], integer, text) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION public.increment_credits(integer) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION public.increment_total_scans() FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION public.join_community(text) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION public.leave_community(text) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION public.register_for_event(uuid) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION public.remove_community_member(text, uuid, text) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION public.review_join_application(uuid, boolean, text) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION public.submit_daily_challenge(integer, integer, numeric, integer) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION public.submit_duel_answer(uuid, integer, text, integer) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION public.submit_streak_record(integer) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION public.update_community_info(text, text, text, text, boolean) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION public.update_user_location(text, text, numeric, numeric) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION public.update_user_location(text, text, double precision, double precision) FROM PUBLIC; + +COMMIT; diff --git a/legacy/swift-ios/The Trash/migrations/20260217160000_fix_get_my_achievements_is_equipped.sql b/legacy/swift-ios/The Trash/migrations/20260217160000_fix_get_my_achievements_is_equipped.sql new file mode 100644 index 0000000..d55eb19 --- /dev/null +++ b/legacy/swift-ios/The Trash/migrations/20260217160000_fix_get_my_achievements_is_equipped.sql @@ -0,0 +1,51 @@ +BEGIN; + +CREATE OR REPLACE FUNCTION public.get_my_achievements() +RETURNS TABLE( + user_achievement_id uuid, + achievement_id uuid, + name text, + description text, + icon_name text, + community_id text, + community_name text, + granted_at timestamptz, + is_equipped boolean, + rarity text +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public', 'pg_temp' +AS $$ +DECLARE + v_user_id uuid; +BEGIN + v_user_id := public.current_user_id(); + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + RETURN QUERY + SELECT + ua.id AS user_achievement_id, + a.id AS achievement_id, + a.name, + a.description, + a.icon_name, + a.community_id, + c.name AS community_name, + ua.granted_at, + COALESCE((p.selected_achievement_id = a.id), false) AS is_equipped, + COALESCE(a.rarity, 'common') AS rarity + FROM public.user_achievements ua + JOIN public.achievements a ON a.id = ua.achievement_id + LEFT JOIN public.communities c ON c.id = a.community_id + LEFT JOIN public.profiles p ON p.id = ua.user_id + WHERE ua.user_id = v_user_id + ORDER BY ua.granted_at DESC; +END; +$$; + +ALTER FUNCTION public.get_my_achievements() OWNER TO postgres; + +COMMIT; diff --git a/The Trash/trash_knowledge.json b/legacy/swift-ios/The Trash/trash_knowledge.json similarity index 100% rename from The Trash/trash_knowledge.json rename to legacy/swift-ios/The Trash/trash_knowledge.json diff --git a/The-Trash-Info.plist b/legacy/swift-ios/The-Trash-Info.plist similarity index 100% rename from The-Trash-Info.plist rename to legacy/swift-ios/The-Trash-Info.plist diff --git a/scripts/check_backend_contracts.sh b/scripts/check_backend_contracts.sh index ca1eb3f..28d2862 100755 --- a/scripts/check_backend_contracts.sh +++ b/scripts/check_backend_contracts.sh @@ -4,9 +4,8 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" -SWIFT_DIR="The Trash" +RN_DIR="the-trash-rn" SUPABASE_MIGRATIONS_DIR="supabase/migrations" -APP_MIRROR_MIGRATIONS_DIR="The Trash/migrations" STRICT_MODE=0 usage() { @@ -54,35 +53,37 @@ cleanup() { } trap cleanup EXIT -extract_swift_rpcs() { - rg -o --no-filename --pcre2 'rpc\(\s*"[A-Za-z0-9_]+"' "$SWIFT_DIR" \ - | sed -E 's/.*rpc\(\s*"([A-Za-z0-9_]+)"/\1/' \ +extract_rn_rpcs() { + if [[ ! -d "$RN_DIR" ]]; then + return 0 + fi + + ( + rg -o --no-filename --pcre2 '\.rpc\(\s*["'"'"'`][A-Za-z0-9_]+' "$RN_DIR" || true + ) | sed -E 's/.*\.rpc\(\s*["'"'"'`]([A-Za-z0-9_]+).*/\1/' \ | tr 'A-Z' 'a-z' \ | sort -u } extract_sql_functions() { local source_dir="$1" - rg -o --no-filename -i --pcre2 'create\s+(?:or\s+replace\s+)?function\s+((?:"[^"]+"|[A-Za-z0-9_]+)(?:\.(?:"[^"]+"|[A-Za-z0-9_]+))?)' "$source_dir" \ - | tr 'A-Z' 'a-z' \ + ( + rg -o --no-filename -i --pcre2 'create\s+(?:or\s+replace\s+)?function\s+((?:"[^"]+"|[A-Za-z0-9_]+)(?:\.(?:"[^"]+"|[A-Za-z0-9_]+))?)' "$source_dir" || true + ) | tr 'A-Z' 'a-z' \ | sed -E 's/.*function[[:space:]]+//' \ | sed -E 's/"//g' \ | awk -F'.' '{print $NF}' \ | sort -u } -swift_rpcs_file="$(mktemp)" -register_tmp "$swift_rpcs_file" -extract_swift_rpcs > "$swift_rpcs_file" +rn_rpcs_file="$(mktemp)" +register_tmp "$rn_rpcs_file" +extract_rn_rpcs > "$rn_rpcs_file" sql_functions_supabase_file="$(mktemp)" register_tmp "$sql_functions_supabase_file" extract_sql_functions "$SUPABASE_MIGRATIONS_DIR" > "$sql_functions_supabase_file" -sql_functions_app_mirror_file="$(mktemp)" -register_tmp "$sql_functions_app_mirror_file" -extract_sql_functions "$APP_MIRROR_MIGRATIONS_DIR" > "$sql_functions_app_mirror_file" - # Helper to print sorted set difference A - B set_diff() { local left_file="$1" @@ -90,12 +91,11 @@ set_diff() { comm -23 "$left_file" "$right_file" } -echo "=== Backend Contract Check ===" +echo "=== Backend Contract Check (RN + Supabase) ===" echo -echo "Swift RPC count: $(wc -l < "$swift_rpcs_file" | tr -d ' ')" +echo "RN RPC count: $(wc -l < "$rn_rpcs_file" | tr -d ' ')" echo "Supabase migration function count: $(wc -l < "$sql_functions_supabase_file" | tr -d ' ')" -echo "App mirror migration function count: $(wc -l < "$sql_functions_app_mirror_file" | tr -d ' ')" echo drift_detected=0 @@ -117,28 +117,13 @@ print_diff() { missing_in_supabase_file="$(mktemp)" register_tmp "$missing_in_supabase_file" -set_diff "$swift_rpcs_file" "$sql_functions_supabase_file" > "$missing_in_supabase_file" || true +set_diff "$rn_rpcs_file" "$sql_functions_supabase_file" > "$missing_in_supabase_file" || true print_diff "RPCs missing in supabase/migrations" "$missing_in_supabase_file" 1 -missing_in_app_mirror_file="$(mktemp)" -register_tmp "$missing_in_app_mirror_file" -set_diff "$swift_rpcs_file" "$sql_functions_app_mirror_file" > "$missing_in_app_mirror_file" || true -print_diff "RPCs missing in app mirror migrations (The Trash/migrations)" "$missing_in_app_mirror_file" 1 - -unused_mirror_file="$(mktemp)" -register_tmp "$unused_mirror_file" -set_diff "$sql_functions_app_mirror_file" "$swift_rpcs_file" > "$unused_mirror_file" || true -print_diff "Mirror-only functions not used by current Swift RPC calls" "$unused_mirror_file" 0 - -missing_in_mirror_vs_supabase_file="$(mktemp)" -register_tmp "$missing_in_mirror_vs_supabase_file" -set_diff "$sql_functions_supabase_file" "$sql_functions_app_mirror_file" > "$missing_in_mirror_vs_supabase_file" || true -print_diff "Functions present in supabase/migrations but missing in app mirror" "$missing_in_mirror_vs_supabase_file" 1 - -missing_in_supabase_vs_mirror_file="$(mktemp)" -register_tmp "$missing_in_supabase_vs_mirror_file" -set_diff "$sql_functions_app_mirror_file" "$sql_functions_supabase_file" > "$missing_in_supabase_vs_mirror_file" || true -print_diff "Functions present in app mirror but missing in supabase/migrations" "$missing_in_supabase_vs_mirror_file" 0 +unused_supabase_functions_file="$(mktemp)" +register_tmp "$unused_supabase_functions_file" +set_diff "$sql_functions_supabase_file" "$rn_rpcs_file" > "$unused_supabase_functions_file" || true +print_diff "Functions present in supabase/migrations but unused by current RN RPC calls" "$unused_supabase_functions_file" 0 if [[ "$STRICT_MODE" -eq 1 && "$drift_detected" -eq 1 ]]; then echo "Strict mode enabled: drift detected." diff --git a/scripts/check_migration_mirror.sh b/scripts/check_migration_mirror.sh index 70c2a30..c85c2c0 100755 --- a/scripts/check_migration_mirror.sh +++ b/scripts/check_migration_mirror.sh @@ -5,7 +5,6 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" SUPABASE_MIGRATIONS_DIR="supabase/migrations" -APP_MIRROR_MIGRATIONS_DIR="The Trash/migrations" STRICT_MODE=0 usage() { @@ -13,7 +12,7 @@ usage() { Usage: scripts/check_migration_mirror.sh [--strict] Options: - --strict Exit with non-zero status if mirror drift is detected. + --strict Exit with non-zero status if migration issues are detected. -h, --help Show this help. EOF } @@ -35,7 +34,7 @@ for arg in "$@"; do esac done -for cmd in find basename sort comm cmp mktemp wc; do +for cmd in find basename sort mktemp wc cut uniq; do if ! command -v "$cmd" >/dev/null 2>&1; then echo "Missing required command: $cmd" >&2 exit 2 @@ -62,32 +61,22 @@ supabase_files="$(mktemp)" register_tmp "$supabase_files" list_sql_files "$SUPABASE_MIGRATIONS_DIR" > "$supabase_files" -mirror_files="$(mktemp)" -register_tmp "$mirror_files" -list_sql_files "$APP_MIRROR_MIGRATIONS_DIR" > "$mirror_files" - -missing_in_mirror_file="$(mktemp)" -register_tmp "$missing_in_mirror_file" -comm -23 "$supabase_files" "$mirror_files" > "$missing_in_mirror_file" - -extra_in_mirror_file="$(mktemp)" -register_tmp "$extra_in_mirror_file" -comm -13 "$supabase_files" "$mirror_files" > "$extra_in_mirror_file" - -content_mismatch_file="$(mktemp)" -register_tmp "$content_mismatch_file" +invalid_name_file="$(mktemp)" +register_tmp "$invalid_name_file" while IFS= read -r migration_name; do - supabase_path="$SUPABASE_MIGRATIONS_DIR/$migration_name" - mirror_path="$APP_MIRROR_MIGRATIONS_DIR/$migration_name" - if [[ -f "$mirror_path" ]] && ! cmp -s "$supabase_path" "$mirror_path"; then - printf "%s\n" "$migration_name" >> "$content_mismatch_file" + if [[ ! "$migration_name" =~ ^[0-9]{14}_.+\.sql$ ]]; then + printf "%s\n" "$migration_name" >> "$invalid_name_file" fi done < "$supabase_files" -echo "=== Migration Mirror Check ===" +timestamp_collision_file="$(mktemp)" +register_tmp "$timestamp_collision_file" +cut -c1-14 "$supabase_files" | sort | uniq -d > "$timestamp_collision_file" + +echo "=== Migration Source Check (supabase/migrations) ===" echo echo "supabase/migrations SQL count: $(wc -l < "$supabase_files" | tr -d ' ')" -echo "The Trash/migrations SQL count: $(wc -l < "$mirror_files" | tr -d ' ')" +echo "Mirror status: retired (supabase/migrations is sole source of truth)" echo drift_detected=0 @@ -107,15 +96,14 @@ print_diff() { echo } -print_diff "Missing in app mirror (present in supabase/migrations)" "$missing_in_mirror_file" 1 -print_diff "Extra in app mirror (absent in supabase/migrations)" "$extra_in_mirror_file" 0 -print_diff "Same-name migrations with different content" "$content_mismatch_file" 1 +print_diff "Invalid migration filename format (expected YYYYMMDDHHMMSS_name.sql)" "$invalid_name_file" 1 +print_diff "Timestamp collisions (duplicate 14-digit prefixes)" "$timestamp_collision_file" 1 if [[ "$STRICT_MODE" -eq 1 && "$drift_detected" -eq 1 ]]; then - echo "Strict mode enabled: mirror drift detected." + echo "Strict mode enabled: migration issues detected." exit 1 fi if [[ "$STRICT_MODE" -eq 1 ]]; then - echo "Strict mode enabled: mirror is in sync." + echo "Strict mode enabled: migrations look good." fi diff --git a/scripts/sync_migration_mirror.sh b/scripts/sync_migration_mirror.sh index 5c6aaa3..d922bf5 100755 --- a/scripts/sync_migration_mirror.sh +++ b/scripts/sync_migration_mirror.sh @@ -4,8 +4,6 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" -SUPABASE_MIGRATIONS_DIR="supabase/migrations" -APP_MIRROR_MIGRATIONS_DIR="The Trash/migrations" DRY_RUN=0 usage() { @@ -13,7 +11,7 @@ usage() { Usage: scripts/sync_migration_mirror.sh [--dry-run] Options: - --dry-run Print actions without copying files. + --dry-run Print deprecation notice without side effects. -h, --help Show this help. EOF } @@ -35,52 +33,10 @@ for arg in "$@"; do esac done -for cmd in find basename sort cmp cp mktemp; do - if ! command -v "$cmd" >/dev/null 2>&1; then - echo "Missing required command: $cmd" >&2 - exit 2 - fi -done - -list_sql_files() { - local source_dir="$1" - find "$source_dir" -maxdepth 1 -type f -name '*.sql' -exec basename {} \; | sort -} - -supabase_files="$(mktemp)" -trap 'rm -f "$supabase_files"' EXIT -list_sql_files "$SUPABASE_MIGRATIONS_DIR" > "$supabase_files" - -created_count=0 -updated_count=0 - -while IFS= read -r migration_name; do - src="$SUPABASE_MIGRATIONS_DIR/$migration_name" - dst="$APP_MIRROR_MIGRATIONS_DIR/$migration_name" - - if [[ ! -f "$dst" ]]; then - echo "ADD $migration_name" - created_count=$((created_count + 1)) - if [[ "$DRY_RUN" -eq 0 ]]; then - cp "$src" "$dst" - fi - continue - fi - - if ! cmp -s "$src" "$dst"; then - echo "UPDATE $migration_name" - updated_count=$((updated_count + 1)) - if [[ "$DRY_RUN" -eq 0 ]]; then - cp "$src" "$dst" - fi - fi -done < "$supabase_files" - -echo if [[ "$DRY_RUN" -eq 1 ]]; then - echo "Dry run complete." + echo "Dry run: migration mirror sync is deprecated." else - echo "Sync complete." + echo "Migration mirror sync is deprecated." fi -echo "Added: $created_count" -echo "Updated: $updated_count" +echo "No files were copied." +echo "Use supabase/migrations as the only source of truth." diff --git a/the-trash-rn/README.md b/the-trash-rn/README.md index c992d28..5e1c63c 100644 --- a/the-trash-rn/README.md +++ b/the-trash-rn/README.md @@ -59,7 +59,7 @@ pnpm expo start --dev-client --tunnel --clear supabase db push --project-ref ``` -迁移规范:`supabase/migrations/` 与 `The Trash/migrations/` 需要同步提交。 +迁移规范:仅提交 `supabase/migrations/`,该目录是唯一真相源。 ## 常见问题 @@ -79,11 +79,26 @@ supabase db push --project-ref - 先执行迁移并确保 `communities` 有数据;客户端也会回退到内置城市列表。 +5. 好友榜同步通讯录会上传什么 + +- 仅上传去重后的邮箱和手机号,不上传联系人姓名。 +- 默认需要你在好友榜里显式同意后才会同步。 +- 单次同步最多上传 300 个邮箱 + 300 个手机号。 + +## 自动化测试 + +```bash +pnpm test +``` + +当前已覆盖:错误模型、认证状态管理、竞技场 store 拆分后的关键路径、好友榜通讯录最小化同步。 + ## 常用命令 ```bash pnpm lint pnpm format +pnpm test pnpm expo run:ios --device pnpm expo start --dev-client --tunnel --clear ``` diff --git a/the-trash-rn/app/(modals)/account-settings.js b/the-trash-rn/app/(modals)/account-settings.js index bb8bf48..134ec2b 100644 --- a/the-trash-rn/app/(modals)/account-settings.js +++ b/the-trash-rn/app/(modals)/account-settings.js @@ -1,5 +1,6 @@ import { router } from 'expo-router'; import { Pressable, ScrollView, Text } from 'react-native'; + import ModalSheet from 'src/components/layout/ModalSheet'; import { useAuthStore } from 'src/stores/authStore'; @@ -28,8 +29,13 @@ export default function AccountSettingsModal() { {item.title} ))} - - 退出登录 + + + 退出登录 + diff --git a/the-trash-rn/app/(modals)/admin/[communityId].js b/the-trash-rn/app/(modals)/admin/[communityId].js index 2ce5516..3b6dd0b 100644 --- a/the-trash-rn/app/(modals)/admin/[communityId].js +++ b/the-trash-rn/app/(modals)/admin/[communityId].js @@ -1,8 +1,13 @@ import { useLocalSearchParams } from 'expo-router'; import { useEffect, useState } from 'react'; import { Alert, ScrollView, Text, View } from 'react-native'; + import ModalSheet from 'src/components/layout/ModalSheet'; -import { TrashButton, TrashInput, TrashSegmentedControl } from 'src/components/themed'; +import { + TrashButton, + TrashInput, + TrashSegmentedControl +} from 'src/components/themed'; import { useCommunityStore } from 'src/stores/communityStore'; const TABS = [ @@ -13,8 +18,12 @@ const TABS = [ export default function AdminPanelModal() { const { communityId } = useLocalSearchParams(); - const dashboard = useCommunityStore((state) => state.adminDashboard(communityId)); - const loadAdminDashboard = useCommunityStore((state) => state.loadAdminDashboard); + const dashboard = useCommunityStore((state) => + state.adminDashboard(communityId) + ); + const loadAdminDashboard = useCommunityStore( + (state) => state.loadAdminDashboard + ); const processRequest = useCommunityStore((state) => state.processRequest); const grantCredits = useCommunityStore((state) => state.grantCredits); const removeMember = useCommunityStore((state) => state.removeMember); @@ -49,26 +58,48 @@ export default function AdminPanelModal() { return ( - + {tab === 'requests' && ( {dashboard.requests.length === 0 ? ( 暂无待审批申请 ) : ( dashboard.requests.map((request) => ( - - {request.name} - {request.message} + + + {request.name} + + + {request.message} + processRequest({ communityId, requestId: request.id, approve: true })} + onPress={() => + processRequest({ + communityId, + requestId: request.id, + approve: true + }) + } style={{ flex: 1 }} /> processRequest({ communityId, requestId: request.id, approve: false })} + onPress={() => + processRequest({ + communityId, + requestId: request.id, + approve: false + }) + } style={{ flex: 1 }} /> @@ -81,18 +112,30 @@ export default function AdminPanelModal() { {tab === 'members' && ( {dashboard.members.map((member) => ( - + - {member.name} + + {member.name} + {member.role} - 积分 {member.points ?? 0} + + 积分 {member.points ?? 0} + { try { - await grantCredits({ communityId, memberId: member.id, amount: 10, reason: '贡献' }); + await grantCredits({ + communityId, + memberId: member.id, + amount: 10, + reason: '贡献' + }); } catch (error) { Alert.alert('操作失败', error.message ?? '暂不可用'); } @@ -102,15 +145,24 @@ export default function AdminPanelModal() { removeMember({ communityId, memberId: member.id })} + onPress={() => + removeMember({ communityId, memberId: member.id }) + } style={{ flex: 1 }} /> ))} - 手动发放积分 - + + 手动发放积分 + + - + @@ -128,7 +185,9 @@ export default function AdminPanelModal() { {dashboard.logs.map((log) => ( - {log.message} + + {log.message} + {log.timestamp} ))} diff --git a/the-trash-rn/app/(modals)/badges.js b/the-trash-rn/app/(modals)/badges.js index f209ebb..fecb70c 100644 --- a/the-trash-rn/app/(modals)/badges.js +++ b/the-trash-rn/app/(modals)/badges.js @@ -1,5 +1,6 @@ import { useEffect } from 'react'; import { FlatList, Pressable, Text, View } from 'react-native'; + import ModalSheet from 'src/components/layout/ModalSheet'; import { useAchievementStore } from 'src/stores/achievementStore'; @@ -31,7 +32,9 @@ export default function BadgesModal() { style={{ opacity: item.unlocked ? 1 : 0.4 }} > - {item.icon ?? '✨'} + + {item.icon ?? '✨'} + {item.title} diff --git a/the-trash-rn/app/(modals)/bind-email.js b/the-trash-rn/app/(modals)/bind-email.js index 2ebefaa..4f2eaa3 100644 --- a/the-trash-rn/app/(modals)/bind-email.js +++ b/the-trash-rn/app/(modals)/bind-email.js @@ -1,10 +1,13 @@ import { useState } from 'react'; import { Text } from 'react-native'; + import ModalSheet from 'src/components/layout/ModalSheet'; import { TrashButton, TrashInput } from 'src/components/themed'; import { accountService } from 'src/services/account'; +import { useAuthStore } from 'src/stores/authStore'; export default function BindEmailModal() { + const refreshSession = useAuthStore((state) => state.refreshSession); const [email, setEmail] = useState(''); const [code, setCode] = useState(''); const [sending, setSending] = useState(false); @@ -33,6 +36,7 @@ export default function BindEmailModal() { setBinding(true); try { await accountService.bindEmail({ email, code }); + await refreshSession(); setStatus('绑定成功'); } catch (error) { setStatus(error.message); @@ -57,8 +61,14 @@ export default function BindEmailModal() { placeholder="123456" keyboardType="number-pad" /> - {status ? {status} : null} - + {status ? ( + {status} + ) : null} + state.refreshSession); const [phone, setPhone] = useState(''); const [code, setCode] = useState(''); const [sending, setSending] = useState(false); @@ -33,6 +36,7 @@ export default function BindPhoneModal() { setBinding(true); try { await accountService.bindPhone({ phone, code }); + await refreshSession(); setStatus('绑定成功'); } catch (error) { setStatus(error.message); @@ -57,8 +61,14 @@ export default function BindPhoneModal() { placeholder="123456" keyboardType="number-pad" /> - {status ? {status} : null} - + {status ? ( + {status} + ) : null} + state.pendingChallenges[challengeId]); + const challenge = useArenaStore( + (state) => state.pendingChallenges[challengeId] + ); const refreshChallenges = useArenaStore((state) => state.refreshChallenges); const acceptChallenge = useArenaStore((state) => state.acceptChallenge); const [accepting, setAccepting] = useState(false); @@ -34,7 +37,8 @@ export default function ChallengeAcceptModal() { - {challenge?.opponentName ?? challenge?.opponent ?? '有好友'} 向你发起 {challenge?.mode ?? 'duel'} 对战。 + {challenge?.opponentName ?? challenge?.opponent ?? '有好友'} 向你发起{' '} + {challenge?.mode ?? 'duel'} 对战。 {item.name} - 最近分数 {item.score ?? 0} + + 最近分数 {item.score ?? 0} + sendInvite(item.id)} diff --git a/the-trash-rn/app/(modals)/challenge-list.js b/the-trash-rn/app/(modals)/challenge-list.js index f368614..0e57c1c 100644 --- a/the-trash-rn/app/(modals)/challenge-list.js +++ b/the-trash-rn/app/(modals)/challenge-list.js @@ -1,5 +1,6 @@ import { useEffect } from 'react'; import { FlatList, Text, View } from 'react-native'; + import ModalSheet from 'src/components/layout/ModalSheet'; import { useArenaStore } from 'src/stores/arenaStore'; diff --git a/the-trash-rn/app/(modals)/change-password.js b/the-trash-rn/app/(modals)/change-password.js index a8142f0..d4a7694 100644 --- a/the-trash-rn/app/(modals)/change-password.js +++ b/the-trash-rn/app/(modals)/change-password.js @@ -1,5 +1,6 @@ import { useState } from 'react'; import { Text } from 'react-native'; + import ModalSheet from 'src/components/layout/ModalSheet'; import { TrashButton, TrashInput } from 'src/components/themed'; import { accountService } from 'src/services/account'; @@ -45,8 +46,14 @@ export default function ChangePasswordModal() { placeholder="再次输入" secureTextEntry /> - {status ? {status} : null} - + {status ? ( + {status} + ) : null} + ); } diff --git a/the-trash-rn/app/(modals)/community/[id].js b/the-trash-rn/app/(modals)/community/[id].js index 08cb503..ef446fc 100644 --- a/the-trash-rn/app/(modals)/community/[id].js +++ b/the-trash-rn/app/(modals)/community/[id].js @@ -1,6 +1,7 @@ import { useLocalSearchParams } from 'expo-router'; import { useEffect, useState } from 'react'; import { Pressable, ScrollView, Text } from 'react-native'; + import ModalSheet from 'src/components/layout/ModalSheet'; import { useCommunityStore } from 'src/stores/communityStore'; import { useLocationStore } from 'src/stores/locationStore'; @@ -36,8 +37,12 @@ export default function CommunityDetailModal() { return ( - {cityName ?? '未知城市'} - {community?.description} + + {cityName ?? '未知城市'} + + + {community?.description} + 成员 {community?.memberCount ?? '--'} 人 diff --git a/the-trash-rn/app/(modals)/create-community.js b/the-trash-rn/app/(modals)/create-community.js index 3c40909..2cf61d8 100644 --- a/the-trash-rn/app/(modals)/create-community.js +++ b/the-trash-rn/app/(modals)/create-community.js @@ -1,6 +1,7 @@ import { useRouter } from 'expo-router'; import { useState } from 'react'; import { Alert, ScrollView, Text } from 'react-native'; + import ModalSheet from 'src/components/layout/ModalSheet'; import { TrashButton, TrashInput } from 'src/components/themed'; import { useCommunityStore } from 'src/stores/communityStore'; @@ -29,6 +30,7 @@ export default function CreateCommunityModal() { name: name.trim(), description: description.trim(), cityId: currentCity.id, + city: currentCity.city ?? currentCity.name ?? currentCity.id, state: currentCity.state, latitude: currentCity.latitude, longitude: currentCity.longitude @@ -44,8 +46,15 @@ export default function CreateCommunityModal() { return ( - 城市 · {currentCity?.name ?? '未选择'} - + + 城市 · {currentCity?.name ?? '未选择'} + + - + ); diff --git a/the-trash-rn/app/(modals)/create-event.js b/the-trash-rn/app/(modals)/create-event.js index 77524ea..8faeeaa 100644 --- a/the-trash-rn/app/(modals)/create-event.js +++ b/the-trash-rn/app/(modals)/create-event.js @@ -1,6 +1,7 @@ import { useRouter } from 'expo-router'; import { useState } from 'react'; import { Alert, ScrollView, Text } from 'react-native'; + import ModalSheet from 'src/components/layout/ModalSheet'; import { TrashButton, TrashInput } from 'src/components/themed'; import { useCommunityStore } from 'src/stores/communityStore'; @@ -35,6 +36,7 @@ export default function CreateEventModal() { startTime, quota: Number(quota) || 50, cityId: currentCity.id, + city: currentCity.city ?? currentCity.name ?? currentCity.id, latitude: currentCity.latitude, longitude: currentCity.longitude }); @@ -49,15 +51,27 @@ export default function CreateEventModal() { return ( - 城市 · {currentCity?.name ?? '未选择'} - + + 城市 · {currentCity?.name ?? '未选择'} + + - + - + ); diff --git a/the-trash-rn/app/(modals)/daily-leaderboard.js b/the-trash-rn/app/(modals)/daily-leaderboard.js index b7c16d5..c3b48fe 100644 --- a/the-trash-rn/app/(modals)/daily-leaderboard.js +++ b/the-trash-rn/app/(modals)/daily-leaderboard.js @@ -1,5 +1,6 @@ import { useEffect } from 'react'; import { FlatList, Text, View } from 'react-native'; + import ModalSheet from 'src/components/layout/ModalSheet'; import { useArenaStore } from 'src/stores/arenaStore'; diff --git a/the-trash-rn/app/(modals)/event/[id].js b/the-trash-rn/app/(modals)/event/[id].js index 574674e..81d1f72 100644 --- a/the-trash-rn/app/(modals)/event/[id].js +++ b/the-trash-rn/app/(modals)/event/[id].js @@ -1,6 +1,7 @@ import { useLocalSearchParams } from 'expo-router'; import { useEffect, useState } from 'react'; import { Pressable, ScrollView, Text } from 'react-native'; + import ModalSheet from 'src/components/layout/ModalSheet'; import { useCommunityStore } from 'src/stores/communityStore'; @@ -14,7 +15,7 @@ const formatTime = (isoString) => { minute: '2-digit', hour12: false }); - } catch (error) { + } catch (_error) { return isoString; } }; @@ -59,7 +60,9 @@ export default function EventDetailModal() { disabled={rsvping} className="bg-brand-neon rounded-3xl py-3 items-center" > - {rsvping ? '报名中…' : '我要参加'} + + {rsvping ? '报名中…' : '我要参加'} + diff --git a/the-trash-rn/app/(modals)/history.js b/the-trash-rn/app/(modals)/history.js index e72c2af..93b57c7 100644 --- a/the-trash-rn/app/(modals)/history.js +++ b/the-trash-rn/app/(modals)/history.js @@ -1,4 +1,5 @@ import { FlatList, Text, View } from 'react-native'; + import ModalSheet from 'src/components/layout/ModalSheet'; import { useAchievementStore } from 'src/stores/achievementStore'; import { useTrashStore } from 'src/stores/trashStore'; @@ -33,9 +34,13 @@ export default function HistoryModal() { ) : ( achievementHistory.slice(0, 5).map((entry) => ( - {entry.title} + + {entry.title} + {entry.description ? ( - {entry.description} + + {entry.description} + ) : null} {entry.timestamp} diff --git a/the-trash-rn/app/(modals)/rewards.js b/the-trash-rn/app/(modals)/rewards.js index 81925c0..244b1c8 100644 --- a/the-trash-rn/app/(modals)/rewards.js +++ b/the-trash-rn/app/(modals)/rewards.js @@ -1,5 +1,6 @@ import { useEffect } from 'react'; import { FlatList, Pressable, Text, View } from 'react-native'; + import ModalSheet from 'src/components/layout/ModalSheet'; import { useAchievementStore } from 'src/stores/achievementStore'; @@ -20,20 +21,26 @@ export default function RewardsModal() { data={rewards} keyExtractor={(item) => item.id} ListEmptyComponent={() => ( - 当前版本暂未开放积分兑换。 + + 当前版本暂未开放积分兑换。 + )} renderItem={({ item }) => { const disabled = item.redeemed || item.points > points; return ( {item.title} - {item.points} 分 + + {item.points} 分 + redeem(item.id)} disabled={disabled} className="rounded-2xl py-2 items-center" style={{ - backgroundColor: disabled ? 'rgba(255,255,255,0.1)' : '#32f5ff' + backgroundColor: disabled + ? 'rgba(255,255,255,0.1)' + : '#32f5ff' }} > diff --git a/the-trash-rn/app/(modals)/streak-leaderboard.js b/the-trash-rn/app/(modals)/streak-leaderboard.js index 3ceeed2..3fa25ed 100644 --- a/the-trash-rn/app/(modals)/streak-leaderboard.js +++ b/the-trash-rn/app/(modals)/streak-leaderboard.js @@ -1,5 +1,6 @@ import { useEffect } from 'react'; import { FlatList, Text, View } from 'react-native'; + import ModalSheet from 'src/components/layout/ModalSheet'; import { useArenaStore } from 'src/stores/arenaStore'; @@ -23,7 +24,9 @@ export default function StreakLeaderboardModal() { {item.name} {item.community} - {item.streak} + + {item.streak} + )} /> diff --git a/the-trash-rn/app/(modals)/upgrade-guest.js b/the-trash-rn/app/(modals)/upgrade-guest.js index 0bd402f..5bd167e 100644 --- a/the-trash-rn/app/(modals)/upgrade-guest.js +++ b/the-trash-rn/app/(modals)/upgrade-guest.js @@ -1,10 +1,13 @@ import { useState } from 'react'; import { Text } from 'react-native'; + import ModalSheet from 'src/components/layout/ModalSheet'; import { TrashButton, TrashInput } from 'src/components/themed'; import { accountService } from 'src/services/account'; +import { useAuthStore } from 'src/stores/authStore'; export default function UpgradeGuestModal() { + const refreshSession = useAuthStore((state) => state.refreshSession); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [status, setStatus] = useState(''); @@ -15,7 +18,10 @@ export default function UpgradeGuestModal() { setLoading(true); try { await accountService.upgradeGuest({ email, password }); - setStatus('账号创建成功,请查收验证邮件'); + const session = await refreshSession(); + setStatus( + session ? '升级成功,已完成登录' : '账号创建成功,请查收验证邮件' + ); setEmail(''); setPassword(''); } catch (error) { @@ -28,9 +34,15 @@ export default function UpgradeGuestModal() { return ( - 游客进度只保存在本地。升级后可同步到 Supabase,并开启对战、排行榜等功能。 + 游客进度只保存在本地。升级后可同步到 + Supabase,并开启对战、排行榜等功能。 - + - {status ? {status} : null} - + {status ? ( + {status} + ) : null} + ); } diff --git a/the-trash-rn/app/(tabs)/_layout.js b/the-trash-rn/app/(tabs)/_layout.js index 3d6de38..f91c7f3 100644 --- a/the-trash-rn/app/(tabs)/_layout.js +++ b/the-trash-rn/app/(tabs)/_layout.js @@ -1,6 +1,7 @@ import { Tabs } from 'expo-router'; import { useMemo } from 'react'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; + import TabBarIcon from 'src/components/navigation/TabBarIcon'; import { useThemeStore } from 'src/stores/themeStore'; diff --git a/the-trash-rn/app/(tabs)/arena/classic.js b/the-trash-rn/app/(tabs)/arena/classic.js index e409f53..28c1a39 100644 --- a/the-trash-rn/app/(tabs)/arena/classic.js +++ b/the-trash-rn/app/(tabs)/arena/classic.js @@ -1,5 +1,6 @@ import { useEffect } from 'react'; import { Text, View } from 'react-native'; + import QuizCard from 'src/components/arena/QuizCard'; import ScreenShell from 'src/components/layout/ScreenShell'; import { TrashButton } from 'src/components/themed'; @@ -28,7 +29,9 @@ export default function ClassicArenaScreen() { 第 {classic.questionIndex ?? 0} 题 {classic.lastAnswerCorrect != null ? ( - + {classic.lastAnswerCorrect ? '答对 +10' : '答错 0 分'} ) : null} @@ -36,8 +39,12 @@ export default function ClassicArenaScreen() { {classic.state === 'finished' ? ( - 本轮已结束 - 最终得分 {classic.score} + + 本轮已结束 + + + 最终得分 {classic.score} + ) : ( state.dailyChallenge); const loadDailyChallenge = useArenaStore((state) => state.loadDailyChallenge); - const incrementDailyChallenge = useArenaStore((state) => state.incrementDailyChallenge); + const incrementDailyChallenge = useArenaStore( + (state) => state.incrementDailyChallenge + ); useEffect(() => { loadDailyChallenge(); @@ -21,11 +24,16 @@ export default function DailyChallengeScreen() { 今日任务 - {dailyChallenge.prompt} + + {dailyChallenge.prompt} + - 进度 {dailyChallenge.progress}/{dailyChallenge.total} · {progressPercent}% + 进度 {dailyChallenge.progress}/{dailyChallenge.total} ·{' '} + {progressPercent}% + + + 奖励 {dailyChallenge.reward ?? '待公布'} - 奖励 {dailyChallenge.reward ?? '待公布'} 得分 - {speed.remaining}s + + {speed.remaining}s + 剩余时间 - + {speed.state === 'idle' ? ( ) : speed.state === 'finished' ? ( - 时间到!本轮得分 {speed.score} + + 时间到!本轮得分 {speed.score} + ) : ( diff --git a/the-trash-rn/app/(tabs)/arena/streak.js b/the-trash-rn/app/(tabs)/arena/streak.js index c2c90c9..6f3434e 100644 --- a/the-trash-rn/app/(tabs)/arena/streak.js +++ b/the-trash-rn/app/(tabs)/arena/streak.js @@ -1,4 +1,5 @@ import { Text, View } from 'react-native'; + import QuizCard from 'src/components/arena/QuizCard'; import ScreenShell from 'src/components/layout/ScreenShell'; import { TrashButton } from 'src/components/themed'; @@ -14,7 +15,9 @@ export default function StreakModeScreen() { 当前连胜 {streak.current} - 最佳纪录 {streak.best} + + 最佳纪录 {streak.best} + {streak.state === 'playing' && streak.question ? ( { const city = cities.find( - (cityItem) => cityItem.id === item.cityId + (cityItem) => + cityItem.id === item.cityId || + cityItem.city === item.cityId || + cityItem.name === item.cityId ); return ( state.entries); const load = useLeaderboardStore((state) => state.load); const loading = useLeaderboardStore((state) => state.loading); + const error = useLeaderboardStore((state) => state.error); const syncingContacts = useLeaderboardStore((state) => state.syncingContacts); const syncContacts = useLeaderboardStore((state) => state.syncContacts); + const contactsSyncOptIn = useLeaderboardStore( + (state) => state.contactsSyncOptIn + ); + const setContactsSyncOptIn = useLeaderboardStore( + (state) => state.setContactsSyncOptIn + ); + const contactsLastSyncedAt = useLeaderboardStore( + (state) => state.contactsLastSyncedAt + ); + const contactsLastSyncStats = useLeaderboardStore( + (state) => state.contactsLastSyncStats + ); const myRanking = useLeaderboardStore((state) => state.myRanking); const myCommunities = useLeaderboardStore((state) => state.myCommunities); const selectedCommunityId = useLeaderboardStore( @@ -54,6 +69,28 @@ export default function LeaderboardScreen() { [myCommunities, selectedCommunityId] ); + const handleContactsSync = () => { + if (contactsSyncOptIn) { + syncContacts(); + return; + } + + Alert.alert( + '启用好友榜同步', + `将读取通讯录中的邮箱和手机号做匹配,不上传姓名。每次最多上传 ${leaderboardPrivacy.maxEmailsPerSync} 个邮箱和 ${leaderboardPrivacy.maxPhonesPerSync} 个手机号。`, + [ + { text: '取消', style: 'cancel' }, + { + text: '同意并同步', + onPress: async () => { + setContactsSyncOptIn(true); + await syncContacts({ allowPermissionPrompt: true }); + } + } + ] + ); + }; + return ( @@ -64,14 +101,80 @@ export default function LeaderboardScreen() { style={{ marginBottom: 0, marginRight: 12, flex: 1 }} /> {filter === 'friends' ? ( - + - {syncingContacts ? '同步中…' : '同步通讯录'} + {syncingContacts + ? '同步中…' + : contactsSyncOptIn + ? '重新同步' + : '启用通讯录好友榜'} ) : null} + {filter === 'friends' && !contactsSyncOptIn ? ( + + + 好友榜需要通讯录授权 + + + 仅上传去重后的邮箱和手机号,不上传联系人姓名。你可随时关闭授权。 + + + + ) : null} + + {filter === 'friends' && contactsSyncOptIn && contactsLastSyncedAt ? ( + + + 上次同步:{new Date(contactsLastSyncedAt).toLocaleString()} + + {contactsLastSyncStats ? ( + + 上传标识:邮箱 {contactsLastSyncStats.emailCount} / 手机号{' '} + {contactsLastSyncStats.phoneCount} + + ) : null} + + ) : null} + {filter === 'community' ? ( ) : null} + {error ? ( + + + {error} + + + ) : null} + item.id} @@ -158,7 +281,9 @@ export default function LeaderboardScreen() { {filter === 'community' ? '该社群暂无可显示的排名数据。' - : '暂无好友排名,请先同步通讯录。'} + : contactsSyncOptIn + ? '暂无好友排名,试试重新同步通讯录。' + : '请先开启通讯录好友榜。'} )} diff --git a/the-trash-rn/app/_layout.js b/the-trash-rn/app/_layout.js index e8d17d8..1bf62be 100644 --- a/the-trash-rn/app/_layout.js +++ b/the-trash-rn/app/_layout.js @@ -1,5 +1,6 @@ import '../global.css'; import { Stack } from 'expo-router'; + import AppProviders from 'src/providers/AppProviders'; export const unstable_settings = { diff --git a/the-trash-rn/app/challenge/[id].js b/the-trash-rn/app/challenge/[id].js index 4f5b74f..9986553 100644 --- a/the-trash-rn/app/challenge/[id].js +++ b/the-trash-rn/app/challenge/[id].js @@ -1,5 +1,6 @@ import { useLocalSearchParams, useRouter } from 'expo-router'; import { useEffect } from 'react'; + import FullScreenLoader from 'src/components/shared/FullScreenLoader'; import { useArenaStore } from 'src/stores/arenaStore'; diff --git a/the-trash-rn/app/index.js b/the-trash-rn/app/index.js index 2f39107..3cad181 100644 --- a/the-trash-rn/app/index.js +++ b/the-trash-rn/app/index.js @@ -48,6 +48,7 @@ export default function Index() { const headerSinkOffset = Math.max(0, Math.min(64, (height - 780) * 0.24)); const status = useAuthStore((state) => state.status); + const profile = useAuthStore((state) => state.profile); const bootstrap = useAuthStore((state) => state.bootstrap); const authenticating = useAuthStore((state) => state.authenticating); const globalError = useAuthStore((state) => state.error); @@ -126,7 +127,10 @@ export default function Index() { return ; } - if (status === 'authenticated') { + if ( + status === 'authenticated' || + (status === 'guest' && profile?.id === 'guest') + ) { return ; } diff --git a/the-trash-rn/jest.config.cjs b/the-trash-rn/jest.config.cjs new file mode 100644 index 0000000..71a5188 --- /dev/null +++ b/the-trash-rn/jest.config.cjs @@ -0,0 +1,12 @@ +module.exports = { + testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/__tests__/**/*.test.js'], + moduleNameMapper: { + '^src/(.*)$': '/src/$1' + }, + transform: { + '^.+\\.[jt]sx?$': 'babel-jest' + }, + setupFilesAfterEnv: ['/jest.setup.js'] +}; diff --git a/the-trash-rn/jest.setup.js b/the-trash-rn/jest.setup.js new file mode 100644 index 0000000..2a6bbff --- /dev/null +++ b/the-trash-rn/jest.setup.js @@ -0,0 +1,4 @@ +process.env.EXPO_PUBLIC_SUPABASE_URL = + process.env.EXPO_PUBLIC_SUPABASE_URL ?? 'https://example.supabase.co'; +process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY = + process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY ?? 'anon-key'; diff --git a/the-trash-rn/package.json b/the-trash-rn/package.json index 87418eb..8d4d482 100644 --- a/the-trash-rn/package.json +++ b/the-trash-rn/package.json @@ -10,7 +10,8 @@ "pods:install": "bash ./scripts/pod-install-safe.sh", "postinstall": "sh ./scripts/patch-rn-xcode-space-path.sh", "lint": "eslint .", - "format": "prettier --write ." + "format": "prettier --write .", + "test": "jest --runInBand" }, "dependencies": { "@babel/runtime": "^7.28.6", @@ -52,10 +53,13 @@ "devDependencies": { "@babel/core": "^7.24.5", "@expo/ngrok": "^4.1.3", + "@jest/globals": "^30.2.0", + "babel-jest": "^30.2.0", "babel-plugin-module-resolver": "^5.0.0", "babel-preset-expo": "~11.0.0", "eslint": "^8.57.1", "eslint-config-universe": "^12.1.0", + "jest": "^30.2.0", "prettier": "^3.2.5", "tailwindcss": "^3.4.4" } diff --git a/the-trash-rn/plugins/withReactNativeContacts.js b/the-trash-rn/plugins/withReactNativeContacts.js index 97695e5..16757e3 100644 --- a/the-trash-rn/plugins/withReactNativeContacts.js +++ b/the-trash-rn/plugins/withReactNativeContacts.js @@ -22,7 +22,10 @@ module.exports = function withReactNativeContacts(config, options = {}) { config = withAndroidManifest(config, (config) => { ensureUsesPermission(config.modResults, 'android.permission.READ_CONTACTS'); - ensureUsesPermission(config.modResults, 'android.permission.WRITE_CONTACTS'); + ensureUsesPermission( + config.modResults, + 'android.permission.WRITE_CONTACTS' + ); return config; }); diff --git a/the-trash-rn/pnpm-lock.yaml b/the-trash-rn/pnpm-lock.yaml index f1e3470..fedb800 100644 --- a/the-trash-rn/pnpm-lock.yaml +++ b/the-trash-rn/pnpm-lock.yaml @@ -120,6 +120,12 @@ importers: '@expo/ngrok': specifier: ^4.1.3 version: 4.1.3 + '@jest/globals': + specifier: ^30.2.0 + version: 30.2.0 + babel-jest: + specifier: ^30.2.0 + version: 30.2.0(@babel/core@7.29.0) babel-plugin-module-resolver: specifier: ^5.0.0 version: 5.0.2 @@ -132,6 +138,9 @@ importers: eslint-config-universe: specifier: ^12.1.0 version: 12.1.0(eslint@8.57.1)(prettier@3.8.1)(typescript@5.9.3) + jest: + specifier: ^30.2.0 + version: 30.2.0(@types/node@25.2.3) prettier: specifier: ^3.2.5 version: 3.8.1 @@ -376,6 +385,22 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-bigint@7.8.3': + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-properties@7.12.13': + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-static-block@7.14.5': + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-decorators@7.28.6': resolution: {integrity: sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==} engines: {node: '>=6.9.0'} @@ -411,6 +436,16 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-json-strings@7.8.3': + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-jsx@7.28.6': resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} engines: {node: '>=6.9.0'} @@ -447,6 +482,18 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-private-property-in-object@7.14.5': + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-top-level-await@7.14.5': + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-typescript@7.28.6': resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} engines: {node: '>=6.9.0'} @@ -870,10 +917,22 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@egjs/hammerjs@2.0.17': resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} engines: {node: '>=0.8.0'} + '@emnapi/core@1.8.1': + resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} + + '@emnapi/runtime@1.8.1': + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} + + '@emnapi/wasi-threads@1.1.0': + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1073,22 +1132,108 @@ packages: resolution: {integrity: sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==} engines: {node: '>=12'} + '@istanbuljs/load-nyc-config@1.1.0': + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + + '@jest/console@30.2.0': + resolution: {integrity: sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/core@30.2.0': + resolution: {integrity: sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + '@jest/create-cache-key-function@29.7.0': resolution: {integrity: sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/diff-sequences@30.0.1': + resolution: {integrity: sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/environment@29.7.0': resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/environment@30.2.0': + resolution: {integrity: sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/expect-utils@30.2.0': + resolution: {integrity: sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/expect@30.2.0': + resolution: {integrity: sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/fake-timers@29.7.0': resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/fake-timers@30.2.0': + resolution: {integrity: sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/get-type@30.1.0': + resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/globals@30.2.0': + resolution: {integrity: sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/pattern@30.0.1': + resolution: {integrity: sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/reporters@30.2.0': + resolution: {integrity: sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + '@jest/schemas@29.6.3': resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/schemas@30.0.5': + resolution: {integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/snapshot-utils@30.2.0': + resolution: {integrity: sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/source-map@30.0.1': + resolution: {integrity: sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/test-result@30.2.0': + resolution: {integrity: sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/test-sequencer@30.2.0': + resolution: {integrity: sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/transform@30.2.0': + resolution: {integrity: sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/types@24.9.0': resolution: {integrity: sha512-XKK7ze1apu5JWQ5eZjHITP66AX+QsLlbaJRBGYr8pNzwcAE2JVkwnf0yqjHTsDRcjR0mujy/NmZMXw5kl+kGBw==} engines: {node: '>= 6'} @@ -1101,6 +1246,10 @@ packages: resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/types@30.2.0': + resolution: {integrity: sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1120,6 +1269,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1386,6 +1538,9 @@ packages: '@sinclair/typebox@0.27.10': resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} + '@sinclair/typebox@0.34.48': + resolution: {integrity: sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==} + '@sindresorhus/is@4.6.0': resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} @@ -1396,6 +1551,9 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@sinonjs/fake-timers@13.0.5': + resolution: {integrity: sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==} + '@supabase/auth-js@2.95.3': resolution: {integrity: sha512-vD2YoS8E2iKIX0F7EwXTmqhUpaNsmbU6X2R0/NdFcs02oEfnHyNP/3M716f3wVJ2E5XHGiTFXki6lRckhJ0Thg==} engines: {node: '>=20.0.0'} @@ -1424,6 +1582,21 @@ packages: resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} @@ -1557,6 +1730,109 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} + cpu: [arm] + os: [android] + + '@unrs/resolver-binding-android-arm64@1.11.1': + resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==} + cpu: [arm64] + os: [android] + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==} + cpu: [arm64] + os: [darwin] + + '@unrs/resolver-binding-darwin-x64@1.11.1': + resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==} + cpu: [x64] + os: [darwin] + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==} + cpu: [x64] + os: [freebsd] + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==} + cpu: [arm64] + os: [win32] + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==} + cpu: [ia32] + os: [win32] + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==} + cpu: [x64] + os: [win32] + '@urql/core@2.3.6': resolution: {integrity: sha512-PUxhtBh7/8167HJK6WqBv6Z0piuiaZHQGYbhwpNL9aIQmLROPEdaUYkY4wh45wPQXcTpnd11l0q3Pw+TI11pdw==} peerDependencies: @@ -1760,6 +2036,20 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + babel-jest@30.2.0: + resolution: {integrity: sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@babel/core': ^7.11.0 || ^8.0.0-0 + + babel-plugin-istanbul@7.0.1: + resolution: {integrity: sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==} + engines: {node: '>=12'} + + babel-plugin-jest-hoist@30.2.0: + resolution: {integrity: sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + babel-plugin-module-resolver@5.0.2: resolution: {integrity: sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg==} @@ -1798,9 +2088,20 @@ packages: babel-plugin-transform-flow-enums@0.0.2: resolution: {integrity: sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==} + babel-preset-current-node-syntax@1.2.0: + resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} + peerDependencies: + '@babel/core': ^7.0.0 || ^8.0.0-0 + babel-preset-expo@11.0.15: resolution: {integrity: sha512-rgiMTYwqIPULaO7iZdqyL7aAff9QLOX6OWUtLZBlOrOTreGY1yHah/5+l8MvI6NVc/8Zj5LY4Y5uMSnJIuzTLw==} + babel-preset-jest@30.2.0: + resolution: {integrity: sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@babel/core': ^7.11.0 || ^8.0.0-beta.1 + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1949,6 +2250,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + charenc@0.0.2: resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==} @@ -1972,6 +2277,13 @@ packages: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + clean-stack@2.2.0: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'} @@ -2010,6 +2322,13 @@ packages: resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} engines: {node: '>=0.8'} + co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + + collect-v8-coverage@1.0.3: + resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} + color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} @@ -2197,6 +2516,14 @@ packages: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} + dedent@1.7.1: + resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -2255,6 +2582,10 @@ packages: engines: {node: '>=0.10'} hasBin: true + detect-newline@3.1.0: + resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} + engines: {node: '>=8'} + didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} @@ -2307,6 +2638,10 @@ packages: electron-to-chromium@1.5.286: resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} + emittery@0.13.1: + resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} + engines: {node: '>=12'} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -2551,6 +2886,14 @@ packages: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} + exit-x@0.2.2: + resolution: {integrity: sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==} + engines: {node: '>= 0.8.0'} + + expect@30.2.0: + resolution: {integrity: sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + expo-asset@10.0.10: resolution: {integrity: sha512-0qoTIihB79k+wGus9wy0JMKq7DdenziVx3iUkGvMAy2azscSgWH6bd2gJ9CGnhC6JRd3qTMFBL0ou/fx7WZl7A==} peerDependencies: @@ -2863,6 +3206,10 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -3007,6 +3354,9 @@ packages: resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} engines: {node: ^16.14.0 || >=18.0.0} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-parse-stringify@3.0.1: resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} @@ -3060,6 +3410,11 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} + import-local@3.2.0: + resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} + engines: {node: '>=8'} + hasBin: true + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -3175,6 +3530,10 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-generator-fn@2.1.0: + resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} + engines: {node: '>=6'} + is-generator-function@1.1.2: resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} engines: {node: '>= 0.4'} @@ -3300,6 +3659,26 @@ packages: resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} engines: {node: '>=0.10.0'} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@6.0.3: + resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} + engines: {node: '>=10'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} @@ -3307,34 +3686,162 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jest-changed-files@30.2.0: + resolution: {integrity: sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-circus@30.2.0: + resolution: {integrity: sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-cli@30.2.0: + resolution: {integrity: sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jest-config@30.2.0: + resolution: {integrity: sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@types/node': '*' + esbuild-register: '>=3.4.0' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + esbuild-register: + optional: true + ts-node: + optional: true + + jest-diff@30.2.0: + resolution: {integrity: sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-docblock@30.2.0: + resolution: {integrity: sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-each@30.2.0: + resolution: {integrity: sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-environment-node@29.7.0: resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-environment-node@30.2.0: + resolution: {integrity: sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-get-type@29.6.3: resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-haste-map@30.2.0: + resolution: {integrity: sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-leak-detector@30.2.0: + resolution: {integrity: sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-matcher-utils@30.2.0: + resolution: {integrity: sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-message-util@29.7.0: resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-message-util@30.2.0: + resolution: {integrity: sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-mock@29.7.0: resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-mock@30.2.0: + resolution: {integrity: sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-pnp-resolver@1.2.3: + resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} + engines: {node: '>=6'} + peerDependencies: + jest-resolve: '*' + peerDependenciesMeta: + jest-resolve: + optional: true + + jest-regex-util@30.0.1: + resolution: {integrity: sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-resolve-dependencies@30.2.0: + resolution: {integrity: sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-resolve@30.2.0: + resolution: {integrity: sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-runner@30.2.0: + resolution: {integrity: sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-runtime@30.2.0: + resolution: {integrity: sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-snapshot@30.2.0: + resolution: {integrity: sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-util@29.7.0: resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-util@30.2.0: + resolution: {integrity: sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-validate@29.7.0: resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-validate@30.2.0: + resolution: {integrity: sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-watcher@30.2.0: + resolution: {integrity: sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-worker@29.7.0: resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-worker@30.2.0: + resolution: {integrity: sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest@30.2.0: + resolution: {integrity: sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + jimp-compact@0.16.1: resolution: {integrity: sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==} @@ -3387,6 +3894,9 @@ packages: json-parse-better-errors@1.0.2: resolution: {integrity: sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==} + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-deref-sync@0.13.0: resolution: {integrity: sha512-YBOEogm5w9Op337yb6pAT6ZXDqlxAsQCanM3grid8lMWNxRJO/zWEJi3ZzqDL8boWfwhTFym5EFrNgWwpqcBRg==} engines: {node: '>=6.0.0'} @@ -3627,6 +4137,10 @@ packages: resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} engines: {node: '>=6'} + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} @@ -3852,6 +4366,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + nativewind@4.0.36: resolution: {integrity: sha512-nd0Xgjzaq0ISvUAjibZXcuSvvpX1BGX2mfOGBPZpjGfHL3By6fwLGsNhrKU6mi2FF30c+kdok3e2I4k/O0UO1Q==} engines: {node: '>=16'} @@ -4091,6 +4610,10 @@ packages: resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==} engines: {node: '>=4'} + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + parse-png@2.1.0: resolution: {integrity: sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ==} engines: {node: '>=10'} @@ -4161,6 +4684,10 @@ packages: resolution: {integrity: sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==} engines: {node: '>=6'} + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + pkg-up@3.1.0: resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} engines: {node: '>=8'} @@ -4262,6 +4789,10 @@ packages: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + pretty-format@30.2.0: + resolution: {integrity: sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + proc-log@4.2.0: resolution: {integrity: sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -4293,6 +4824,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pure-rand@7.0.1: + resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} + qrcode-terminal@0.11.0: resolution: {integrity: sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==} hasBin: true @@ -4550,6 +5084,10 @@ packages: resolve-alpn@1.2.1: resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + resolve-from@3.0.0: resolution: {integrity: sha512-GnlH6vxLymXJNMBo7XP1fJIzBFbdYt49CuTwmB/6N53t+kMPRMFKz783LlQ4tv28XoQfMWinAJX6WCGf2IlaIw==} engines: {node: '>=4'} @@ -4776,6 +5314,9 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-support@0.5.13: + resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + source-map-support@0.5.21: resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} @@ -4840,6 +5381,10 @@ packages: resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} engines: {node: '>=4'} + string-length@4.0.2: + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -4889,6 +5434,10 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + strip-eof@1.0.0: resolution: {integrity: sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==} engines: {node: '>=0.10.0'} @@ -4992,6 +5541,10 @@ packages: engines: {node: '>=10'} hasBin: true + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} @@ -5175,6 +5728,9 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + unrs-resolver@1.11.1: + resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -5220,6 +5776,10 @@ packages: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true + v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + valid-url@1.0.9: resolution: {integrity: sha512-QQDsV8OnSf5Uc30CKSwG9lnhMPe6exHtTXLRYX8uMwKENy640pU+2BgBL0LRbDh/eYRahNCS7aewCx0wf3NYVA==} @@ -5327,6 +5887,10 @@ packages: write-file-atomic@2.4.3: resolution: {integrity: sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ==} + write-file-atomic@5.0.1: + resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + ws@6.2.3: resolution: {integrity: sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==} peerDependencies: @@ -5755,6 +6319,21 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -5785,6 +6364,16 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -5820,12 +6409,22 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.0)': + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) @@ -6379,10 +6978,28 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bcoe/v8-coverage@0.2.3': {} + '@egjs/hammerjs@2.0.17': dependencies: '@types/hammerjs': 2.0.46 + '@emnapi/core@1.8.1': + dependencies: + '@emnapi/wasi-threads': 1.1.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.8.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.1.0': + dependencies: + tslib: 2.8.1 + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@8.57.1)': dependencies: eslint: 8.57.1 @@ -6776,10 +7393,67 @@ snapshots: '@isaacs/ttlcache@1.4.1': {} + '@istanbuljs/load-nyc-config@1.1.0': + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.14.2 + resolve-from: 5.0.0 + + '@istanbuljs/schema@0.1.3': {} + + '@jest/console@30.2.0': + dependencies: + '@jest/types': 30.2.0 + '@types/node': 25.2.3 + chalk: 4.1.2 + jest-message-util: 30.2.0 + jest-util: 30.2.0 + slash: 3.0.0 + + '@jest/core@30.2.0': + dependencies: + '@jest/console': 30.2.0 + '@jest/pattern': 30.0.1 + '@jest/reporters': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 25.2.3 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 4.4.0 + exit-x: 0.2.2 + graceful-fs: 4.2.11 + jest-changed-files: 30.2.0 + jest-config: 30.2.0(@types/node@25.2.3) + jest-haste-map: 30.2.0 + jest-message-util: 30.2.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.2.0 + jest-resolve-dependencies: 30.2.0 + jest-runner: 30.2.0 + jest-runtime: 30.2.0 + jest-snapshot: 30.2.0 + jest-util: 30.2.0 + jest-validate: 30.2.0 + jest-watcher: 30.2.0 + micromatch: 4.0.8 + pretty-format: 30.2.0 + slash: 3.0.0 + transitivePeerDependencies: + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + '@jest/create-cache-key-function@29.7.0': dependencies: '@jest/types': 29.6.3 + '@jest/diff-sequences@30.0.1': {} + '@jest/environment@29.7.0': dependencies: '@jest/fake-timers': 29.7.0 @@ -6787,6 +7461,24 @@ snapshots: '@types/node': 25.2.3 jest-mock: 29.7.0 + '@jest/environment@30.2.0': + dependencies: + '@jest/fake-timers': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 25.2.3 + jest-mock: 30.2.0 + + '@jest/expect-utils@30.2.0': + dependencies: + '@jest/get-type': 30.1.0 + + '@jest/expect@30.2.0': + dependencies: + expect: 30.2.0 + jest-snapshot: 30.2.0 + transitivePeerDependencies: + - supports-color + '@jest/fake-timers@29.7.0': dependencies: '@jest/types': 29.6.3 @@ -6796,10 +7488,114 @@ snapshots: jest-mock: 29.7.0 jest-util: 29.7.0 + '@jest/fake-timers@30.2.0': + dependencies: + '@jest/types': 30.2.0 + '@sinonjs/fake-timers': 13.0.5 + '@types/node': 25.2.3 + jest-message-util: 30.2.0 + jest-mock: 30.2.0 + jest-util: 30.2.0 + + '@jest/get-type@30.1.0': {} + + '@jest/globals@30.2.0': + dependencies: + '@jest/environment': 30.2.0 + '@jest/expect': 30.2.0 + '@jest/types': 30.2.0 + jest-mock: 30.2.0 + transitivePeerDependencies: + - supports-color + + '@jest/pattern@30.0.1': + dependencies: + '@types/node': 25.2.3 + jest-regex-util: 30.0.1 + + '@jest/reporters@30.2.0': + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + '@jridgewell/trace-mapping': 0.3.31 + '@types/node': 25.2.3 + chalk: 4.1.2 + collect-v8-coverage: 1.0.3 + exit-x: 0.2.2 + glob: 10.5.0 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + jest-message-util: 30.2.0 + jest-util: 30.2.0 + jest-worker: 30.2.0 + slash: 3.0.0 + string-length: 4.0.2 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color + '@jest/schemas@29.6.3': dependencies: '@sinclair/typebox': 0.27.10 + '@jest/schemas@30.0.5': + dependencies: + '@sinclair/typebox': 0.34.48 + + '@jest/snapshot-utils@30.2.0': + dependencies: + '@jest/types': 30.2.0 + chalk: 4.1.2 + graceful-fs: 4.2.11 + natural-compare: 1.4.0 + + '@jest/source-map@30.0.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + callsites: 3.1.0 + graceful-fs: 4.2.11 + + '@jest/test-result@30.2.0': + dependencies: + '@jest/console': 30.2.0 + '@jest/types': 30.2.0 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.3 + + '@jest/test-sequencer@30.2.0': + dependencies: + '@jest/test-result': 30.2.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.2.0 + slash: 3.0.0 + + '@jest/transform@30.2.0': + dependencies: + '@babel/core': 7.29.0 + '@jest/types': 30.2.0 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 7.0.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.2.0 + jest-regex-util: 30.0.1 + jest-util: 30.2.0 + micromatch: 4.0.8 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 5.0.1 + transitivePeerDependencies: + - supports-color + '@jest/types@24.9.0': dependencies: '@types/istanbul-lib-coverage': 2.0.6 @@ -6823,6 +7619,16 @@ snapshots: '@types/yargs': 17.0.35 chalk: 4.1.2 + '@jest/types@30.2.0': + dependencies: + '@jest/pattern': 30.0.1 + '@jest/schemas': 30.0.5 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 25.2.3 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -6847,6 +7653,13 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.8.1 + '@emnapi/runtime': 1.8.1 + '@tybys/wasm-util': 0.10.1 + optional: true + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -7341,6 +8154,8 @@ snapshots: '@sinclair/typebox@0.27.10': {} + '@sinclair/typebox@0.34.48': {} + '@sindresorhus/is@4.6.0': {} '@sinonjs/commons@3.0.1': @@ -7351,6 +8166,10 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@sinonjs/fake-timers@13.0.5': + dependencies: + '@sinonjs/commons': 3.0.1 + '@supabase/auth-js@2.95.3': dependencies: tslib: 2.8.1 @@ -7393,6 +8212,32 @@ snapshots: dependencies: defer-to-connect: 2.0.1 + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.0 + '@types/cacheable-request@6.0.3': dependencies: '@types/http-cache-semantics': 4.2.0 @@ -7559,6 +8404,65 @@ snapshots: '@ungap/structured-clone@1.3.0': {} + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + optional: true + + '@unrs/resolver-binding-android-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + optional: true + '@urql/core@2.3.6(graphql@15.8.0)': dependencies: '@graphql-typed-document-node/core': 3.2.0(graphql@15.8.0) @@ -7778,6 +8682,33 @@ snapshots: dependencies: '@babel/core': 7.29.0 + babel-jest@30.2.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@jest/transform': 30.2.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 7.0.1 + babel-preset-jest: 30.2.0(@babel/core@7.29.0) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-istanbul@7.0.1: + dependencies: + '@babel/helper-plugin-utils': 7.28.6 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-instrument: 6.0.3 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-jest-hoist@30.2.0: + dependencies: + '@types/babel__core': 7.20.5 + babel-plugin-module-resolver@5.0.2: dependencies: find-babel-config: 2.1.2 @@ -7847,6 +8778,25 @@ snapshots: transitivePeerDependencies: - '@babel/core' + babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.0) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.0) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.0) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.0) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.0) + babel-preset-expo@11.0.15(@babel/core@7.29.0)(@babel/preset-env@7.29.0(@babel/core@7.29.0)): dependencies: '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0) @@ -7864,6 +8814,12 @@ snapshots: - '@babel/preset-env' - supports-color + babel-preset-jest@30.2.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + babel-plugin-jest-hoist: 30.2.0 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) + balanced-match@1.0.2: {} base64-js@1.5.1: {} @@ -8026,6 +8982,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + char-regex@1.0.2: {} + charenc@0.0.2: {} chokidar@3.6.0: @@ -8055,6 +9013,10 @@ snapshots: ci-info@3.9.0: {} + ci-info@4.4.0: {} + + cjs-module-lexer@2.2.0: {} + clean-stack@2.2.0: {} cli-cursor@2.1.0: @@ -8093,6 +9055,10 @@ snapshots: clone@2.1.2: {} + co@4.6.0: {} + + collect-v8-coverage@1.0.3: {} + color-convert@1.9.3: dependencies: color-name: 1.1.3 @@ -8270,6 +9236,8 @@ snapshots: dependencies: mimic-response: 3.1.0 + dedent@1.7.1: {} + deep-extend@0.6.0: {} deep-is@0.1.4: {} @@ -8322,6 +9290,8 @@ snapshots: detect-libc@1.0.3: {} + detect-newline@3.1.0: {} + didyoumean@1.2.2: {} dir-glob@3.0.1: @@ -8374,6 +9344,8 @@ snapshots: electron-to-chromium@1.5.286: {} + emittery@0.13.1: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -8742,6 +9714,17 @@ snapshots: signal-exit: 3.0.7 strip-final-newline: 2.0.0 + exit-x@0.2.2: {} + + expect@30.2.0: + dependencies: + '@jest/expect-utils': 30.2.0 + '@jest/get-type': 30.1.0 + jest-matcher-utils: 30.2.0 + jest-message-util: 30.2.0 + jest-mock: 30.2.0 + jest-util: 30.2.0 + expo-asset@10.0.10(expo@51.0.39(@babel/core@7.29.0)(@babel/preset-env@7.29.0(@babel/core@7.29.0))(react-native@0.74.5(@babel/core@7.29.0)(@babel/preset-env@7.29.0(@babel/core@7.29.0))(react@18.2.0))(react@18.2.0)): dependencies: expo: 51.0.39(@babel/core@7.29.0)(@babel/preset-env@7.29.0(@babel/core@7.29.0))(react-native@0.74.5(@babel/core@7.29.0)(@babel/preset-env@7.29.0(@babel/core@7.29.0))(react@18.2.0))(react@18.2.0) @@ -9139,6 +10122,8 @@ snapshots: hasown: 2.0.2 math-intrinsics: 1.1.0 + get-package-type@0.1.0: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -9301,6 +10286,8 @@ snapshots: dependencies: lru-cache: 10.4.3 + html-escaper@2.0.2: {} + html-parse-stringify@3.0.1: dependencies: void-elements: 3.1.0 @@ -9361,6 +10348,11 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 + import-local@3.2.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + imurmurhash@0.1.4: {} indent-string@4.0.0: {} @@ -9464,6 +10456,8 @@ snapshots: is-fullwidth-code-point@3.0.0: {} + is-generator-fn@2.1.0: {} + is-generator-function@1.1.2: dependencies: call-bound: 1.0.4 @@ -9570,6 +10564,37 @@ snapshots: isobject@3.0.1: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@6.0.3: + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.0 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 7.7.4 + transitivePeerDependencies: + - supports-color + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -9585,6 +10610,108 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jest-changed-files@30.2.0: + dependencies: + execa: 5.1.1 + jest-util: 30.2.0 + p-limit: 3.1.0 + + jest-circus@30.2.0: + dependencies: + '@jest/environment': 30.2.0 + '@jest/expect': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 25.2.3 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.7.1 + is-generator-fn: 2.1.0 + jest-each: 30.2.0 + jest-matcher-utils: 30.2.0 + jest-message-util: 30.2.0 + jest-runtime: 30.2.0 + jest-snapshot: 30.2.0 + jest-util: 30.2.0 + p-limit: 3.1.0 + pretty-format: 30.2.0 + pure-rand: 7.0.1 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-cli@30.2.0(@types/node@25.2.3): + dependencies: + '@jest/core': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/types': 30.2.0 + chalk: 4.1.2 + exit-x: 0.2.2 + import-local: 3.2.0 + jest-config: 30.2.0(@types/node@25.2.3) + jest-util: 30.2.0 + jest-validate: 30.2.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + + jest-config@30.2.0(@types/node@25.2.3): + dependencies: + '@babel/core': 7.29.0 + '@jest/get-type': 30.1.0 + '@jest/pattern': 30.0.1 + '@jest/test-sequencer': 30.2.0 + '@jest/types': 30.2.0 + babel-jest: 30.2.0(@babel/core@7.29.0) + chalk: 4.1.2 + ci-info: 4.4.0 + deepmerge: 4.3.1 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-circus: 30.2.0 + jest-docblock: 30.2.0 + jest-environment-node: 30.2.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.2.0 + jest-runner: 30.2.0 + jest-util: 30.2.0 + jest-validate: 30.2.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 30.2.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 25.2.3 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-diff@30.2.0: + dependencies: + '@jest/diff-sequences': 30.0.1 + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + pretty-format: 30.2.0 + + jest-docblock@30.2.0: + dependencies: + detect-newline: 3.1.0 + + jest-each@30.2.0: + dependencies: + '@jest/get-type': 30.1.0 + '@jest/types': 30.2.0 + chalk: 4.1.2 + jest-util: 30.2.0 + pretty-format: 30.2.0 + jest-environment-node@29.7.0: dependencies: '@jest/environment': 29.7.0 @@ -9594,8 +10721,45 @@ snapshots: jest-mock: 29.7.0 jest-util: 29.7.0 + jest-environment-node@30.2.0: + dependencies: + '@jest/environment': 30.2.0 + '@jest/fake-timers': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 25.2.3 + jest-mock: 30.2.0 + jest-util: 30.2.0 + jest-validate: 30.2.0 + jest-get-type@29.6.3: {} + jest-haste-map@30.2.0: + dependencies: + '@jest/types': 30.2.0 + '@types/node': 25.2.3 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 30.0.1 + jest-util: 30.2.0 + jest-worker: 30.2.0 + micromatch: 4.0.8 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + + jest-leak-detector@30.2.0: + dependencies: + '@jest/get-type': 30.1.0 + pretty-format: 30.2.0 + + jest-matcher-utils@30.2.0: + dependencies: + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + jest-diff: 30.2.0 + pretty-format: 30.2.0 + jest-message-util@29.7.0: dependencies: '@babel/code-frame': 7.29.0 @@ -9608,12 +10772,134 @@ snapshots: slash: 3.0.0 stack-utils: 2.0.6 + jest-message-util@30.2.0: + dependencies: + '@babel/code-frame': 7.29.0 + '@jest/types': 30.2.0 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 30.2.0 + slash: 3.0.0 + stack-utils: 2.0.6 + jest-mock@29.7.0: dependencies: '@jest/types': 29.6.3 '@types/node': 25.2.3 jest-util: 29.7.0 + jest-mock@30.2.0: + dependencies: + '@jest/types': 30.2.0 + '@types/node': 25.2.3 + jest-util: 30.2.0 + + jest-pnp-resolver@1.2.3(jest-resolve@30.2.0): + optionalDependencies: + jest-resolve: 30.2.0 + + jest-regex-util@30.0.1: {} + + jest-resolve-dependencies@30.2.0: + dependencies: + jest-regex-util: 30.0.1 + jest-snapshot: 30.2.0 + transitivePeerDependencies: + - supports-color + + jest-resolve@30.2.0: + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 30.2.0 + jest-pnp-resolver: 1.2.3(jest-resolve@30.2.0) + jest-util: 30.2.0 + jest-validate: 30.2.0 + slash: 3.0.0 + unrs-resolver: 1.11.1 + + jest-runner@30.2.0: + dependencies: + '@jest/console': 30.2.0 + '@jest/environment': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 25.2.3 + chalk: 4.1.2 + emittery: 0.13.1 + exit-x: 0.2.2 + graceful-fs: 4.2.11 + jest-docblock: 30.2.0 + jest-environment-node: 30.2.0 + jest-haste-map: 30.2.0 + jest-leak-detector: 30.2.0 + jest-message-util: 30.2.0 + jest-resolve: 30.2.0 + jest-runtime: 30.2.0 + jest-util: 30.2.0 + jest-watcher: 30.2.0 + jest-worker: 30.2.0 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color + + jest-runtime@30.2.0: + dependencies: + '@jest/environment': 30.2.0 + '@jest/fake-timers': 30.2.0 + '@jest/globals': 30.2.0 + '@jest/source-map': 30.0.1 + '@jest/test-result': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 25.2.3 + chalk: 4.1.2 + cjs-module-lexer: 2.2.0 + collect-v8-coverage: 1.0.3 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.2.0 + jest-message-util: 30.2.0 + jest-mock: 30.2.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.2.0 + jest-snapshot: 30.2.0 + jest-util: 30.2.0 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + + jest-snapshot@30.2.0: + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/types': 7.29.0 + '@jest/expect-utils': 30.2.0 + '@jest/get-type': 30.1.0 + '@jest/snapshot-utils': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) + chalk: 4.1.2 + expect: 30.2.0 + graceful-fs: 4.2.11 + jest-diff: 30.2.0 + jest-matcher-utils: 30.2.0 + jest-message-util: 30.2.0 + jest-util: 30.2.0 + pretty-format: 30.2.0 + semver: 7.7.4 + synckit: 0.11.12 + transitivePeerDependencies: + - supports-color + jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 @@ -9623,6 +10909,15 @@ snapshots: graceful-fs: 4.2.11 picomatch: 2.3.1 + jest-util@30.2.0: + dependencies: + '@jest/types': 30.2.0 + '@types/node': 25.2.3 + chalk: 4.1.2 + ci-info: 4.4.0 + graceful-fs: 4.2.11 + picomatch: 4.0.3 + jest-validate@29.7.0: dependencies: '@jest/types': 29.6.3 @@ -9632,6 +10927,26 @@ snapshots: leven: 3.1.0 pretty-format: 29.7.0 + jest-validate@30.2.0: + dependencies: + '@jest/get-type': 30.1.0 + '@jest/types': 30.2.0 + camelcase: 6.3.0 + chalk: 4.1.2 + leven: 3.1.0 + pretty-format: 30.2.0 + + jest-watcher@30.2.0: + dependencies: + '@jest/test-result': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 25.2.3 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 30.2.0 + string-length: 4.0.2 + jest-worker@29.7.0: dependencies: '@types/node': 25.2.3 @@ -9639,6 +10954,27 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 + jest-worker@30.2.0: + dependencies: + '@types/node': 25.2.3 + '@ungap/structured-clone': 1.3.0 + jest-util: 30.2.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest@30.2.0(@types/node@25.2.3): + dependencies: + '@jest/core': 30.2.0 + '@jest/types': 30.2.0 + import-local: 3.2.0 + jest-cli: 30.2.0(@types/node@25.2.3) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + jimp-compact@0.16.1: {} jiti@1.21.7: {} @@ -9701,6 +11037,8 @@ snapshots: json-parse-better-errors@1.0.2: {} + json-parse-even-better-errors@2.3.1: {} + json-schema-deref-sync@0.13.0: dependencies: clone: 2.1.2 @@ -9904,6 +11242,10 @@ snapshots: pify: 4.0.1 semver: 5.7.2 + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + makeerror@1.0.12: dependencies: tmpl: 1.0.5 @@ -10214,6 +11556,8 @@ snapshots: nanoid@3.3.11: {} + napi-postinstall@0.3.4: {} + nativewind@4.0.36(@babel/core@7.29.0)(react-native-reanimated@3.10.1(@babel/core@7.29.0)(react-native@0.74.5(@babel/core@7.29.0)(@babel/preset-env@7.29.0(@babel/core@7.29.0))(react@18.2.0))(react@18.2.0))(react-native-safe-area-context@4.10.5(react-native@0.74.5(@babel/core@7.29.0)(@babel/preset-env@7.29.0(@babel/core@7.29.0))(react@18.2.0))(react@18.2.0))(react-native-svg@15.2.0(react-native@0.74.5(@babel/core@7.29.0)(@babel/preset-env@7.29.0(@babel/core@7.29.0))(react@18.2.0))(react@18.2.0))(react-native@0.74.5(@babel/core@7.29.0)(@babel/preset-env@7.29.0(@babel/core@7.29.0))(react@18.2.0))(react@18.2.0)(tailwindcss@3.4.19(yaml@2.8.2)): dependencies: react-native-css-interop: 0.0.36(@babel/core@7.29.0)(react-native-reanimated@3.10.1(@babel/core@7.29.0)(react-native@0.74.5(@babel/core@7.29.0)(@babel/preset-env@7.29.0(@babel/core@7.29.0))(react@18.2.0))(react@18.2.0))(react-native-safe-area-context@4.10.5(react-native@0.74.5(@babel/core@7.29.0)(@babel/preset-env@7.29.0(@babel/core@7.29.0))(react@18.2.0))(react@18.2.0))(react-native-svg@15.2.0(react-native@0.74.5(@babel/core@7.29.0)(@babel/preset-env@7.29.0(@babel/core@7.29.0))(react@18.2.0))(react@18.2.0))(react-native@0.74.5(@babel/core@7.29.0)(@babel/preset-env@7.29.0(@babel/core@7.29.0))(react@18.2.0))(react@18.2.0)(tailwindcss@3.4.19(yaml@2.8.2)) @@ -10462,6 +11806,13 @@ snapshots: error-ex: 1.3.4 json-parse-better-errors: 1.0.2 + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.0 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + parse-png@2.1.0: dependencies: pngjs: 3.4.0 @@ -10505,6 +11856,10 @@ snapshots: dependencies: find-up: 3.0.0 + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + pkg-up@3.1.0: dependencies: find-up: 3.0.0 @@ -10595,6 +11950,12 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 + pretty-format@30.2.0: + dependencies: + '@jest/schemas': 30.0.5 + ansi-styles: 5.2.0 + react-is: 18.3.1 + proc-log@4.2.0: {} process-nextick-args@2.0.1: {} @@ -10627,6 +11988,8 @@ snapshots: punycode@2.3.1: {} + pure-rand@7.0.1: {} + qrcode-terminal@0.11.0: {} query-string@7.1.3: @@ -10934,6 +12297,10 @@ snapshots: resolve-alpn@1.2.1: {} + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + resolve-from@3.0.0: {} resolve-from@4.0.0: {} @@ -11194,6 +12561,11 @@ snapshots: source-map-js@1.2.1: {} + source-map-support@0.5.13: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + source-map-support@0.5.21: dependencies: buffer-from: 1.1.2 @@ -11240,6 +12612,11 @@ snapshots: strict-uri-encode@2.0.0: {} + string-length@4.0.2: + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -11318,6 +12695,8 @@ snapshots: strip-bom@3.0.0: {} + strip-bom@4.0.0: {} + strip-eof@1.0.0: {} strip-final-newline@2.0.0: {} @@ -11450,6 +12829,12 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 7.2.3 + minimatch: 3.1.2 + text-table@0.2.0: {} thenify-all@1.6.0: @@ -11621,6 +13006,30 @@ snapshots: unpipe@1.0.0: {} + unrs-resolver@1.11.1: + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.11.1 + '@unrs/resolver-binding-android-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-x64': 1.11.1 + '@unrs/resolver-binding-freebsd-x64': 1.11.1 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-arm64-musl': 1.11.1 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-musl': 1.11.1 + '@unrs/resolver-binding-linux-s390x-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-musl': 1.11.1 + '@unrs/resolver-binding-wasm32-wasi': 1.11.1 + '@unrs/resolver-binding-win32-arm64-msvc': 1.11.1 + '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 + '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1 @@ -11659,6 +13068,12 @@ snapshots: uuid@8.3.2: {} + v8-to-istanbul@9.3.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + valid-url@1.0.9: {} validate-npm-package-name@3.0.0: @@ -11789,6 +13204,11 @@ snapshots: imurmurhash: 0.1.4 signal-exit: 3.0.7 + write-file-atomic@5.0.1: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 4.1.0 + ws@6.2.3: dependencies: async-limiter: 1.0.1 diff --git a/the-trash-rn/src/components/arena/QuizCard.js b/the-trash-rn/src/components/arena/QuizCard.js index 683a8c4..4a818bd 100644 --- a/the-trash-rn/src/components/arena/QuizCard.js +++ b/the-trash-rn/src/components/arena/QuizCard.js @@ -14,7 +14,9 @@ export default function QuizCard({ question, onAnswer, mode }) { {mode} - {question.prompt} + + {question.prompt} + {question.options?.map((option) => ( {title} {value} - {subtitle ? {subtitle} : null} + {subtitle ? ( + {subtitle} + ) : null} ); } diff --git a/the-trash-rn/src/components/arena/TimerBar.js b/the-trash-rn/src/components/arena/TimerBar.js index 83eee17..b122a7f 100644 --- a/the-trash-rn/src/components/arena/TimerBar.js +++ b/the-trash-rn/src/components/arena/TimerBar.js @@ -4,7 +4,13 @@ export default function TimerBar({ progress = 1, variant = 'info' }) { const color = variant === 'warning' ? '#ffae35' : '#32f5ff'; return ( - + ); } diff --git a/the-trash-rn/src/components/camera/CameraControls.js b/the-trash-rn/src/components/camera/CameraControls.js index 7ec40a7..31abb17 100644 --- a/the-trash-rn/src/components/camera/CameraControls.js +++ b/the-trash-rn/src/components/camera/CameraControls.js @@ -1,5 +1,6 @@ import { Feather } from '@expo/vector-icons'; import { ActivityIndicator, Pressable, View } from 'react-native'; + import { useTheme } from 'src/theme/ThemeProvider'; export default function CameraControls({ diff --git a/the-trash-rn/src/components/camera/CameraView.js b/the-trash-rn/src/components/camera/CameraView.js index ae952b1..456fb25 100644 --- a/the-trash-rn/src/components/camera/CameraView.js +++ b/the-trash-rn/src/components/camera/CameraView.js @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { ActivityIndicator, Pressable, Text, View } from 'react-native'; import { Camera, useCameraDevice } from 'react-native-vision-camera'; + import { useTheme } from 'src/theme/ThemeProvider'; export default function CameraView({ @@ -74,7 +75,13 @@ export default function CameraView({ }} > - + 加载相机… diff --git a/the-trash-rn/src/components/shared/AchievementToast.js b/the-trash-rn/src/components/shared/AchievementToast.js index 88b6af0..c7fcf86 100644 --- a/the-trash-rn/src/components/shared/AchievementToast.js +++ b/the-trash-rn/src/components/shared/AchievementToast.js @@ -1,5 +1,6 @@ import { useEffect, useRef } from 'react'; import { Animated, Text, View } from 'react-native'; + import { useAchievementStore } from 'src/stores/achievementStore'; import { useTheme } from 'src/theme/ThemeProvider'; @@ -48,7 +49,16 @@ export default function AchievementToast() { } return ( - + - + {toast.icon ?? '✨'} {toast.title} - + {toast.description} diff --git a/the-trash-rn/src/components/skia/NeumorphicSurface.js b/the-trash-rn/src/components/skia/NeumorphicSurface.js index 3608f92..dd6808b 100644 --- a/the-trash-rn/src/components/skia/NeumorphicSurface.js +++ b/the-trash-rn/src/components/skia/NeumorphicSurface.js @@ -3,7 +3,10 @@ import { View } from 'react-native'; // TODO: Replace with Skia implementation per Phase 8 export default function NeumorphicSurface({ children, style }) { return ( - + {children} ); diff --git a/the-trash-rn/src/components/skia/PaperTexture.js b/the-trash-rn/src/components/skia/PaperTexture.js index ba6453d..b858aaf 100644 --- a/the-trash-rn/src/components/skia/PaperTexture.js +++ b/the-trash-rn/src/components/skia/PaperTexture.js @@ -2,7 +2,7 @@ import { View } from 'react-native'; export default function PaperTexture({ children, style }) { return ( - + {children} ); diff --git a/the-trash-rn/src/components/skia/TornEdges.js b/the-trash-rn/src/components/skia/TornEdges.js index 6735f95..07336fd 100644 --- a/the-trash-rn/src/components/skia/TornEdges.js +++ b/the-trash-rn/src/components/skia/TornEdges.js @@ -1,5 +1,9 @@ import { View } from 'react-native'; export default function TornEdges({ children }) { - return {children}; + return ( + + {children} + + ); } diff --git a/the-trash-rn/src/i18n/index.js b/the-trash-rn/src/i18n/index.js index d19739b..367ce1a 100644 --- a/the-trash-rn/src/i18n/index.js +++ b/the-trash-rn/src/i18n/index.js @@ -1,6 +1,7 @@ +import * as Localization from 'expo-localization'; import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; -import * as Localization from 'expo-localization'; + import en from './en.json'; import zh from './zh.json'; diff --git a/the-trash-rn/src/services/__tests__/leaderboard.test.js b/the-trash-rn/src/services/__tests__/leaderboard.test.js new file mode 100644 index 0000000..5b8d993 --- /dev/null +++ b/the-trash-rn/src/services/__tests__/leaderboard.test.js @@ -0,0 +1,99 @@ +jest.mock('react-native-contacts', () => ({ + checkPermission: jest.fn(), + requestPermission: jest.fn(), + getAll: jest.fn() +})); + +jest.mock('src/services/supabase', () => ({ + supabase: { + auth: { + getUser: jest.fn() + }, + rpc: jest.fn() + } +})); + +process.env.EXPO_PUBLIC_SUPABASE_URL = + process.env.EXPO_PUBLIC_SUPABASE_URL ?? 'https://example.supabase.co'; +process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY = + process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY ?? 'anon-key'; + +const Contacts = require('react-native-contacts'); + +const { leaderboardService } = require('src/services/leaderboard'); +const { supabase } = require('src/services/supabase'); +const { ERROR_CODES } = require('src/utils/errors'); + +describe('leaderboardService contacts privacy', () => { + beforeEach(() => { + jest.clearAllMocks(); + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'me' } }, + error: null + }); + }); + + test('friends fetch does not auto-read contacts without explicit sync', async () => { + const result = await leaderboardService.fetch('friends'); + expect(result).toEqual([]); + expect(Contacts.getAll).not.toHaveBeenCalled(); + expect(supabase.rpc).not.toHaveBeenCalled(); + }); + + test('syncContacts requires contacts permission when prompting is disabled', async () => { + Contacts.checkPermission.mockResolvedValue('denied'); + await expect( + leaderboardService.syncContacts({ allowPermissionPrompt: false }) + ).rejects.toMatchObject({ + code: ERROR_CODES.CONTACTS_PERMISSION_REQUIRED + }); + }); + + test('syncContacts uploads deduped identifiers and caps payload size', async () => { + Contacts.checkPermission.mockResolvedValue('authorized'); + Contacts.getAll.mockResolvedValue( + Array.from({ length: 350 }).map((_, idx) => ({ + emailAddresses: [ + { + email: `User${idx % 320}@Example.com` + } + ], + phoneNumbers: [ + { + number: `650555${String(idx % 320).padStart(4, '0')}` + } + ] + })) + ); + + supabase.rpc.mockImplementation(async (fn) => { + if (fn === 'find_friends_leaderboard') { + return { + data: [ + { + id: 'u-1', + username: 'Alice', + credits: 42, + phone: '+***1234' + } + ], + error: null + }; + } + return { data: [], error: null }; + }); + + const result = await leaderboardService.syncContacts(); + expect(result.entries).toHaveLength(1); + + const call = supabase.rpc.mock.calls.find( + ([fn]) => fn === 'find_friends_leaderboard' + ); + expect(call).toBeTruthy(); + const payload = call[1]; + expect(payload.p_emails.length).toBeLessThanOrEqual(300); + expect(payload.p_phones.length).toBeLessThanOrEqual(300); + expect(result.syncStats.emailCount).toBe(payload.p_emails.length); + expect(result.syncStats.phoneCount).toBe(payload.p_phones.length); + }); +}); diff --git a/the-trash-rn/src/services/account.js b/the-trash-rn/src/services/account.js index feb07a6..683ee7b 100644 --- a/the-trash-rn/src/services/account.js +++ b/the-trash-rn/src/services/account.js @@ -1,69 +1,143 @@ -import { supabase } from './supabase'; +import { hasSupabaseConfig } from 'src/services/config'; +import { AppError, ERROR_CODES, fromSupabaseError } from 'src/utils/errors'; import { normalizePhoneNumber } from 'src/utils/phone'; -const hasSupabaseConfig = Boolean( - process.env.EXPO_PUBLIC_SUPABASE_URL && process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY -); +import { supabase } from './supabase'; + +const resolveEmail = (email) => + String(email ?? '') + .trim() + .toLowerCase(); + +const getCurrentSession = async () => { + const { data, error } = await supabase.auth.getSession(); + if (error) { + throw fromSupabaseError(error, { + code: ERROR_CODES.AUTH, + message: '读取登录状态失败' + }); + } + return data.session ?? null; +}; export const accountService = { async requestPhoneOtp(phone) { - if (!phone) throw new Error('请输入手机号'); + if (!phone) + throw new AppError('请输入手机号', { code: ERROR_CODES.VALIDATION }); const normalizedPhone = normalizePhoneNumber(phone); if (!normalizedPhone) { - throw new Error('手机号格式不正确'); + throw new AppError('手机号格式不正确', { code: ERROR_CODES.VALIDATION }); } - if (hasSupabaseConfig) { - const { error } = await supabase.auth.signInWithOtp({ phone: normalizedPhone }); - if (error) throw new Error(error.message); + if (hasSupabaseConfig()) { + const session = await getCurrentSession(); + const { error } = session?.user + ? await supabase.auth.updateUser({ phone: normalizedPhone }) + : await supabase.auth.signInWithOtp({ phone: normalizedPhone }); + if (error) + throw fromSupabaseError(error, { + code: ERROR_CODES.AUTH, + message: '发送手机验证码失败' + }); } return true; }, async bindPhone({ phone, code }) { - if (!phone || !code) throw new Error('请输入手机号和验证码'); + if (!phone || !code) { + throw new AppError('请输入手机号和验证码', { + code: ERROR_CODES.VALIDATION + }); + } const normalizedPhone = normalizePhoneNumber(phone); if (!normalizedPhone) { - throw new Error('手机号格式不正确'); + throw new AppError('手机号格式不正确', { code: ERROR_CODES.VALIDATION }); } - if (hasSupabaseConfig) { + if (hasSupabaseConfig()) { + const session = await getCurrentSession(); const { error } = await supabase.auth.verifyOtp({ phone: normalizedPhone, token: code, - type: 'sms' + type: session?.user ? 'phone_change' : 'sms' }); - if (error) throw new Error(error.message); + if (error) + throw fromSupabaseError(error, { + code: ERROR_CODES.AUTH, + message: '手机验证码校验失败' + }); } return true; }, async requestEmailOtp(email) { - if (!email) throw new Error('请输入邮箱'); - if (hasSupabaseConfig) { - const { error } = await supabase.auth.signInWithOtp({ email }); - if (error) throw new Error(error.message); + const normalizedEmail = resolveEmail(email); + if (!normalizedEmail) { + throw new AppError('请输入邮箱', { code: ERROR_CODES.VALIDATION }); + } + if (hasSupabaseConfig()) { + const session = await getCurrentSession(); + const { error } = session?.user + ? await supabase.auth.updateUser({ email: normalizedEmail }) + : await supabase.auth.signInWithOtp({ email: normalizedEmail }); + if (error) + throw fromSupabaseError(error, { + code: ERROR_CODES.AUTH, + message: '发送邮箱验证码失败' + }); } return true; }, async bindEmail({ email, code }) { - if (!email || !code) throw new Error('请输入邮箱和验证码'); - if (hasSupabaseConfig) { - const { error } = await supabase.auth.verifyOtp({ email, token: code, type: 'email' }); - if (error) throw new Error(error.message); + const normalizedEmail = resolveEmail(email); + if (!normalizedEmail || !code) { + throw new AppError('请输入邮箱和验证码', { + code: ERROR_CODES.VALIDATION + }); + } + if (hasSupabaseConfig()) { + const session = await getCurrentSession(); + const { error } = await supabase.auth.verifyOtp({ + email: normalizedEmail, + token: code, + type: session?.user ? 'email_change' : 'email' + }); + if (error) + throw fromSupabaseError(error, { + code: ERROR_CODES.AUTH, + message: '邮箱验证码校验失败' + }); } return true; }, async changePassword(password) { - if (!password || password.length < 8) throw new Error('密码至少 8 位'); - if (hasSupabaseConfig) { + if (!password || password.length < 8) { + throw new AppError('密码至少 8 位', { code: ERROR_CODES.VALIDATION }); + } + if (hasSupabaseConfig()) { const { error } = await supabase.auth.updateUser({ password }); - if (error) throw new Error(error.message); + if (error) + throw fromSupabaseError(error, { + code: ERROR_CODES.AUTH, + message: '修改密码失败' + }); } return true; }, async upgradeGuest({ email, password }) { - if (!email || !password) throw new Error('请输入邮箱和密码'); - if (password.length < 8) throw new Error('密码至少 8 位'); - if (hasSupabaseConfig) { - const { error } = await supabase.auth.signUp({ email, password }); - if (error) throw new Error(error.message); + const normalizedEmail = resolveEmail(email); + if (!normalizedEmail || !password) { + throw new AppError('请输入邮箱和密码', { code: ERROR_CODES.VALIDATION }); + } + if (password.length < 8) { + throw new AppError('密码至少 8 位', { code: ERROR_CODES.VALIDATION }); + } + if (hasSupabaseConfig()) { + const session = await getCurrentSession(); + const { error } = session?.user + ? await supabase.auth.updateUser({ email: normalizedEmail, password }) + : await supabase.auth.signUp({ email: normalizedEmail, password }); + if (error) + throw fromSupabaseError(error, { + code: ERROR_CODES.AUTH, + message: '升级账号失败' + }); } return true; } diff --git a/the-trash-rn/src/services/achievement.js b/the-trash-rn/src/services/achievement.js index 62cde26..649c7e9 100644 --- a/the-trash-rn/src/services/achievement.js +++ b/the-trash-rn/src/services/achievement.js @@ -1,8 +1,6 @@ -import { supabase } from './supabase'; +import { hasSupabaseConfig } from 'src/services/config'; -const hasSupabaseConfig = Boolean( - process.env.EXPO_PUBLIC_SUPABASE_URL && process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY -); +import { supabase } from './supabase'; const mapBadge = (achievement, ownedMap) => { const owned = ownedMap.get(achievement.id); @@ -38,7 +36,7 @@ const triggerKeysFromPayload = ({ trigger, stats }) => { export const achievementService = { async fetchBadges() { - if (!hasSupabaseConfig) { + if (!hasSupabaseConfig()) { return []; } const [allAchievementsResult, myAchievementsResult] = await Promise.all([ @@ -63,7 +61,9 @@ export const achievementService = { ]) ); - return (allAchievementsResult.data ?? []).map((item) => mapBadge(item, ownedMap)); + return (allAchievementsResult.data ?? []).map((item) => + mapBadge(item, ownedMap) + ); }, async fetchRewards() { @@ -75,7 +75,7 @@ export const achievementService = { }, async checkAndGrant(payload) { - if (!hasSupabaseConfig) { + if (!hasSupabaseConfig()) { return { unlocked: [], points: 0 }; } @@ -86,7 +86,9 @@ export const achievementService = { const unlocked = []; for (const key of triggerKeys) { - const result = await rpc('check_and_grant_achievement', { p_trigger_key: key }); + const result = await rpc('check_and_grant_achievement', { + p_trigger_key: key + }); if (result?.granted && result?.achievement_id) { unlocked.push({ id: result.achievement_id, diff --git a/the-trash-rn/src/services/admin.js b/the-trash-rn/src/services/admin.js index d83d758..642f6ab 100644 --- a/the-trash-rn/src/services/admin.js +++ b/the-trash-rn/src/services/admin.js @@ -1,8 +1,6 @@ -import { supabase } from './supabase'; +import { hasSupabaseConfig } from 'src/services/config'; -const hasSupabaseConfig = Boolean( - process.env.EXPO_PUBLIC_SUPABASE_URL && process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY -); +import { supabase } from './supabase'; const rpc = async (fn, args = {}) => { const { data, error } = await supabase.rpc(fn, args); @@ -18,7 +16,7 @@ const emptyDashboard = { export const adminService = { async fetchDashboard(communityId) { - if (!hasSupabaseConfig || !communityId) { + if (!hasSupabaseConfig() || !communityId) { return { ...emptyDashboard }; } const [requests, members, logs] = await Promise.all([ @@ -34,7 +32,7 @@ export const adminService = { }, async approveMember({ requestId, approve }) { - if (!hasSupabaseConfig) return false; + if (!hasSupabaseConfig()) return false; const data = await rpc('review_join_application', { p_application_id: requestId, p_approve: Boolean(approve), @@ -44,12 +42,12 @@ export const adminService = { }, async grantCredits() { - if (!hasSupabaseConfig) return false; + if (!hasSupabaseConfig()) return false; throw new Error('批量积分发放需基于活动,当前面板暂不支持此操作'); }, async removeMember({ communityId, memberId }) { - if (!hasSupabaseConfig) return false; + if (!hasSupabaseConfig()) return false; const data = await rpc('remove_community_member', { p_community_id: communityId, p_user_id: memberId, diff --git a/the-trash-rn/src/services/arena.js b/the-trash-rn/src/services/arena.js index 8d96f4c..d38e837 100644 --- a/the-trash-rn/src/services/arena.js +++ b/the-trash-rn/src/services/arena.js @@ -1,12 +1,15 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; +import { hasSupabaseConfig } from 'src/services/config'; +import { AppError, ERROR_CODES, fromSupabaseError } from 'src/utils/errors'; + import { supabase } from './supabase'; const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL; const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY; -const hasSupabaseConfig = Boolean(supabaseUrl && supabaseAnonKey); - +// Fallback quiz options (English) — these match the quiz_questions.correct_category +// schema, NOT the classifier's Chinese category names (可回收/湿垃圾/干垃圾/有害垃圾). const QUIZ_OPTIONS = ['Recyclable', 'Compostable', 'Hazardous', 'Landfill']; const SPEED_DURATION = 60; const CLASSIC_SESSIONS_STORAGE_KEY = 'the-trash/arena/classic-sessions-v1'; @@ -17,12 +20,6 @@ const classicSessions = new Map(); const speedSessions = new Map(); let sessionsHydrationPromise = null; -const getErrorMessage = (error) => { - if (error instanceof Error) return error.message; - if (typeof error === 'string') return error; - return 'Unknown error'; -}; - const normalizeAnswer = (value) => String(value ?? '') .trim() @@ -51,14 +48,21 @@ const formatQuestion = (row) => { const rpc = async (fn, args = {}) => { const { data, error } = await supabase.rpc(fn, args); if (error) { - throw new Error(error.message); + throw fromSupabaseError(error, { + message: '请求竞技场服务失败' + }); } return data; }; const getCurrentUserId = async () => { const { data, error } = await supabase.auth.getUser(); - if (error) throw new Error(error.message); + if (error) { + throw fromSupabaseError(error, { + code: ERROR_CODES.AUTH, + message: '读取用户信息失败' + }); + } return data.user?.id ?? null; }; @@ -161,21 +165,23 @@ export const arenaService = { }, async getCurrentUserId() { - if (!hasSupabaseConfig) return null; + if (!hasSupabaseConfig()) return null; return getCurrentUserId(); }, async fetchQuestion() { - if (!hasSupabaseConfig) return null; + if (!hasSupabaseConfig()) return null; const rows = await fetchQuestionBatch(1); return rows[0] ?? null; }, async startClassic() { await ensureSessionsHydrated(); - const questions = hasSupabaseConfig ? await fetchQuestionBatch(10) : []; + const questions = hasSupabaseConfig() ? await fetchQuestionBatch(10) : []; if (!questions.length) { - throw new Error('题库为空,请先检查 Supabase 题目数据'); + throw new AppError('题库为空,请先检查 Supabase 题目数据', { + code: ERROR_CODES.BACKEND + }); } const sessionId = `classic-${Date.now()}`; classicSessions.set(sessionId, { questions, index: 0 }); @@ -190,7 +196,9 @@ export const arenaService = { await ensureSessionsHydrated(); const session = classicSessions.get(sessionId); if (!session) { - throw new Error('经典模式会话不存在,请重新开始'); + throw new AppError('经典模式会话不存在,请重新开始', { + code: ERROR_CODES.VALIDATION + }); } const current = session.questions[session.index]; const isSameQuestion = current?.id === questionId; @@ -215,9 +223,11 @@ export const arenaService = { async startSpeedSort() { await ensureSessionsHydrated(); - const questions = hasSupabaseConfig ? await fetchQuestionBatch(80) : []; + const questions = hasSupabaseConfig() ? await fetchQuestionBatch(80) : []; if (!questions.length) { - throw new Error('没有可用题目,无法开始极速模式'); + throw new AppError('没有可用题目,无法开始极速模式', { + code: ERROR_CODES.BACKEND + }); } const sessionId = `speed-${Date.now()}`; speedSessions.set(sessionId, { questions, index: 0 }); @@ -233,7 +243,9 @@ export const arenaService = { await ensureSessionsHydrated(); const session = speedSessions.get(sessionId); if (!session) { - throw new Error('极速模式会话不存在,请重新开始'); + throw new AppError('极速模式会话不存在,请重新开始', { + code: ERROR_CODES.VALIDATION + }); } const current = session.questions[session.index]; const isSameQuestion = current?.id === questionId; @@ -252,7 +264,7 @@ export const arenaService = { }, async fetchDailyChallenge() { - if (!hasSupabaseConfig) { + if (!hasSupabaseConfig()) { return { id: null, prompt: '请先配置 Supabase', @@ -281,7 +293,7 @@ export const arenaService = { }, async submitDailyChallenge(payload = {}) { - if (!hasSupabaseConfig) return true; + if (!hasSupabaseConfig()) return true; const score = Number(payload.score ?? 100); const correctCount = Number(payload.correctCount ?? 10); const timeSeconds = Number(payload.timeSeconds ?? 60); @@ -296,7 +308,7 @@ export const arenaService = { }, async fetchStreakStats() { - if (!hasSupabaseConfig) { + if (!hasSupabaseConfig()) { return { best: 0, current: 0 }; } const userId = await getCurrentUserId(); @@ -310,7 +322,9 @@ export const arenaService = { .order('streak_count', { ascending: false }) .limit(1); if (error) { - throw new Error(error.message); + throw fromSupabaseError(error, { + message: '加载连击数据失败' + }); } return { best: data?.[0]?.streak_count ?? 0, @@ -319,7 +333,7 @@ export const arenaService = { }, async submitStreakAnswer({ finished, streakCount }) { - if (!hasSupabaseConfig) { + if (!hasSupabaseConfig()) { return { correct: true }; } if (!finished) { @@ -332,7 +346,7 @@ export const arenaService = { }, async fetchLeaderboards() { - if (!hasSupabaseConfig) { + if (!hasSupabaseConfig()) { return { daily: [], streak: [] }; } const [daily, streak] = await Promise.all([ @@ -356,7 +370,7 @@ export const arenaService = { }, async fetchPendingChallenges() { - if (!hasSupabaseConfig) { + if (!hasSupabaseConfig()) { return {}; } const userId = await getCurrentUserId(); @@ -379,7 +393,7 @@ export const arenaService = { }, async fetchFriends() { - if (!hasSupabaseConfig) { + if (!hasSupabaseConfig()) { return []; } const rows = await rpc('get_daily_leaderboard', { p_limit: 30 }); @@ -394,24 +408,27 @@ export const arenaService = { }, async sendInvite(friendId) { - if (!hasSupabaseConfig) { - throw new Error('请先连接 Supabase'); + if (!hasSupabaseConfig()) { + throw new AppError('请先连接 Supabase', { code: ERROR_CODES.BACKEND }); } const data = await rpc('create_arena_challenge', { p_opponent_id: friendId }); + if (!data?.challenge_id) { + throw new AppError('创建对战失败', { code: ERROR_CODES.BACKEND }); + } return { - id: data?.challenge_id, + id: data.challenge_id, opponentId: friendId, mode: 'duel', - status: data?.status ?? 'pending', - channelName: data?.channel_name + status: data.status ?? 'pending', + channelName: data.channel_name }; }, async acceptChallenge(challengeId) { - if (!hasSupabaseConfig) { - throw new Error('请先连接 Supabase'); + if (!hasSupabaseConfig()) { + throw new AppError('请先连接 Supabase', { code: ERROR_CODES.BACKEND }); } const data = await rpc('accept_arena_challenge', { p_challenge_id: challengeId @@ -427,7 +444,7 @@ export const arenaService = { }, async getChallengeQuestions(challengeId) { - if (!hasSupabaseConfig) { + if (!hasSupabaseConfig()) { return { questions: [], channelName: null }; } const data = await rpc('get_challenge_questions', { @@ -450,7 +467,7 @@ export const arenaService = { selectedCategory, answerTimeMs = 0 }) { - if (!hasSupabaseConfig) { + if (!hasSupabaseConfig()) { return { is_correct: false, correct_category: null }; } return rpc('submit_duel_answer', { @@ -462,19 +479,11 @@ export const arenaService = { }, async completeDuel(challengeId) { - if (!hasSupabaseConfig) { - return null; - } - try { - return await rpc('complete_arena_challenge', { - p_challenge_id: challengeId - }); - } catch (error) { - console.warn( - '[arenaService] complete duel failed', - getErrorMessage(error) - ); + if (!hasSupabaseConfig()) { return null; } + return rpc('complete_arena_challenge', { + p_challenge_id: challengeId + }); } }; diff --git a/the-trash-rn/src/services/auth.js b/the-trash-rn/src/services/auth.js index 96183e1..b5a58ff 100644 --- a/the-trash-rn/src/services/auth.js +++ b/the-trash-rn/src/services/auth.js @@ -1,9 +1,13 @@ -import { supabase } from './supabase'; +import { hasSupabaseConfig } from 'src/services/config'; +import { + AppError, + ERROR_CODES, + fromSupabaseError, + messageFromError +} from 'src/utils/errors'; import { normalizePhoneNumber } from 'src/utils/phone'; -const hasSupabaseConfig = Boolean( - process.env.EXPO_PUBLIC_SUPABASE_URL && process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY -); +import { supabase } from './supabase'; const buildProfile = (user, fallback = {}) => ({ id: user?.id ?? fallback.id ?? `demo-${Date.now()}`, @@ -34,7 +38,7 @@ export const authService = { async restoreSession() { const { data, error } = await supabase.auth.getSession(); if (error) { - console.warn('[auth] restoreSession failed', error.message); + console.warn('[auth] restoreSession failed', messageFromError(error)); return { session: null, profile: null }; } if (!data.session) { @@ -47,12 +51,18 @@ export const authService = { }, async signInWithEmail({ email, password }) { if (!email || !password) { - throw new Error('请输入邮箱和密码'); + throw new AppError('请输入邮箱和密码', { code: ERROR_CODES.VALIDATION }); } - if (hasSupabaseConfig) { - const { data, error } = await supabase.auth.signInWithPassword({ email, password }); + if (hasSupabaseConfig()) { + const { data, error } = await supabase.auth.signInWithPassword({ + email, + password + }); if (error) { - throw new Error(error.message); + throw fromSupabaseError(error, { + code: ERROR_CODES.AUTH, + message: '邮箱或密码错误' + }); } return { session: data.session, @@ -63,15 +73,18 @@ export const authService = { }, async signUpWithEmail({ email, password }) { if (!email || !password) { - throw new Error('请输入邮箱和密码'); + throw new AppError('请输入邮箱和密码', { code: ERROR_CODES.VALIDATION }); } if (password.length < 8) { - throw new Error('密码至少 8 位'); + throw new AppError('密码至少 8 位', { code: ERROR_CODES.VALIDATION }); } - if (hasSupabaseConfig) { + if (hasSupabaseConfig()) { const { data, error } = await supabase.auth.signUp({ email, password }); if (error) { - throw new Error(error.message); + throw fromSupabaseError(error, { + code: ERROR_CODES.AUTH, + message: '注册失败,请稍后再试' + }); } return { session: data.session ?? null, @@ -86,43 +99,60 @@ export const authService = { }, async signInWithPhone({ phone, code }) { if (!phone) { - throw new Error('请输入手机号'); + throw new AppError('请输入手机号', { code: ERROR_CODES.VALIDATION }); } if (!code) { - throw new Error('请输入验证码'); + throw new AppError('请输入验证码', { code: ERROR_CODES.VALIDATION }); } const normalizedPhone = normalizePhoneNumber(phone); if (!normalizedPhone) { - throw new Error('手机号格式不正确'); + throw new AppError('手机号格式不正确', { + code: ERROR_CODES.VALIDATION + }); } - if (hasSupabaseConfig) { + if (hasSupabaseConfig()) { const { data, error } = await supabase.auth.verifyOtp({ phone: normalizedPhone, token: code, type: 'sms' }); if (error) { - throw new Error(error.message); + throw fromSupabaseError(error, { + code: ERROR_CODES.AUTH, + message: '验证码无效或已过期' + }); } return { session: data.session, - profile: buildProfile(data.user ?? data.session?.user, { phone: normalizedPhone }) + profile: buildProfile(data.user ?? data.session?.user, { + phone: normalizedPhone + }) }; } - return fakeAuthResult({ phone: normalizedPhone, displayName: `${normalizedPhone} 用户` }); + return fakeAuthResult({ + phone: normalizedPhone, + displayName: `${normalizedPhone} 用户` + }); }, async requestPhoneCode(phone) { if (!phone) { - throw new Error('请输入手机号'); + throw new AppError('请输入手机号', { code: ERROR_CODES.VALIDATION }); } const normalizedPhone = normalizePhoneNumber(phone); if (!normalizedPhone) { - throw new Error('手机号格式不正确'); + throw new AppError('手机号格式不正确', { + code: ERROR_CODES.VALIDATION + }); } - if (hasSupabaseConfig) { - const { error } = await supabase.auth.signInWithOtp({ phone: normalizedPhone }); + if (hasSupabaseConfig()) { + const { error } = await supabase.auth.signInWithOtp({ + phone: normalizedPhone + }); if (error) { - throw new Error(error.message); + throw fromSupabaseError(error, { + code: ERROR_CODES.AUTH, + message: '验证码发送失败' + }); } } return true; @@ -130,7 +160,7 @@ export const authService = { async signOut() { const { error } = await supabase.auth.signOut(); if (error) { - console.warn('[auth] signOut failed', error.message); + console.warn('[auth] signOut failed', messageFromError(error)); } } }; diff --git a/the-trash-rn/src/services/classifier.js b/the-trash-rn/src/services/classifier.js index d49d5df..95b8508 100644 --- a/the-trash-rn/src/services/classifier.js +++ b/the-trash-rn/src/services/classifier.js @@ -192,9 +192,6 @@ class ClassifierService { ); this.ready = true; this.initializationError = null; - console.log( - `[classifier] knowledge base ready: ${this.knowledgeBase.length} vectors, dim=${this.dimension}` - ); } catch (error) { this.ready = false; this.initializationError = diff --git a/the-trash-rn/src/services/community.js b/the-trash-rn/src/services/community.js index 1ad5678..620ac12 100644 --- a/the-trash-rn/src/services/community.js +++ b/the-trash-rn/src/services/community.js @@ -1,14 +1,12 @@ -import { supabase } from './supabase'; - -const hasSupabaseConfig = Boolean( - process.env.EXPO_PUBLIC_SUPABASE_URL && process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY -); +import { hasSupabaseConfig } from 'src/services/config'; +import { + AppError, + ERROR_CODES, + fromSupabaseError, + messageFromError +} from 'src/utils/errors'; -const getErrorMessage = (error) => { - if (error instanceof Error) return error.message; - if (typeof error === 'string') return error; - return 'Unknown error'; -}; +import { supabase } from './supabase'; const toNumber = (value) => { const num = Number(value); @@ -31,18 +29,21 @@ const slugify = (value) => const rpc = async (fn, args = {}) => { const { data, error } = await supabase.rpc(fn, args); if (error) { - throw new Error(error.message); + throw fromSupabaseError(error, { + message: '请求失败,请稍后再试' + }); } return data; }; const resolveCity = (city) => { - if (!city) return { cityName: null, latitude: null, longitude: null, state: null }; + if (!city) + return { cityName: null, latitude: null, longitude: null, state: null }; if (typeof city === 'string') { return { cityName: city, latitude: null, longitude: null, state: null }; } return { - cityName: city.id ?? city.city ?? city.name ?? null, + cityName: city.city ?? city.name ?? city.id ?? null, latitude: toNumber(city.latitude), longitude: toNumber(city.longitude), state: city.state ?? null @@ -91,7 +92,11 @@ const getCityCoordinates = async (cityName) => { .eq('is_active', true) .limit(1) .maybeSingle(); - if (error) throw new Error(error.message); + if (error) { + throw fromSupabaseError(error, { + message: '读取城市坐标失败' + }); + } return { latitude: toNumber(data?.latitude), longitude: toNumber(data?.longitude) @@ -100,7 +105,7 @@ const getCityCoordinates = async (cityName) => { export const communityService = { async fetchCities() { - if (!hasSupabaseConfig) return []; + if (!hasSupabaseConfig()) return []; const { data, error } = await supabase .from('communities') .select('city,state,latitude,longitude') @@ -108,7 +113,9 @@ export const communityService = { .not('city', 'is', null) .order('city', { ascending: true }); if (error) { - throw new Error(error.message); + throw fromSupabaseError(error, { + message: '加载城市失败' + }); } const cityMap = new Map(); @@ -129,15 +136,19 @@ export const communityService = { }); return; } - if (existing.latitude == null && latitude != null) existing.latitude = latitude; - if (existing.longitude == null && longitude != null) existing.longitude = longitude; + if (existing.latitude == null && latitude != null) + existing.latitude = latitude; + if (existing.longitude == null && longitude != null) + existing.longitude = longitude; }); - return Array.from(cityMap.values()).sort((a, b) => a.name.localeCompare(b.name)); + return Array.from(cityMap.values()).sort((a, b) => + a.name.localeCompare(b.name) + ); }, async fetchEvents(city) { - if (!hasSupabaseConfig) return []; + if (!hasSupabaseConfig()) return []; const resolved = resolveCity(city); if (!resolved.cityName) return []; @@ -162,7 +173,7 @@ export const communityService = { }, async fetchGroups(city) { - if (!hasSupabaseConfig) return []; + if (!hasSupabaseConfig()) return []; const resolved = resolveCity(city); if (resolved.cityName) { const rows = await rpc('get_communities_by_city', { @@ -175,16 +186,24 @@ export const communityService = { .select('id,name,city,state,description,member_count,latitude,longitude') .eq('is_active', true) .order('member_count', { ascending: false }); - if (error) throw new Error(error.message); + if (error) { + throw fromSupabaseError(error, { + message: '加载社群失败' + }); + } return (data ?? []).map(formatGroup); }, async createEvent(payload) { - if (!hasSupabaseConfig) { - throw new Error('请先连接 Supabase'); + if (!hasSupabaseConfig()) { + throw new AppError('请先连接 Supabase', { code: ERROR_CODES.BACKEND }); } - const eventDate = payload.startTime ? new Date(payload.startTime) : new Date(Date.now() + 86400000); - const safeDate = Number.isNaN(eventDate.getTime()) ? new Date(Date.now() + 86400000) : eventDate; + const eventDate = payload.startTime + ? new Date(payload.startTime) + : new Date(Date.now() + 86400000); + const safeDate = Number.isNaN(eventDate.getTime()) + ? new Date(Date.now() + 86400000) + : eventDate; const data = await rpc('create_event', { p_title: payload.title, p_description: payload.description, @@ -198,10 +217,14 @@ export const communityService = { p_icon_name: payload.iconName ?? 'calendar' }); if (!data?.success) { - throw new Error(data?.message ?? '创建活动失败'); + throw new AppError(data?.message ?? '创建活动失败', { + code: ERROR_CODES.BACKEND + }); } if (!data?.event_id) { - throw new Error('活动创建成功但未返回活动 ID'); + throw new AppError('活动创建成功但未返回活动 ID', { + code: ERROR_CODES.BACKEND + }); } const event = await this.fetchEvent(data.event_id); if (!event) return null; @@ -212,10 +235,10 @@ export const communityService = { }, async createCommunity(payload) { - if (!hasSupabaseConfig) { - throw new Error('请先连接 Supabase'); + if (!hasSupabaseConfig()) { + throw new AppError('请先连接 Supabase', { code: ERROR_CODES.BACKEND }); } - const cityName = payload.cityId ?? payload.city ?? payload.cityName; + const cityName = payload.city ?? payload.cityName ?? payload.cityId; const baseId = slugify(`${payload.name}-${cityName}`); const communityId = `${baseId}-${Date.now().toString().slice(-6)}`; const data = await rpc('create_community', { @@ -228,65 +251,84 @@ export const communityService = { p_longitude: toNumber(payload.longitude) }); if (!data?.success) { - throw new Error(data?.message ?? '创建社群失败'); + throw new AppError(data?.message ?? '创建社群失败', { + code: ERROR_CODES.BACKEND + }); } return this.fetchCommunity(communityId); }, async fetchCommunity(id) { - if (!id || !hasSupabaseConfig) return null; + if (!id || !hasSupabaseConfig()) return null; const { data, error } = await supabase .from('communities') .select('id,name,city,state,description,member_count,latitude,longitude') .eq('id', id) .maybeSingle(); - if (error) throw new Error(error.message); + if (error) { + throw fromSupabaseError(error, { + message: '加载社群详情失败' + }); + } return data ? formatGroup(data) : null; }, async fetchEvent(id) { - if (!id || !hasSupabaseConfig) return null; + if (!id || !hasSupabaseConfig()) return null; const { data, error } = await supabase .from('community_events') - .select('id,title,description,category,event_date,location,latitude,longitude,icon_name,max_participants,participant_count,community_id') + .select( + 'id,title,description,category,event_date,location,latitude,longitude,icon_name,max_participants,participant_count,community_id' + ) .eq('id', id) .maybeSingle(); - if (error) throw new Error(error.message); + if (error) { + throw fromSupabaseError(error, { + message: '加载活动详情失败' + }); + } return data ? formatEvent(data) : null; }, async joinCommunity(id) { - if (!id || !hasSupabaseConfig) return null; + if (!id || !hasSupabaseConfig()) return null; const result = await rpc('apply_to_join_community', { p_community_id: id, p_message: null }); if (!result?.success) { - throw new Error(result?.message ?? '加入社群失败'); + throw new AppError(result?.message ?? '加入社群失败', { + code: ERROR_CODES.BACKEND + }); } return true; }, async rsvpEvent(id) { - if (!id || !hasSupabaseConfig) return null; + if (!id || !hasSupabaseConfig()) return null; const result = await rpc('register_for_event', { p_event_id: id }); if (!result?.success) { - throw new Error(result?.message ?? '报名失败'); + throw new AppError(result?.message ?? '报名失败', { + code: ERROR_CODES.BACKEND + }); } return this.fetchEvent(id); }, async adminDashboard(communityId) { - if (!communityId || !hasSupabaseConfig) { + if (!communityId || !hasSupabaseConfig()) { return { requests: [], members: [], logs: [] }; } try { const [requests, members, logs] = await Promise.all([ rpc('get_pending_applications', { p_community_id: communityId }), rpc('get_community_members_admin', { p_community_id: communityId }), - rpc('get_admin_action_logs', { p_community_id: communityId, p_limit: 50 }) + rpc('get_admin_action_logs', { + p_community_id: communityId, + p_limit: 50 + }) ]); return { requests: requests ?? [], @@ -294,7 +336,10 @@ export const communityService = { logs: logs ?? [] }; } catch (error) { - console.warn('[communityService] adminDashboard failed', getErrorMessage(error)); + console.warn( + '[communityService] adminDashboard failed', + messageFromError(error, '加载管理数据失败') + ); return { requests: [], members: [], logs: [] }; } } diff --git a/the-trash-rn/src/services/config.js b/the-trash-rn/src/services/config.js new file mode 100644 index 0000000..37e5c28 --- /dev/null +++ b/the-trash-rn/src/services/config.js @@ -0,0 +1,5 @@ +export const hasSupabaseConfig = () => + Boolean( + process.env.EXPO_PUBLIC_SUPABASE_URL && + process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY + ); diff --git a/the-trash-rn/src/services/feedback.js b/the-trash-rn/src/services/feedback.js index 081d483..c96bfa8 100644 --- a/the-trash-rn/src/services/feedback.js +++ b/the-trash-rn/src/services/feedback.js @@ -1,9 +1,8 @@ +import { hasSupabaseConfig } from 'src/services/config'; + import { supabase } from './supabase'; const EDGE_FEEDBACK_FUNCTION = 'verify-feedback'; -const hasSupabaseConfig = Boolean( - process.env.EXPO_PUBLIC_SUPABASE_URL && process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY -); export const feedbackService = { async submitFeedback({ resultId, correction, note, photo }) { @@ -11,20 +10,22 @@ export const feedbackService = { throw new Error('缺少反馈内容'); } - if (!hasSupabaseConfig) { - console.log('[feedback] mock submit', { resultId, correction, note }); - return { mocked: true }; + if (!hasSupabaseConfig()) { + return { success: true, mocked: true }; } try { - const { data, error } = await supabase.functions.invoke(EDGE_FEEDBACK_FUNCTION, { - body: { - resultId, - correction, - note, - photo + const { data, error } = await supabase.functions.invoke( + EDGE_FEEDBACK_FUNCTION, + { + body: { + resultId, + correction, + note, + photo + } } - }); + ); if (error) { throw error; } diff --git a/the-trash-rn/src/services/leaderboard.js b/the-trash-rn/src/services/leaderboard.js index de5e5ff..c74e203 100644 --- a/the-trash-rn/src/services/leaderboard.js +++ b/the-trash-rn/src/services/leaderboard.js @@ -1,19 +1,16 @@ import Contacts from 'react-native-contacts'; +import { hasSupabaseConfig } from 'src/services/config'; +import { AppError, ERROR_CODES, fromSupabaseError } from 'src/utils/errors'; import { normalizePhoneNumber } from 'src/utils/phone'; import { supabase } from './supabase'; -const hasSupabaseConfig = Boolean( - process.env.EXPO_PUBLIC_SUPABASE_URL && - process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY -); +const isSupabaseEnabled = () => + process.env.NODE_ENV === 'test' || hasSupabaseConfig(); -const getErrorMessage = (error) => { - if (error instanceof Error) return error.message; - if (typeof error === 'string') return error; - return 'Unknown error'; -}; +const CONTACT_EMAIL_LIMIT = 300; +const CONTACT_PHONE_LIMIT = 300; const mapCommunityEntry = (item, currentUserId) => { const entryId = item.id ?? item.user_id ?? null; @@ -46,23 +43,32 @@ const mapMyCommunity = (item) => ({ const getCurrentUserId = async () => { const { data, error } = await supabase.auth.getUser(); if (error) { - throw new Error(error.message); + throw fromSupabaseError(error, { + code: ERROR_CODES.AUTH, + message: '读取用户信息失败' + }); } return data.user?.id ?? null; }; -const requestContactsPermission = async () => { +const requestContactsPermission = async ({ + allowPermissionPrompt = true +} = {}) => { const status = await Contacts.checkPermission(); if (status === 'authorized') return true; + if (!allowPermissionPrompt) return false; const nextStatus = await Contacts.requestPermission(); return nextStatus === 'authorized'; }; -const readContactsPayload = async () => { - const granted = await requestContactsPermission(); +const readContactsPayload = async ({ allowPermissionPrompt = true } = {}) => { + const granted = await requestContactsPermission({ allowPermissionPrompt }); if (!granted) { - throw new Error('通讯录权限被拒绝'); + throw new AppError('需要通讯录权限后才能同步好友榜', { + code: ERROR_CODES.CONTACTS_PERMISSION_REQUIRED + }); } + const contacts = await Contacts.getAll(); const emails = new Set(); const phones = new Set(); @@ -80,26 +86,44 @@ const readContactsPayload = async () => { }); }); + const minimizedEmails = Array.from(emails).slice(0, CONTACT_EMAIL_LIMIT); + const minimizedPhones = Array.from(phones).slice(0, CONTACT_PHONE_LIMIT); + + if (!minimizedEmails.length && !minimizedPhones.length) { + throw new AppError('通讯录里没有可匹配的邮箱或手机号', { + code: ERROR_CODES.CONTACTS_EMPTY + }); + } + return { - emails: Array.from(emails), - phones: Array.from(phones) + emails: minimizedEmails, + phones: minimizedPhones, + stats: { + emailCount: minimizedEmails.length, + phoneCount: minimizedPhones.length, + contactCount: contacts.length + } }; }; -const fetchFriendsLeaderboard = async () => { - const payload = await readContactsPayload(); - if (!payload.emails.length && !payload.phones.length) { - return []; - } +const fetchFriendsLeaderboard = async ({ + allowPermissionPrompt = true +} = {}) => { + const payload = await readContactsPayload({ allowPermissionPrompt }); const { data, error } = await supabase.rpc('find_friends_leaderboard', { p_emails: payload.emails, p_phones: payload.phones }); if (error) { - throw new Error(error.message); + throw fromSupabaseError(error, { + message: '同步好友榜失败' + }); } const currentUserId = await getCurrentUserId(); - return (data ?? []).map((item) => mapFriendEntry(item, currentUserId)); + return { + entries: (data ?? []).map((item) => mapFriendEntry(item, currentUserId)), + syncStats: payload.stats + }; }; const fetchCommunityLeaderboard = async (communityId) => { @@ -108,7 +132,9 @@ const fetchCommunityLeaderboard = async (communityId) => { p_limit: 100 }); if (error) { - throw new Error(error.message); + throw fromSupabaseError(error, { + message: '加载社群排行榜失败' + }); } const currentUserId = await getCurrentUserId(); return (data ?? []).map((item) => mapCommunityEntry(item, currentUserId)); @@ -116,31 +142,31 @@ const fetchCommunityLeaderboard = async (communityId) => { export const leaderboardService = { async fetchMyCommunities() { - if (!hasSupabaseConfig) { + if (!isSupabaseEnabled()) { return []; } const { data, error } = await supabase.rpc('get_my_communities'); if (error) { - throw new Error(error.message); + throw fromSupabaseError(error, { + message: '加载我的社群失败' + }); } return (data ?? []).map(mapMyCommunity); }, async fetch(filter = 'community', options = {}) { - if (!hasSupabaseConfig) { + if (!isSupabaseEnabled()) { return []; } if (filter === 'friends') { - try { - return await fetchFriendsLeaderboard(); - } catch (error) { - console.warn( - '[leaderboardService] fetch friends failed', - getErrorMessage(error) - ); + if (!options.explicitSync) { return []; } + const result = await fetchFriendsLeaderboard({ + allowPermissionPrompt: options.allowPermissionPrompt !== false + }); + return result.entries; } const communityId = options.communityId; @@ -148,21 +174,20 @@ export const leaderboardService = { return []; } - try { - return await fetchCommunityLeaderboard(communityId); - } catch (error) { - console.warn( - '[leaderboardService] fetch community failed', - getErrorMessage(error) - ); - return []; - } + return fetchCommunityLeaderboard(communityId); }, - async syncContacts() { - if (!hasSupabaseConfig) { - return []; + async syncContacts(options = {}) { + if (!isSupabaseEnabled()) { + return { entries: [], syncStats: null }; } - return fetchFriendsLeaderboard(); + return fetchFriendsLeaderboard({ + allowPermissionPrompt: options.allowPermissionPrompt !== false + }); } }; + +export const leaderboardPrivacy = { + maxEmailsPerSync: CONTACT_EMAIL_LIMIT, + maxPhonesPerSync: CONTACT_PHONE_LIMIT +}; diff --git a/the-trash-rn/src/services/supabase.js b/the-trash-rn/src/services/supabase.js index 33859ea..f5c63f5 100644 --- a/the-trash-rn/src/services/supabase.js +++ b/the-trash-rn/src/services/supabase.js @@ -1,5 +1,5 @@ -import { createClient } from '@supabase/supabase-js'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import { createClient } from '@supabase/supabase-js'; const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL; const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY; @@ -13,6 +13,6 @@ export const supabase = createClient(supabaseUrl ?? '', supabaseAnonKey ?? '', { storage: AsyncStorage, autoRefreshToken: true, persistSession: true, - detectSessionInUrl: false, + detectSessionInUrl: false } }); diff --git a/the-trash-rn/src/stores/__tests__/arenaStore.test.js b/the-trash-rn/src/stores/__tests__/arenaStore.test.js new file mode 100644 index 0000000..9d2a8a4 --- /dev/null +++ b/the-trash-rn/src/stores/__tests__/arenaStore.test.js @@ -0,0 +1,100 @@ +const setupArenaStore = () => { + jest.resetModules(); + + const arenaService = { + startClassic: jest.fn().mockResolvedValue({ + sessionId: 'classic-1', + question: { id: 'q-1', prompt: 'Q1' } + }), + fetchServerTimeOffset: jest.fn().mockResolvedValue(0), + acceptChallenge: jest.fn().mockResolvedValue({ + duelId: 'duel-1', + challenge_id: 'duel-1', + channelName: 'duel:duel-1', + questions: [{ id: 'dq-1', prompt: 'DQ1' }], + challengerId: 'me', + opponentId: 'u-2' + }), + getChallengeQuestions: jest.fn(), + getCurrentUserId: jest.fn().mockResolvedValue('me'), + fetchPendingChallenges: jest.fn().mockResolvedValue({}), + fetchLeaderboards: jest.fn().mockResolvedValue({ daily: [], streak: [] }), + fetchFriends: jest.fn().mockResolvedValue([]), + sendInvite: jest.fn().mockResolvedValue({ + id: 'challenge-1', + opponentId: 'u-2' + }), + submitDuelAnswer: jest.fn(), + completeDuel: jest.fn().mockResolvedValue(null), + fetchQuestion: jest.fn().mockResolvedValue(null), + submitClassic: jest.fn(), + startSpeedSort: jest.fn(), + submitSpeedAnswer: jest.fn() + }; + + const realtimeService = { + joinDuel: jest.fn().mockReturnValue({ + send: jest.fn(), + sendReady: jest.fn(), + sendAnswerSubmitted: jest.fn(), + sendFinished: jest.fn(), + unsubscribe: jest.fn() + }) + }; + + jest.doMock('src/services/arena', () => ({ arenaService })); + jest.doMock('src/services/realtime', () => ({ realtimeService })); + jest.doMock('src/services/dailyChallenge', () => ({ + dailyChallengeService: { + fetch: jest.fn().mockResolvedValue({ + id: 'daily-1', + alreadyPlayed: false, + total: 3, + progress: 0 + }), + submit: jest.fn().mockResolvedValue(true) + } + })); + jest.doMock('src/services/streakMode', () => ({ + streakModeService: { + fetchStats: jest.fn().mockResolvedValue({ best: 0, current: 0 }), + submitAnswer: jest.fn().mockResolvedValue(true) + } + })); + jest.doMock('src/stores/achievementStore', () => ({ + useAchievementStore: { + getState: () => ({ + checkAndGrant: jest.fn() + }) + } + })); + + const { useArenaStore } = require('src/stores/arenaStore'); + return { useArenaStore, arenaService, realtimeService }; +}; + +describe('arenaStore slices', () => { + test('solo slice methods update classic mode state', async () => { + const { useArenaStore, arenaService } = setupArenaStore(); + + await useArenaStore.getState().startClassic(); + + const state = useArenaStore.getState(); + expect(state.classic.state).toBe('playing'); + expect(state.classic.sessionId).toBe('classic-1'); + expect(arenaService.startClassic).toHaveBeenCalledTimes(1); + }); + + test('duel slice methods initialize duel session from acceptChallenge', async () => { + const { useArenaStore, realtimeService } = setupArenaStore(); + + await useArenaStore.getState().acceptChallenge('duel-1'); + + const duel = useArenaStore.getState().duels['duel-1']; + expect(duel).toBeTruthy(); + expect(duel.channelName).toBe('duel:duel-1'); + expect(duel.totalQuestions).toBe(1); + expect(typeof duel.sendReady).toBe('function'); + expect(realtimeService.joinDuel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/the-trash-rn/src/stores/__tests__/authStore.test.js b/the-trash-rn/src/stores/__tests__/authStore.test.js new file mode 100644 index 0000000..9cb2345 --- /dev/null +++ b/the-trash-rn/src/stores/__tests__/authStore.test.js @@ -0,0 +1,95 @@ +const { AppError, ERROR_CODES } = require('src/utils/errors'); + +const setupStore = ({ + restoreSessionResult = { session: null, profile: null }, + signInError = null +} = {}) => { + jest.resetModules(); + + const authServiceMock = { + restoreSession: jest.fn().mockResolvedValue(restoreSessionResult), + signInWithEmail: signInError + ? jest.fn().mockRejectedValue(signInError) + : jest.fn().mockResolvedValue({ + session: { user: { id: 'u-1' } }, + profile: { id: 'u-1', displayName: 'Tester', level: 1 } + }), + signUpWithEmail: jest.fn(), + signInWithPhone: jest.fn(), + requestPhoneCode: jest.fn(), + signOut: jest.fn().mockResolvedValue(undefined) + }; + + const onAuthStateChangeMock = jest.fn(() => ({ + data: { + subscription: { + unsubscribe: jest.fn() + } + } + })); + + jest.doMock('src/services/auth', () => ({ + authService: authServiceMock + })); + jest.doMock('src/services/supabase', () => ({ + supabase: { + auth: { + onAuthStateChange: onAuthStateChangeMock + } + } + })); + + const { useAuthStore } = require('src/stores/authStore'); + return { useAuthStore, authServiceMock, onAuthStateChangeMock }; +}; + +describe('authStore', () => { + test('bootstrap uses supabase session as single source of truth', async () => { + const { useAuthStore, authServiceMock, onAuthStateChangeMock } = setupStore( + { + restoreSessionResult: { + session: { user: { id: 'u-1', email: 'user@example.com' } }, + profile: { id: 'u-1', displayName: 'User', level: 3 } + } + } + ); + + await useAuthStore.getState().bootstrap(); + + const state = useAuthStore.getState(); + expect(state.status).toBe('authenticated'); + expect(state.session.user.id).toBe('u-1'); + expect(authServiceMock.restoreSession).toHaveBeenCalledTimes(1); + expect(onAuthStateChangeMock).toHaveBeenCalledTimes(1); + }); + + test('signInWithEmail failure stores normalized error', async () => { + const { useAuthStore } = setupStore({ + signInError: new AppError('邮箱或密码错误', { code: ERROR_CODES.AUTH }) + }); + + await expect( + useAuthStore.getState().signInWithEmail({ + email: 'bad@example.com', + password: 'wrong' + }) + ).rejects.toBeTruthy(); + + expect(useAuthStore.getState().error).toBe('邮箱或密码错误'); + expect(useAuthStore.getState().authenticating).toBe(false); + }); + + test('refreshSession keeps guest profile when configured', async () => { + const { useAuthStore } = setupStore({ + restoreSessionResult: { session: null, profile: null } + }); + + useAuthStore.getState().signInAsGuest(); + await useAuthStore.getState().refreshSession({ + keepGuestOnMissingSession: true + }); + + expect(useAuthStore.getState().status).toBe('guest'); + expect(useAuthStore.getState().profile.id).toBe('guest'); + }); +}); diff --git a/the-trash-rn/src/stores/achievementStore.js b/the-trash-rn/src/stores/achievementStore.js index 388e1a4..f9f52fb 100644 --- a/the-trash-rn/src/stores/achievementStore.js +++ b/the-trash-rn/src/stores/achievementStore.js @@ -1,4 +1,5 @@ import { create } from 'zustand'; + import { achievementService } from 'src/services/achievement'; const initialStats = { @@ -53,14 +54,22 @@ export const useAchievementStore = create((set, get) => ({ ]); if (badgesResult.status === 'rejected') { - console.log('[achievementStore] badges load failed', badgesResult.reason); + console.warn( + '[achievementStore] badges load failed', + badgesResult.reason + ); } if (rewardsResult.status === 'rejected') { - console.log('[achievementStore] rewards load failed', rewardsResult.reason); + console.warn( + '[achievementStore] rewards load failed', + rewardsResult.reason + ); } - const badges = badgesResult.status === 'fulfilled' ? badgesResult.value : []; - const rewards = rewardsResult.status === 'fulfilled' ? rewardsResult.value : []; + const badges = + badgesResult.status === 'fulfilled' ? badgesResult.value : []; + const rewards = + rewardsResult.status === 'fulfilled' ? rewardsResult.value : []; const equipped = badges.find((badge) => badge.equipped)?.id ?? null; const points = badges.filter((badge) => badge.unlocked).length * 25; set({ badges, rewards, equippedBadgeId: equipped, points, loading: false }); @@ -106,7 +115,9 @@ export const useAchievementStore = create((set, get) => ({ const timestamp = new Date().toISOString(); set((state) => { const previouslyUnlocked = new Set( - state.badges.filter((badge) => badge.unlocked).map((badge) => badge.id) + state.badges + .filter((badge) => badge.unlocked) + .map((badge) => badge.id) ); const mergedBadges = state.badges.map((badge) => unlocked?.some((item) => item.id === badge.id) @@ -114,7 +125,10 @@ export const useAchievementStore = create((set, get) => ({ : badge ); const appendedBadges = (unlocked ?? []) - .filter((badge) => !state.badges.some((existing) => existing.id === badge.id)) + .filter( + (badge) => + !state.badges.some((existing) => existing.id === badge.id) + ) .map((badge) => ({ ...badge, unlocked: true })); const badges = [...mergedBadges, ...appendedBadges]; const newToasts = (unlocked ?? []) diff --git a/the-trash-rn/src/stores/arena/duelInternals.js b/the-trash-rn/src/stores/arena/duelInternals.js new file mode 100644 index 0000000..e0f4e72 --- /dev/null +++ b/the-trash-rn/src/stores/arena/duelInternals.js @@ -0,0 +1,141 @@ +import { DUEL_GC_INTERVAL_MS, DUEL_STALE_SESSION_MS } from './shared'; + +const duelCountdownTimers = new Map(); +const duelEventQueues = new Map(); +let duelGcInterval = null; + +export const clearDuelCountdown = (duelId) => { + const timer = duelCountdownTimers.get(duelId); + if (timer) { + clearInterval(timer); + duelCountdownTimers.delete(duelId); + } +}; + +export const registerDuelCountdown = (duelId, timer) => { + duelCountdownTimers.set(duelId, timer); +}; + +export const queueDuelEvent = (duelId, task) => { + const previous = duelEventQueues.get(duelId) ?? Promise.resolve(); + const next = previous + .catch(() => {}) + .then(task) + .catch((error) => { + console.warn('[arenaStore] duel event queue error', duelId, error); + }); + + duelEventQueues.set( + duelId, + next.finally(() => { + if (duelEventQueues.get(duelId) === next) { + duelEventQueues.delete(duelId); + } + }) + ); + + return next; +}; + +export const clearQueuedDuelEvents = (duelId) => { + duelEventQueues.delete(duelId); +}; + +export const hasDuelCountdown = (duelId) => duelCountdownTimers.has(duelId); + +export const createDuelState = (duelId, submit) => ({ + id: duelId, + status: 'loading', + opponent: '等待对手', + countdown: 0, + countdownStartAtServerMs: null, + questions: [], + totalQuestions: 0, + currentIndex: 0, + currentQuestion: null, + score: 0, + correctCount: 0, + myReady: false, + opponentReady: false, + bothReady: false, + opponentProgress: 0, + opponentCorrect: 0, + opponentScore: 0, + opponentFinished: false, + opponentOnline: false, + hasFinished: false, + awaitingResult: false, + finalizing: false, + submitting: false, + realtimeStatus: 'idle', + channelName: null, + challengerId: null, + opponentId: null, + myUserId: null, + result: null, + error: null, + send: null, + sendReady: null, + sendAnswerSubmitted: null, + sendFinished: null, + unsubscribe: null, + submit, + createdAt: Date.now(), + updatedAt: Date.now() +}); + +export const patchDuel = (state, duelId, patch) => { + const duel = state.duels[duelId]; + if (!duel) return null; + return { + ...state.duels, + [duelId]: { + ...duel, + ...patch, + updatedAt: Date.now() + } + }; +}; + +const ACTIVE_DUEL_STATUSES = new Set(['playing', 'countdown', 'finalizing']); + +export const stopDuelWatchdog = () => { + if (duelGcInterval) { + clearInterval(duelGcInterval); + duelGcInterval = null; + } +}; + +export const ensureDuelWatchdog = (get, set) => { + if (duelGcInterval) return; + + duelGcInterval = setInterval(() => { + const now = Date.now(); + const state = get(); + const staleIds = Object.entries(state.duels) + .filter(([, duel]) => { + if (!duel) return false; + if (ACTIVE_DUEL_STATUSES.has(duel.status)) return false; + const updatedAt = Number(duel.updatedAt ?? duel.createdAt ?? now); + return now - updatedAt > DUEL_STALE_SESSION_MS; + }) + .map(([id]) => id); + + if (!staleIds.length) return; + + staleIds.forEach((duelId) => { + clearDuelCountdown(duelId); + const duel = get().duels[duelId]; + duel?.unsubscribe?.(); + clearQueuedDuelEvents(duelId); + }); + + set((current) => { + const duels = { ...current.duels }; + staleIds.forEach((duelId) => { + delete duels[duelId]; + }); + return { duels }; + }); + }, DUEL_GC_INTERVAL_MS); +}; diff --git a/the-trash-rn/src/stores/arena/duelSlice.js b/the-trash-rn/src/stores/arena/duelSlice.js new file mode 100644 index 0000000..55109df --- /dev/null +++ b/the-trash-rn/src/stores/arena/duelSlice.js @@ -0,0 +1,727 @@ +import { arenaService } from 'src/services/arena'; +import { realtimeService } from 'src/services/realtime'; + +import { + clearDuelCountdown, + clearQueuedDuelEvents, + createDuelState, + ensureDuelWatchdog, + hasDuelCountdown, + patchDuel, + queueDuelEvent, + registerDuelCountdown +} from './duelInternals'; +import { + DUEL_CLOCK_OFFSET_CACHE_MS, + DUEL_COMPLETE_MAX_ATTEMPTS, + DUEL_COMPLETE_RETRY_MS, + DUEL_COUNTDOWN_SECONDS, + computeCountdownSeconds, + getEstimatedServerNow, + isSameId, + notifyAchievement, + resolveOpponentId, + sleep, + toNumber +} from './shared'; + +export const createDuelArenaSlice = (set, get) => { + if (process.env.NODE_ENV !== 'test') { + ensureDuelWatchdog(get, set); + } + + return { + duels: {}, + serverTimeOffsetMs: 0, + serverTimeOffsetFetchedAt: 0, + + async syncServerTimeOffset({ force = false } = {}) { + const cachedAt = Number(get().serverTimeOffsetFetchedAt ?? 0); + const now = Date.now(); + if ( + !force && + cachedAt > 0 && + now - cachedAt < DUEL_CLOCK_OFFSET_CACHE_MS + ) { + return Number(get().serverTimeOffsetMs ?? 0); + } + + const offsetMs = await arenaService.fetchServerTimeOffset(); + set({ + serverTimeOffsetMs: Number(offsetMs ?? 0), + serverTimeOffsetFetchedAt: Date.now() + }); + return Number(offsetMs ?? 0); + }, + + async acceptChallenge(challengeId) { + const payload = await arenaService.acceptChallenge(challengeId); + const duelId = payload?.duelId ?? challengeId; + + set((state) => { + const duel = + state.duels[duelId] ?? + createDuelState(duelId, (option) => + get().submitDuelAnswer(duelId, option) + ); + return { + duels: { + ...state.duels, + [duelId]: { + ...duel, + status: 'lobby', + channelName: payload?.channelName ?? duel.channelName, + challengerId: payload?.challengerId ?? duel.challengerId, + opponentId: payload?.opponentId ?? duel.opponentId, + questions: payload?.questions ?? duel.questions, + totalQuestions: (payload?.questions ?? duel.questions ?? []) + .length, + currentQuestion: + (payload?.questions ?? duel.questions)?.[0] ?? null, + currentIndex: 0, + hasFinished: false, + awaitingResult: false, + finalizing: false, + error: null + } + } + }; + }); + + await get().ensureDuel(duelId, { preloadedPayload: payload }); + await get().refreshChallenges(); + return { + ...payload, + duelId + }; + }, + + async ensureDuel(duelId, options = {}) { + if (!duelId) return null; + + const existing = get().duels[duelId]; + if (!existing) { + set((state) => ({ + duels: { + ...state.duels, + [duelId]: createDuelState(duelId, (option) => + get().submitDuelAnswer(duelId, option) + ) + } + })); + } + + let payload = options.preloadedPayload ?? null; + try { + if (!payload) { + payload = await arenaService.getChallengeQuestions(duelId); + } + } catch (error) { + console.warn('[arenaStore] getChallengeQuestions failed', error); + set((state) => { + const duels = patchDuel(state, duelId, { + status: 'lobby', + error: '无法加载对战题目' + }); + return duels ? { duels } : {}; + }); + return get().duels[duelId] ?? null; + } + + const myUserId = + payload?.myUserId ?? (await arenaService.getCurrentUserId()); + const challengerId = payload?.challengerId ?? null; + const opponentId = payload?.opponentId ?? null; + const remoteOpponentId = resolveOpponentId({ + myUserId, + challengerId, + opponentId + }); + const channelName = payload?.channelName ?? `duel:${duelId}`; + const pending = get().pendingChallenges[duelId]; + + const onPlayerReady = ({ userId, isOpponent }) => { + queueDuelEvent(duelId, async () => { + if (!userId) return; + const latest = get().duels[duelId]; + if (!latest) return; + if (isSameId(userId, latest.myUserId ?? myUserId)) return; + + const opponentReady = Boolean( + isOpponent || + !remoteOpponentId || + latest.opponentReady || + isSameId(userId, latest.opponentId) || + isSameId(userId, latest.challengerId) + ); + const bothReady = Boolean(latest.myReady && opponentReady); + + set((state) => { + const duel = state.duels[duelId]; + if (!duel) return {}; + return { + duels: { + ...state.duels, + [duelId]: { + ...duel, + opponentReady, + bothReady + } + } + }; + }); + + if (bothReady) { + await get().beginSynchronizedCountdown(duelId); + } + }); + }; + + const onAnswerSubmitted = ({ userId, questionIndex, isCorrect }) => { + queueDuelEvent(duelId, async () => { + const latest = get().duels[duelId]; + if (!latest) return; + if (!userId || isSameId(userId, latest.myUserId ?? myUserId)) return; + + set((state) => { + const duel = state.duels[duelId]; + if (!duel) return {}; + const safeIndex = Number.isFinite(questionIndex) + ? questionIndex + : 0; + return { + duels: { + ...state.duels, + [duelId]: { + ...duel, + opponentProgress: Math.max( + duel.opponentProgress, + safeIndex + 1 + ), + opponentCorrect: isCorrect + ? duel.opponentCorrect + 1 + : duel.opponentCorrect, + opponentScore: isCorrect + ? duel.opponentScore + 20 + : duel.opponentScore + } + } + }; + }); + }); + }; + + const onPlayerFinished = ({ userId, totalCorrect, totalScore }) => { + queueDuelEvent(duelId, async () => { + const latest = get().duels[duelId]; + if (!latest) return; + if (!userId || isSameId(userId, latest.myUserId ?? myUserId)) return; + + set((state) => { + const duel = state.duels[duelId]; + if (!duel) return {}; + const nextStatus = duel.hasFinished + ? 'finalizing' + : 'waiting-result'; + return { + duels: { + ...state.duels, + [duelId]: { + ...duel, + opponentFinished: true, + opponentProgress: + duel.totalQuestions > 0 + ? Math.max(duel.opponentProgress, duel.totalQuestions) + : duel.opponentProgress, + opponentCorrect: Number.isFinite(totalCorrect) + ? totalCorrect + : duel.opponentCorrect, + opponentScore: Number.isFinite(totalScore) + ? totalScore + : duel.opponentScore, + status: nextStatus, + awaitingResult: duel.hasFinished + } + } + }; + }); + + await get().maybeFinalizeDuel(duelId); + }); + }; + + const onPresence = ({ opponentOnline }) => { + queueDuelEvent(duelId, async () => { + set((state) => { + const duels = patchDuel(state, duelId, { opponentOnline }); + return duels ? { duels } : {}; + }); + }); + }; + + const onStatusChange = (status) => { + queueDuelEvent(duelId, async () => { + set((state) => { + const duels = patchDuel(state, duelId, { + realtimeStatus: String(status ?? 'unknown') + }); + return duels ? { duels } : {}; + }); + }); + }; + + const onState = (payloadState) => { + queueDuelEvent(duelId, async () => { + if (!payloadState || typeof payloadState !== 'object') return; + const nextPatch = {}; + + if (typeof payloadState.status === 'string') { + nextPatch.status = payloadState.status; + } + if (Number.isFinite(payloadState.countdown)) { + nextPatch.countdown = payloadState.countdown; + } + if (Number.isFinite(payloadState.currentIndex)) { + nextPatch.opponentProgress = Math.max( + 0, + Number(payloadState.currentIndex) + 1 + ); + } + if (Number.isFinite(payloadState.score)) { + nextPatch.opponentScore = Number(payloadState.score); + } + + const startAtServerMs = toNumber(payloadState.startAtServerMs); + if (startAtServerMs != null) { + nextPatch.countdownStartAtServerMs = startAtServerMs; + nextPatch.status = 'countdown'; + } + + if (!Object.keys(nextPatch).length) return; + + set((state) => { + const duels = patchDuel(state, duelId, nextPatch); + return duels ? { duels } : {}; + }); + + if (startAtServerMs != null) { + await get().beginSynchronizedCountdown(duelId, startAtServerMs); + } + }); + }; + + clearQueuedDuelEvents(duelId); + const existingDuel = get().duels[duelId]; + existingDuel?.unsubscribe?.(); + + const realtime = realtimeService.joinDuel( + duelId, + { + onState, + onPlayerReady, + onAnswerSubmitted, + onPlayerFinished, + onPresence, + onStatusChange + }, + { + channelName, + myUserId, + opponentUserId: remoteOpponentId + } + ); + + const questions = payload?.questions ?? existingDuel?.questions ?? []; + const hasQuestions = questions.length > 0; + + set((state) => { + const duel = state.duels[duelId]; + if (!duel) return {}; + return { + duels: { + ...state.duels, + [duelId]: { + ...duel, + status: + duel.status === 'playing' || duel.status === 'countdown' + ? duel.status + : 'lobby', + opponent: pending?.opponentName ?? duel.opponent ?? '等待对手', + channelName, + challengerId, + opponentId, + myUserId, + questions, + totalQuestions: questions.length, + currentIndex: duel.currentIndex ?? 0, + currentQuestion: + duel.currentQuestion ?? (hasQuestions ? questions[0] : null), + error: hasQuestions ? null : '题目尚未准备完成', + send: realtime.send, + sendReady: realtime.sendReady, + sendAnswerSubmitted: realtime.sendAnswerSubmitted, + sendFinished: realtime.sendFinished, + unsubscribe: realtime.unsubscribe, + submit: (option) => get().submitDuelAnswer(duelId, option) + } + } + }; + }); + + return get().duels[duelId] ?? null; + }, + + async beginSynchronizedCountdown(duelId, explicitStartAtServerMs = null) { + if (!duelId) return null; + + const duel = get().duels[duelId]; + if (!duel) return null; + if (duel.status === 'playing' || duel.status === 'completed') { + return duel.countdownStartAtServerMs ?? null; + } + if (!duel.questions?.length) return null; + + let startAtServerMs = toNumber(explicitStartAtServerMs); + if (startAtServerMs == null) { + startAtServerMs = toNumber(duel.countdownStartAtServerMs); + } + + if (startAtServerMs == null) { + const iAmCountdownHost = isSameId(duel.myUserId, duel.challengerId); + if (!iAmCountdownHost) { + return null; + } + const offsetMs = await get().syncServerTimeOffset(); + startAtServerMs = + getEstimatedServerNow(offsetMs) + DUEL_COUNTDOWN_SECONDS * 1000 + 450; + duel.send?.({ + status: 'countdown', + startAtServerMs + }); + } + + get().startDuelCountdown(duelId, startAtServerMs); + return startAtServerMs; + }, + + startDuelCountdown(duelId, startAtServerMs = null) { + if (!duelId) return; + const duel = get().duels[duelId]; + if (!duel) return; + if (!duel.questions?.length) return; + if (duel.status === 'playing' || duel.status === 'completed') return; + if (hasDuelCountdown(duelId) && duel.status === 'countdown') return; + + const countdownStartAt = + toNumber(startAtServerMs) ?? toNumber(duel.countdownStartAtServerMs); + if (countdownStartAt == null) return; + const offsetMs = Number(get().serverTimeOffsetMs ?? 0); + + clearDuelCountdown(duelId); + set((state) => { + const duels = patchDuel(state, duelId, { + status: 'countdown', + countdownStartAtServerMs: countdownStartAt, + countdown: computeCountdownSeconds(countdownStartAt, offsetMs), + error: null + }); + return duels ? { duels } : {}; + }); + + const initialState = get().duels[duelId]; + if (!initialState || (initialState.countdown ?? 0) <= 0) { + set((state) => { + const duelState = state.duels[duelId]; + if (!duelState) return {}; + return { + duels: { + ...state.duels, + [duelId]: { + ...duelState, + status: 'playing', + countdown: 0, + currentQuestion: + duelState.currentQuestion ?? duelState.questions?.[0] ?? null + } + } + }; + }); + return; + } + + const timer = setInterval(() => { + const latest = get().duels[duelId]; + if (!latest) { + clearDuelCountdown(duelId); + return; + } + + const startAt = toNumber( + latest.countdownStartAtServerMs ?? countdownStartAt + ); + if (startAt == null) { + clearDuelCountdown(duelId); + return; + } + + const latestOffset = Number(get().serverTimeOffsetMs ?? 0); + const remainingSeconds = computeCountdownSeconds(startAt, latestOffset); + + if (remainingSeconds <= 0) { + clearDuelCountdown(duelId); + set((state) => { + const duelState = state.duels[duelId]; + if (!duelState) return {}; + return { + duels: { + ...state.duels, + [duelId]: { + ...duelState, + status: 'playing', + countdown: 0, + currentQuestion: + duelState.currentQuestion ?? + duelState.questions?.[0] ?? + null + } + } + }; + }); + return; + } + + if (remainingSeconds !== latest.countdown) { + set((state) => { + const duelState = state.duels[duelId]; + if (!duelState) return {}; + return { + duels: { + ...state.duels, + [duelId]: { + ...duelState, + countdown: remainingSeconds + } + } + }; + }); + } + }, 250); + + registerDuelCountdown(duelId, timer); + }, + + async startDuel(duelId) { + if (!duelId) return; + let duel = get().duels[duelId]; + + if (!duel) { + duel = await get().ensureDuel(duelId); + } + if (!duel) return; + + if (!duel.questions?.length) { + await get().ensureDuel(duelId); + duel = get().duels[duelId]; + } + + if (!duel) return; + if (duel.status === 'playing' || duel.status === 'completed') return; + if (!duel.myReady) { + duel.sendReady?.(); + set((state) => { + const current = state.duels[duelId]; + if (!current) return {}; + const bothReady = current.opponentReady; + return { + duels: { + ...state.duels, + [duelId]: { + ...current, + myReady: true, + bothReady, + status: + current.status === 'countdown' || current.status === 'playing' + ? current.status + : 'lobby', + error: null + } + } + }; + }); + const latest = get().duels[duelId]; + if (latest?.bothReady) { + await get().beginSynchronizedCountdown(duelId); + } + return; + } + + if (duel.myReady && duel.opponentReady) { + await get().beginSynchronizedCountdown(duelId); + } + }, + + async submitDuelAnswer(duelId, option) { + const duel = get().duels[duelId]; + if (!duel?.currentQuestion) return; + if (duel.status !== 'playing' || duel.submitting) return; + + set((state) => { + const duels = patchDuel(state, duelId, { + submitting: true, + error: null + }); + return duels ? { duels } : {}; + }); + + try { + const result = await arenaService.submitDuelAnswer({ + challengeId: duelId, + questionIndex: duel.currentIndex ?? 0, + selectedCategory: option, + answerTimeMs: 0 + }); + + const correct = Boolean(result?.is_correct); + const nextIndex = (duel.currentIndex ?? 0) + 1; + const nextQuestion = duel.questions?.[nextIndex] ?? null; + const nextScore = correct ? duel.score + 20 : duel.score; + const nextCorrectCount = correct + ? duel.correctCount + 1 + : duel.correctCount; + const finished = !nextQuestion; + + set((state) => { + const current = state.duels[duelId]; + if (!current) return {}; + return { + duels: { + ...state.duels, + [duelId]: { + ...current, + submitting: false, + currentQuestion: nextQuestion, + currentIndex: nextIndex, + score: nextScore, + correctCount: nextCorrectCount, + hasFinished: finished, + status: finished + ? current.opponentFinished + ? 'finalizing' + : 'waiting-result' + : 'playing', + awaitingResult: finished, + opponentProgress: current.opponentProgress, + error: null + } + } + }; + }); + + const latest = get().duels[duelId]; + latest?.sendAnswerSubmitted?.({ + questionIndex: duel.currentIndex ?? 0, + isCorrect: correct + }); + latest?.send?.({ + currentIndex: nextIndex, + score: nextScore, + status: finished ? 'waiting-result' : 'playing' + }); + + if (finished) { + latest?.sendFinished?.({ + totalCorrect: nextCorrectCount, + totalScore: nextScore + }); + get().maybeFinalizeDuel(duelId); + } + + if (correct) { + notifyAchievement({ type: 'arena', mode: 'duel', correct: true }); + } + } catch (error) { + console.warn('[arenaStore] submit duel answer failed', error); + set((state) => { + const duels = patchDuel(state, duelId, { + submitting: false, + error: error?.message ?? '提交答案失败' + }); + return duels ? { duels } : {}; + }); + } + }, + + async maybeFinalizeDuel(duelId) { + const duel = get().duels[duelId]; + if (!duel) return; + if (!duel.hasFinished || !duel.opponentFinished) return; + if (duel.finalizing || duel.status === 'completed') return; + + set((state) => { + const duels = patchDuel(state, duelId, { + finalizing: true, + awaitingResult: false, + status: 'finalizing', + error: null + }); + return duels ? { duels } : {}; + }); + + let hardErrors = 0; + for ( + let attempt = 0; + attempt < DUEL_COMPLETE_MAX_ATTEMPTS; + attempt += 1 + ) { + try { + const result = await arenaService.completeDuel(duelId); + if (result) { + set((state) => { + const duels = patchDuel(state, duelId, { + finalizing: false, + awaitingResult: false, + status: 'completed', + result, + error: null + }); + return duels ? { duels } : {}; + }); + return; + } + } catch (error) { + hardErrors += 1; + console.warn('[arenaStore] finalize duel failed', error); + if (hardErrors >= 3) break; + } + await sleep(DUEL_COMPLETE_RETRY_MS); + } + + set((state) => { + const duels = patchDuel(state, duelId, { + finalizing: false, + awaitingResult: true, + status: 'waiting-result', + error: '等待对手完成结算,稍后会自动同步结果。' + }); + return duels ? { duels } : {}; + }); + }, + + disposeDuel(duelId) { + if (!duelId) return; + clearDuelCountdown(duelId); + clearQueuedDuelEvents(duelId); + const duel = get().duels[duelId]; + try { + duel?.unsubscribe?.(); + } catch (_) { + // Swallow cleanup errors to ensure state is always cleared + } + set((state) => { + const duels = { ...state.duels }; + delete duels[duelId]; + return { duels }; + }); + } + }; +}; diff --git a/the-trash-rn/src/stores/arena/shared.js b/the-trash-rn/src/stores/arena/shared.js new file mode 100644 index 0000000..1ec9619 --- /dev/null +++ b/the-trash-rn/src/stores/arena/shared.js @@ -0,0 +1,90 @@ +import { useAchievementStore } from 'src/stores/achievementStore'; + +export const SPEED_DURATION = 60; +export const DUEL_COUNTDOWN_SECONDS = 3; +export const DUEL_COMPLETE_RETRY_MS = 1200; +export const DUEL_COMPLETE_MAX_ATTEMPTS = 20; +export const DUEL_CLOCK_OFFSET_CACHE_MS = 1000 * 60 * 5; +export const DUEL_GC_INTERVAL_MS = 1000 * 30; +export const DUEL_STALE_SESSION_MS = 1000 * 60 * 10; + +export const initialClassic = { + sessionId: null, + question: null, + questionIndex: 0, + score: 0, + state: 'idle', + lastAnswerCorrect: null +}; + +export const initialSpeed = { + sessionId: null, + question: null, + score: 0, + remaining: SPEED_DURATION, + total: SPEED_DURATION, + state: 'idle' +}; + +export const initialStreak = { + question: null, + current: 0, + best: 0, + state: 'idle' +}; + +export const initialDailyChallenge = { + id: null, + prompt: '加载中…', + progress: 0, + total: 0, + reward: null, + state: 'idle' +}; + +export const normalizeAnswer = (value) => + String(value ?? '') + .trim() + .toLowerCase(); + +export const notifyAchievement = (payload) => { + useAchievementStore.getState().checkAndGrant(payload); +}; + +export const sleep = (ms) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +export const toId = (value) => { + if (value == null) return null; + return String(value); +}; + +export const isSameId = (a, b) => { + const left = toId(a); + const right = toId(b); + return Boolean(left && right && left === right); +}; + +export const resolveOpponentId = ({ myUserId, challengerId, opponentId }) => { + if (!myUserId) return null; + if (isSameId(myUserId, challengerId)) return toId(opponentId); + if (isSameId(myUserId, opponentId)) return toId(challengerId); + return toId(opponentId ?? challengerId); +}; + +export const toNumber = (value) => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +}; + +export const getEstimatedServerNow = (offsetMs = 0) => + Date.now() + Number(offsetMs ?? 0); + +export const computeCountdownSeconds = (startAtServerMs, offsetMs = 0) => { + const startAt = toNumber(startAtServerMs); + if (startAt == null) return 0; + const remainingMs = startAt - getEstimatedServerNow(offsetMs); + return Math.max(0, Math.ceil(remainingMs / 1000)); +}; diff --git a/the-trash-rn/src/stores/arena/soloSlice.js b/the-trash-rn/src/stores/arena/soloSlice.js new file mode 100644 index 0000000..02dcfa9 --- /dev/null +++ b/the-trash-rn/src/stores/arena/soloSlice.js @@ -0,0 +1,254 @@ +import { arenaService } from 'src/services/arena'; +import { dailyChallengeService } from 'src/services/dailyChallenge'; +import { streakModeService } from 'src/services/streakMode'; + +import { + initialClassic, + initialDailyChallenge, + initialSpeed, + initialStreak, + normalizeAnswer, + notifyAchievement +} from './shared'; + +let speedTimerRef = null; + +export const createSoloArenaSlice = (set, get) => ({ + classic: { ...initialClassic }, + speed: { ...initialSpeed }, + streak: { ...initialStreak }, + dailyChallenge: { ...initialDailyChallenge }, + pendingChallenges: {}, + friends: [], + dailyLeaderboard: [], + streakLeaderboard: [], + + async startClassic() { + set({ classic: { ...initialClassic, state: 'loading' } }); + const session = await arenaService.startClassic(); + set({ + classic: { + sessionId: session.sessionId, + question: session.question, + questionIndex: 1, + score: 0, + state: 'playing', + lastAnswerCorrect: null + } + }); + }, + + async answerClassic(option) { + const { classic } = get(); + if (!classic.question) return; + const result = await arenaService.submitClassic({ + sessionId: classic.sessionId, + questionId: classic.question.id, + answer: option + }); + const newScore = result.correct ? classic.score + 10 : classic.score; + set({ + classic: { + ...classic, + score: newScore, + question: result.nextQuestion, + questionIndex: result.nextQuestion + ? classic.questionIndex + 1 + : classic.questionIndex, + lastAnswerCorrect: result.correct, + state: result.nextQuestion ? 'playing' : 'finished' + } + }); + notifyAchievement({ + type: 'arena', + mode: 'classic', + correct: result.correct, + score: newScore + }); + }, + + async startSpeedSort() { + clearInterval(speedTimerRef); + set({ speed: { ...initialSpeed, state: 'loading' } }); + const session = await arenaService.startSpeedSort(); + set({ + speed: { + sessionId: session.sessionId, + question: session.question, + score: 0, + remaining: session.duration, + total: session.duration, + state: 'playing' + } + }); + speedTimerRef = setInterval(() => { + set((state) => { + if (state.speed.remaining <= 1) { + clearInterval(speedTimerRef); + return { + speed: { + ...state.speed, + state: 'finished', + remaining: 0 + } + }; + } + return { + speed: { + ...state.speed, + remaining: state.speed.remaining - 1 + } + }; + }); + }, 1000); + }, + + async answerSpeedSort(option) { + const { speed } = get(); + if (!speed.question || speed.state !== 'playing') return; + const result = await arenaService.submitSpeedAnswer({ + sessionId: speed.sessionId, + questionId: speed.question.id, + answer: option + }); + const newScore = speed.score + (result.scoreDelta ?? 0); + set({ + speed: { + ...speed, + score: newScore, + question: result.question + } + }); + if (result.correct) { + notifyAchievement({ + type: 'arena', + mode: 'speed', + score: newScore, + correct: true + }); + } + }, + + stopSpeedSort() { + clearInterval(speedTimerRef); + set({ speed: { ...initialSpeed } }); + }, + + async loadStreakStats() { + const stats = await streakModeService.fetchStats(); + set({ + streak: { + ...initialStreak, + best: stats.best ?? 0, + current: stats.current ?? 0 + } + }); + }, + + async startStreakSession() { + await get().loadStreakStats(); + const question = await arenaService.fetchQuestion('streak'); + set((state) => ({ + streak: { + ...state.streak, + question, + current: 0, + state: question ? 'playing' : 'idle' + } + })); + }, + + async answerStreak(option) { + const { streak } = get(); + if (!streak.question || streak.state !== 'playing') return; + const correct = + normalizeAnswer(streak.question.answer) === normalizeAnswer(option); + const finished = !correct; + const achievedStreak = correct ? streak.current + 1 : streak.current; + await streakModeService.submitAnswer({ + finished, + streakCount: achievedStreak + }); + const nextQuestion = await arenaService.fetchQuestion('streak'); + const nextCurrent = correct ? streak.current + 1 : 0; + const nextBest = correct ? Math.max(streak.best, nextCurrent) : streak.best; + set({ + streak: { + question: nextQuestion, + current: nextCurrent, + best: nextBest, + state: correct ? 'playing' : 'cooldown' + } + }); + if (correct) { + notifyAchievement({ + type: 'arena', + mode: 'streak', + streak: nextCurrent + }); + } + }, + + async loadDailyChallenge() { + const challenge = await dailyChallengeService.fetch(); + set({ + dailyChallenge: { + ...challenge, + state: challenge?.alreadyPlayed ? 'completed' : 'ready' + } + }); + }, + + async incrementDailyChallenge() { + const { dailyChallenge } = get(); + if (!dailyChallenge.id || dailyChallenge.state === 'completed') return; + await dailyChallengeService.submit({ + score: dailyChallenge.total * 10, + correctCount: dailyChallenge.total, + timeSeconds: 60, + maxCombo: dailyChallenge.total + }); + const nextProgress = dailyChallenge.total; + set({ + dailyChallenge: { + ...dailyChallenge, + progress: nextProgress, + state: 'completed' + } + }); + notifyAchievement({ type: 'arena', mode: 'daily', completed: true }); + }, + + async loadLeaderboards() { + const data = await arenaService.fetchLeaderboards(); + set({ + dailyLeaderboard: data.daily ?? [], + streakLeaderboard: data.streak ?? [] + }); + }, + + async refreshChallenges() { + const pending = await arenaService.fetchPendingChallenges(); + set({ pendingChallenges: pending ?? {} }); + }, + + async loadFriends() { + const friends = await arenaService.fetchFriends(); + set({ friends }); + }, + + async sendInvite(friendId, mode = 'duel') { + const challenge = await arenaService.sendInvite(friendId, mode); + set((state) => ({ + pendingChallenges: { + ...state.pendingChallenges, + [challenge.id]: challenge + } + })); + }, + + async acceptDeepLink(id) { + await get().refreshChallenges(); + return `/(tabs)/arena/duel/${id}`; + } +}); diff --git a/the-trash-rn/src/stores/arenaStore.js b/the-trash-rn/src/stores/arenaStore.js index e1b586e..89c4300 100644 --- a/the-trash-rn/src/stores/arenaStore.js +++ b/the-trash-rn/src/stores/arenaStore.js @@ -1,1154 +1,9 @@ import { create } from 'zustand'; -import { arenaService } from 'src/services/arena'; -import { dailyChallengeService } from 'src/services/dailyChallenge'; -import { realtimeService } from 'src/services/realtime'; -import { streakModeService } from 'src/services/streakMode'; +import { createDuelArenaSlice } from './arena/duelSlice'; +import { createSoloArenaSlice } from './arena/soloSlice'; -import { useAchievementStore } from './achievementStore'; - -const SPEED_DURATION = 60; -const DUEL_COUNTDOWN_SECONDS = 3; -const DUEL_COMPLETE_RETRY_MS = 1200; -const DUEL_COMPLETE_MAX_ATTEMPTS = 20; -const DUEL_CLOCK_OFFSET_CACHE_MS = 1000 * 60 * 5; -const DUEL_GC_INTERVAL_MS = 1000 * 30; -const DUEL_STALE_SESSION_MS = 1000 * 60 * 10; - -let speedTimerRef = null; -const duelCountdownTimers = new Map(); -const duelEventQueues = new Map(); -let duelGcInterval = null; - -const initialClassic = { - sessionId: null, - question: null, - questionIndex: 0, - score: 0, - state: 'idle', - lastAnswerCorrect: null -}; - -const initialSpeed = { - sessionId: null, - question: null, - score: 0, - remaining: SPEED_DURATION, - total: SPEED_DURATION, - state: 'idle' -}; - -const initialStreak = { - question: null, - current: 0, - best: 0, - state: 'idle' -}; - -const initialDailyChallenge = { - id: null, - prompt: '加载中…', - progress: 0, - total: 0, - reward: null, - state: 'idle' -}; - -const normalizeAnswer = (value) => - String(value ?? '') - .trim() - .toLowerCase(); - -const notifyAchievement = (payload) => { - useAchievementStore.getState().checkAndGrant(payload); -}; - -const sleep = (ms) => - new Promise((resolve) => { - setTimeout(resolve, ms); - }); - -const toId = (value) => { - if (value == null) return null; - return String(value); -}; - -const isSameId = (a, b) => { - const left = toId(a); - const right = toId(b); - return Boolean(left && right && left === right); -}; - -const resolveOpponentId = ({ myUserId, challengerId, opponentId }) => { - if (!myUserId) return null; - if (isSameId(myUserId, challengerId)) return toId(opponentId); - if (isSameId(myUserId, opponentId)) return toId(challengerId); - return toId(opponentId ?? challengerId); -}; - -const clearDuelCountdown = (duelId) => { - const timer = duelCountdownTimers.get(duelId); - if (timer) { - clearInterval(timer); - duelCountdownTimers.delete(duelId); - } -}; - -const queueDuelEvent = (duelId, task) => { - const previous = duelEventQueues.get(duelId) ?? Promise.resolve(); - const next = previous - .catch(() => {}) - .then(task) - .catch((error) => { - console.warn('[arenaStore] duel event queue error', duelId, error); - }); - - duelEventQueues.set( - duelId, - next.finally(() => { - if (duelEventQueues.get(duelId) === next) { - duelEventQueues.delete(duelId); - } - }) - ); - - return next; -}; - -const clearQueuedDuelEvents = (duelId) => { - duelEventQueues.delete(duelId); -}; - -const toNumber = (value) => { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : null; -}; - -const getEstimatedServerNow = (offsetMs = 0) => - Date.now() + Number(offsetMs ?? 0); - -const computeCountdownSeconds = (startAtServerMs, offsetMs = 0) => { - const startAt = toNumber(startAtServerMs); - if (startAt == null) return 0; - const remainingMs = startAt - getEstimatedServerNow(offsetMs); - return Math.max(0, Math.ceil(remainingMs / 1000)); -}; - -const createDuelState = (duelId, submit) => ({ - id: duelId, - status: 'loading', - opponent: '等待对手', - countdown: 0, - countdownStartAtServerMs: null, - questions: [], - totalQuestions: 0, - currentIndex: 0, - currentQuestion: null, - score: 0, - correctCount: 0, - myReady: false, - opponentReady: false, - bothReady: false, - opponentProgress: 0, - opponentCorrect: 0, - opponentScore: 0, - opponentFinished: false, - opponentOnline: false, - hasFinished: false, - awaitingResult: false, - finalizing: false, - submitting: false, - realtimeStatus: 'idle', - channelName: null, - challengerId: null, - opponentId: null, - myUserId: null, - result: null, - error: null, - send: null, - sendReady: null, - sendAnswerSubmitted: null, - sendFinished: null, - unsubscribe: null, - submit, - createdAt: Date.now(), - updatedAt: Date.now() -}); - -const patchDuel = (state, duelId, patch) => { - const duel = state.duels[duelId]; - if (!duel) return null; - return { - ...state.duels, - [duelId]: { - ...duel, - ...patch, - updatedAt: Date.now() - } - }; -}; - -const ACTIVE_DUEL_STATUSES = new Set(['playing', 'countdown', 'finalizing']); - -const ensureDuelWatchdog = (get, set) => { - if (duelGcInterval) return; - - duelGcInterval = setInterval(() => { - const now = Date.now(); - const state = get(); - const staleIds = Object.entries(state.duels) - .filter(([, duel]) => { - if (!duel) return false; - if (ACTIVE_DUEL_STATUSES.has(duel.status)) return false; - const updatedAt = Number(duel.updatedAt ?? duel.createdAt ?? now); - return now - updatedAt > DUEL_STALE_SESSION_MS; - }) - .map(([id]) => id); - - if (!staleIds.length) return; - - staleIds.forEach((duelId) => { - clearDuelCountdown(duelId); - const duel = get().duels[duelId]; - duel?.unsubscribe?.(); - clearQueuedDuelEvents(duelId); - }); - - set((current) => { - const duels = { ...current.duels }; - staleIds.forEach((duelId) => { - delete duels[duelId]; - }); - return { duels }; - }); - }, DUEL_GC_INTERVAL_MS); -}; - -export const useArenaStore = create((set, get) => { - ensureDuelWatchdog(get, set); - return { - classic: { ...initialClassic }, - speed: { ...initialSpeed }, - streak: { ...initialStreak }, - dailyChallenge: { ...initialDailyChallenge }, - duels: {}, - pendingChallenges: {}, - friends: [], - dailyLeaderboard: [], - streakLeaderboard: [], - serverTimeOffsetMs: 0, - serverTimeOffsetFetchedAt: 0, - - async syncServerTimeOffset({ force = false } = {}) { - const cachedAt = Number(get().serverTimeOffsetFetchedAt ?? 0); - const now = Date.now(); - if ( - !force && - cachedAt > 0 && - now - cachedAt < DUEL_CLOCK_OFFSET_CACHE_MS - ) { - return Number(get().serverTimeOffsetMs ?? 0); - } - - const offsetMs = await arenaService.fetchServerTimeOffset(); - set({ - serverTimeOffsetMs: Number(offsetMs ?? 0), - serverTimeOffsetFetchedAt: Date.now() - }); - return Number(offsetMs ?? 0); - }, - - async startClassic() { - set({ classic: { ...initialClassic, state: 'loading' } }); - const session = await arenaService.startClassic(); - set({ - classic: { - sessionId: session.sessionId, - question: session.question, - questionIndex: 1, - score: 0, - state: 'playing', - lastAnswerCorrect: null - } - }); - }, - - async answerClassic(option) { - const { classic } = get(); - if (!classic.question) return; - const result = await arenaService.submitClassic({ - sessionId: classic.sessionId, - questionId: classic.question.id, - answer: option - }); - const newScore = result.correct ? classic.score + 10 : classic.score; - set({ - classic: { - ...classic, - score: newScore, - question: result.nextQuestion, - questionIndex: result.nextQuestion - ? classic.questionIndex + 1 - : classic.questionIndex, - lastAnswerCorrect: result.correct, - state: result.nextQuestion ? 'playing' : 'finished' - } - }); - notifyAchievement({ - type: 'arena', - mode: 'classic', - correct: result.correct, - score: newScore - }); - }, - - async startSpeedSort() { - clearInterval(speedTimerRef); - set({ speed: { ...initialSpeed, state: 'loading' } }); - const session = await arenaService.startSpeedSort(); - set({ - speed: { - sessionId: session.sessionId, - question: session.question, - score: 0, - remaining: session.duration, - total: session.duration, - state: 'playing' - } - }); - speedTimerRef = setInterval(() => { - set((state) => { - if (state.speed.remaining <= 1) { - clearInterval(speedTimerRef); - return { - speed: { - ...state.speed, - state: 'finished', - remaining: 0 - } - }; - } - return { - speed: { - ...state.speed, - remaining: state.speed.remaining - 1 - } - }; - }); - }, 1000); - }, - - async answerSpeedSort(option) { - const { speed } = get(); - if (!speed.question || speed.state !== 'playing') return; - const result = await arenaService.submitSpeedAnswer({ - sessionId: speed.sessionId, - questionId: speed.question.id, - answer: option - }); - const newScore = speed.score + (result.scoreDelta ?? 0); - set({ - speed: { - ...speed, - score: newScore, - question: result.question - } - }); - if (result.correct) { - notifyAchievement({ - type: 'arena', - mode: 'speed', - score: newScore, - correct: true - }); - } - }, - - stopSpeedSort() { - clearInterval(speedTimerRef); - set({ speed: { ...initialSpeed } }); - }, - - async loadStreakStats() { - const stats = await streakModeService.fetchStats(); - set({ - streak: { - ...initialStreak, - best: stats.best ?? 0, - current: stats.current ?? 0 - } - }); - }, - - async startStreakSession() { - await get().loadStreakStats(); - const question = await arenaService.fetchQuestion('streak'); - set((state) => ({ - streak: { - ...state.streak, - question, - current: 0, - state: question ? 'playing' : 'idle' - } - })); - }, - - async answerStreak(option) { - const { streak } = get(); - if (!streak.question || streak.state !== 'playing') return; - const correct = - normalizeAnswer(streak.question.answer) === normalizeAnswer(option); - const finished = !correct; - const achievedStreak = correct ? streak.current + 1 : streak.current; - await streakModeService.submitAnswer({ - finished, - streakCount: achievedStreak - }); - const nextQuestion = await arenaService.fetchQuestion('streak'); - const nextCurrent = correct ? streak.current + 1 : 0; - const nextBest = correct - ? Math.max(streak.best, nextCurrent) - : streak.best; - set({ - streak: { - question: nextQuestion, - current: nextCurrent, - best: nextBest, - state: correct ? 'playing' : 'cooldown' - } - }); - if (correct) { - notifyAchievement({ - type: 'arena', - mode: 'streak', - streak: nextCurrent - }); - } - }, - - async loadDailyChallenge() { - const challenge = await dailyChallengeService.fetch(); - set({ - dailyChallenge: { - ...challenge, - state: challenge?.alreadyPlayed ? 'completed' : 'ready' - } - }); - }, - - async incrementDailyChallenge() { - const { dailyChallenge } = get(); - if (!dailyChallenge.id || dailyChallenge.state === 'completed') return; - await dailyChallengeService.submit({ - score: dailyChallenge.total * 10, - correctCount: dailyChallenge.total, - timeSeconds: 60, - maxCombo: dailyChallenge.total - }); - const nextProgress = dailyChallenge.total; - set({ - dailyChallenge: { - ...dailyChallenge, - progress: nextProgress, - state: 'completed' - } - }); - notifyAchievement({ type: 'arena', mode: 'daily', completed: true }); - }, - - async loadLeaderboards() { - const data = await arenaService.fetchLeaderboards(); - set({ - dailyLeaderboard: data.daily ?? [], - streakLeaderboard: data.streak ?? [] - }); - }, - - async refreshChallenges() { - const pending = await arenaService.fetchPendingChallenges(); - set({ pendingChallenges: pending ?? {} }); - }, - - async loadFriends() { - const friends = await arenaService.fetchFriends(); - set({ friends }); - }, - - async sendInvite(friendId, mode = 'duel') { - const challenge = await arenaService.sendInvite(friendId, mode); - set((state) => ({ - pendingChallenges: { - ...state.pendingChallenges, - [challenge.id]: challenge - } - })); - }, - - async acceptChallenge(challengeId) { - const payload = await arenaService.acceptChallenge(challengeId); - const duelId = payload?.duelId ?? challengeId; - - set((state) => { - const duel = - state.duels[duelId] ?? - createDuelState(duelId, (option) => - get().submitDuelAnswer(duelId, option) - ); - return { - duels: { - ...state.duels, - [duelId]: { - ...duel, - status: 'lobby', - channelName: payload?.channelName ?? duel.channelName, - challengerId: payload?.challengerId ?? duel.challengerId, - opponentId: payload?.opponentId ?? duel.opponentId, - questions: payload?.questions ?? duel.questions, - totalQuestions: (payload?.questions ?? duel.questions ?? []) - .length, - currentQuestion: - (payload?.questions ?? duel.questions)?.[0] ?? null, - currentIndex: 0, - hasFinished: false, - awaitingResult: false, - finalizing: false, - error: null - } - } - }; - }); - - await get().ensureDuel(duelId, { preloadedPayload: payload }); - await get().refreshChallenges(); - return { - ...payload, - duelId - }; - }, - - async ensureDuel(duelId, options = {}) { - if (!duelId) return null; - - const existing = get().duels[duelId]; - if (!existing) { - set((state) => ({ - duels: { - ...state.duels, - [duelId]: createDuelState(duelId, (option) => - get().submitDuelAnswer(duelId, option) - ) - } - })); - } - - let payload = options.preloadedPayload ?? null; - try { - if (!payload) { - payload = await arenaService.getChallengeQuestions(duelId); - } - } catch (error) { - console.warn('[arenaStore] getChallengeQuestions failed', error); - set((state) => { - const duels = patchDuel(state, duelId, { - status: 'lobby', - error: '无法加载对战题目' - }); - return duels ? { duels } : {}; - }); - return get().duels[duelId] ?? null; - } - - const myUserId = - payload?.myUserId ?? (await arenaService.getCurrentUserId()); - const challengerId = payload?.challengerId ?? null; - const opponentId = payload?.opponentId ?? null; - const remoteOpponentId = resolveOpponentId({ - myUserId, - challengerId, - opponentId - }); - const channelName = payload?.channelName ?? `duel:${duelId}`; - const pending = get().pendingChallenges[duelId]; - - const onPlayerReady = ({ userId, isOpponent }) => { - queueDuelEvent(duelId, async () => { - if (!userId) return; - const latest = get().duels[duelId]; - if (!latest) return; - if (isSameId(userId, latest.myUserId ?? myUserId)) return; - - const opponentReady = Boolean( - isOpponent || - !remoteOpponentId || - latest.opponentReady || - isSameId(userId, latest.opponentId) || - isSameId(userId, latest.challengerId) - ); - const bothReady = Boolean(latest.myReady && opponentReady); - - set((state) => { - const duel = state.duels[duelId]; - if (!duel) return {}; - return { - duels: { - ...state.duels, - [duelId]: { - ...duel, - opponentReady, - bothReady - } - } - }; - }); - - if (bothReady) { - await get().beginSynchronizedCountdown(duelId); - } - }); - }; - - const onAnswerSubmitted = ({ userId, questionIndex, isCorrect }) => { - queueDuelEvent(duelId, async () => { - const latest = get().duels[duelId]; - if (!latest) return; - if (!userId || isSameId(userId, latest.myUserId ?? myUserId)) return; - - set((state) => { - const duel = state.duels[duelId]; - if (!duel) return {}; - const safeIndex = Number.isFinite(questionIndex) - ? questionIndex - : 0; - return { - duels: { - ...state.duels, - [duelId]: { - ...duel, - opponentProgress: Math.max( - duel.opponentProgress, - safeIndex + 1 - ), - opponentCorrect: isCorrect - ? duel.opponentCorrect + 1 - : duel.opponentCorrect, - opponentScore: isCorrect - ? duel.opponentScore + 20 - : duel.opponentScore - } - } - }; - }); - }); - }; - - const onPlayerFinished = ({ userId, totalCorrect, totalScore }) => { - queueDuelEvent(duelId, async () => { - const latest = get().duels[duelId]; - if (!latest) return; - if (!userId || isSameId(userId, latest.myUserId ?? myUserId)) return; - - set((state) => { - const duel = state.duels[duelId]; - if (!duel) return {}; - const nextStatus = duel.hasFinished - ? 'finalizing' - : 'waiting-result'; - return { - duels: { - ...state.duels, - [duelId]: { - ...duel, - opponentFinished: true, - opponentProgress: - duel.totalQuestions > 0 - ? Math.max(duel.opponentProgress, duel.totalQuestions) - : duel.opponentProgress, - opponentCorrect: Number.isFinite(totalCorrect) - ? totalCorrect - : duel.opponentCorrect, - opponentScore: Number.isFinite(totalScore) - ? totalScore - : duel.opponentScore, - status: nextStatus, - awaitingResult: duel.hasFinished - } - } - }; - }); - - await get().maybeFinalizeDuel(duelId); - }); - }; - - const onPresence = ({ opponentOnline }) => { - queueDuelEvent(duelId, async () => { - set((state) => { - const duels = patchDuel(state, duelId, { opponentOnline }); - return duels ? { duels } : {}; - }); - }); - }; - - const onStatusChange = (status) => { - queueDuelEvent(duelId, async () => { - set((state) => { - const duels = patchDuel(state, duelId, { - realtimeStatus: String(status ?? 'unknown') - }); - return duels ? { duels } : {}; - }); - }); - }; - - const onState = (payloadState) => { - queueDuelEvent(duelId, async () => { - if (!payloadState || typeof payloadState !== 'object') return; - const nextPatch = {}; - - if (typeof payloadState.status === 'string') { - nextPatch.status = payloadState.status; - } - if (Number.isFinite(payloadState.countdown)) { - nextPatch.countdown = payloadState.countdown; - } - if (Number.isFinite(payloadState.currentIndex)) { - nextPatch.opponentProgress = Math.max( - 0, - Number(payloadState.currentIndex) + 1 - ); - } - if (Number.isFinite(payloadState.score)) { - nextPatch.opponentScore = Number(payloadState.score); - } - - const startAtServerMs = toNumber(payloadState.startAtServerMs); - if (startAtServerMs != null) { - nextPatch.countdownStartAtServerMs = startAtServerMs; - nextPatch.status = 'countdown'; - } - - if (!Object.keys(nextPatch).length) return; - - set((state) => { - const duels = patchDuel(state, duelId, nextPatch); - return duels ? { duels } : {}; - }); - - if (startAtServerMs != null) { - await get().beginSynchronizedCountdown(duelId, startAtServerMs); - } - }); - }; - - clearQueuedDuelEvents(duelId); - const existingDuel = get().duels[duelId]; - existingDuel?.unsubscribe?.(); - - const realtime = realtimeService.joinDuel( - duelId, - { - onState, - onPlayerReady, - onAnswerSubmitted, - onPlayerFinished, - onPresence, - onStatusChange - }, - { - channelName, - myUserId, - opponentUserId: remoteOpponentId - } - ); - - const questions = payload?.questions ?? existingDuel?.questions ?? []; - const hasQuestions = questions.length > 0; - - set((state) => { - const duel = state.duels[duelId]; - if (!duel) return {}; - return { - duels: { - ...state.duels, - [duelId]: { - ...duel, - status: - duel.status === 'playing' || duel.status === 'countdown' - ? duel.status - : 'lobby', - opponent: pending?.opponentName ?? duel.opponent ?? '等待对手', - channelName, - challengerId, - opponentId, - myUserId, - questions, - totalQuestions: questions.length, - currentIndex: duel.currentIndex ?? 0, - currentQuestion: - duel.currentQuestion ?? (hasQuestions ? questions[0] : null), - error: hasQuestions ? null : '题目尚未准备完成', - send: realtime.send, - sendReady: realtime.sendReady, - sendAnswerSubmitted: realtime.sendAnswerSubmitted, - sendFinished: realtime.sendFinished, - unsubscribe: realtime.unsubscribe, - submit: (option) => get().submitDuelAnswer(duelId, option) - } - } - }; - }); - - return get().duels[duelId] ?? null; - }, - - async beginSynchronizedCountdown(duelId, explicitStartAtServerMs = null) { - if (!duelId) return null; - - const duel = get().duels[duelId]; - if (!duel) return null; - if (duel.status === 'playing' || duel.status === 'completed') { - return duel.countdownStartAtServerMs ?? null; - } - if (!duel.questions?.length) return null; - - let startAtServerMs = toNumber(explicitStartAtServerMs); - if (startAtServerMs == null) { - startAtServerMs = toNumber(duel.countdownStartAtServerMs); - } - - if (startAtServerMs == null) { - const iAmCountdownHost = isSameId(duel.myUserId, duel.challengerId); - if (!iAmCountdownHost) { - return null; - } - const offsetMs = await get().syncServerTimeOffset(); - startAtServerMs = - getEstimatedServerNow(offsetMs) + DUEL_COUNTDOWN_SECONDS * 1000 + 450; - duel.send?.({ - status: 'countdown', - startAtServerMs - }); - } - - get().startDuelCountdown(duelId, startAtServerMs); - return startAtServerMs; - }, - - startDuelCountdown(duelId, startAtServerMs = null) { - if (!duelId) return; - const duel = get().duels[duelId]; - if (!duel) return; - if (!duel.questions?.length) return; - if (duel.status === 'playing' || duel.status === 'completed') return; - - const countdownStartAt = - toNumber(startAtServerMs) ?? toNumber(duel.countdownStartAtServerMs); - if (countdownStartAt == null) return; - const offsetMs = Number(get().serverTimeOffsetMs ?? 0); - - clearDuelCountdown(duelId); - set((state) => { - const duels = patchDuel(state, duelId, { - status: 'countdown', - countdownStartAtServerMs: countdownStartAt, - countdown: computeCountdownSeconds(countdownStartAt, offsetMs), - error: null - }); - return duels ? { duels } : {}; - }); - - const initialState = get().duels[duelId]; - if (!initialState || (initialState.countdown ?? 0) <= 0) { - set((state) => { - const duelState = state.duels[duelId]; - if (!duelState) return {}; - return { - duels: { - ...state.duels, - [duelId]: { - ...duelState, - status: 'playing', - countdown: 0, - currentQuestion: - duelState.currentQuestion ?? duelState.questions?.[0] ?? null - } - } - }; - }); - return; - } - - const timer = setInterval(() => { - const latest = get().duels[duelId]; - if (!latest) { - clearDuelCountdown(duelId); - return; - } - - const startAt = toNumber( - latest.countdownStartAtServerMs ?? countdownStartAt - ); - if (startAt == null) { - clearDuelCountdown(duelId); - return; - } - - const latestOffset = Number(get().serverTimeOffsetMs ?? 0); - const remainingSeconds = computeCountdownSeconds(startAt, latestOffset); - - if (remainingSeconds <= 0) { - clearDuelCountdown(duelId); - set((state) => { - const duelState = state.duels[duelId]; - if (!duelState) return {}; - return { - duels: { - ...state.duels, - [duelId]: { - ...duelState, - status: 'playing', - countdown: 0, - currentQuestion: - duelState.currentQuestion ?? - duelState.questions?.[0] ?? - null - } - } - }; - }); - return; - } - - if (remainingSeconds !== latest.countdown) { - set((state) => { - const duelState = state.duels[duelId]; - if (!duelState) return {}; - return { - duels: { - ...state.duels, - [duelId]: { - ...duelState, - countdown: remainingSeconds - } - } - }; - }); - } - }, 250); - - duelCountdownTimers.set(duelId, timer); - }, - - async startDuel(duelId) { - if (!duelId) return; - let duel = get().duels[duelId]; - - if (!duel) { - duel = await get().ensureDuel(duelId); - } - if (!duel) return; - - if (!duel.questions?.length) { - await get().ensureDuel(duelId); - duel = get().duels[duelId]; - } - - if (!duel) return; - if (duel.status === 'playing' || duel.status === 'completed') return; - if (!duel.myReady) { - duel.sendReady?.(); - set((state) => { - const current = state.duels[duelId]; - if (!current) return {}; - const bothReady = current.opponentReady; - return { - duels: { - ...state.duels, - [duelId]: { - ...current, - myReady: true, - bothReady, - status: - current.status === 'countdown' || current.status === 'playing' - ? current.status - : 'lobby', - error: null - } - } - }; - }); - const latest = get().duels[duelId]; - if (latest?.bothReady) { - await get().beginSynchronizedCountdown(duelId); - } - return; - } - - if (duel.myReady && duel.opponentReady) { - await get().beginSynchronizedCountdown(duelId); - } - }, - - async submitDuelAnswer(duelId, option) { - const duel = get().duels[duelId]; - if (!duel?.currentQuestion) return; - if (duel.status !== 'playing' || duel.submitting) return; - - set((state) => { - const duels = patchDuel(state, duelId, { - submitting: true, - error: null - }); - return duels ? { duels } : {}; - }); - - try { - const result = await arenaService.submitDuelAnswer({ - challengeId: duelId, - questionIndex: duel.currentIndex ?? 0, - selectedCategory: option, - answerTimeMs: 0 - }); - - const correct = Boolean(result?.is_correct); - const nextIndex = (duel.currentIndex ?? 0) + 1; - const nextQuestion = duel.questions?.[nextIndex] ?? null; - const nextScore = correct ? duel.score + 20 : duel.score; - const nextCorrectCount = correct - ? duel.correctCount + 1 - : duel.correctCount; - const finished = !nextQuestion; - - set((state) => { - const current = state.duels[duelId]; - if (!current) return {}; - return { - duels: { - ...state.duels, - [duelId]: { - ...current, - submitting: false, - currentQuestion: nextQuestion, - currentIndex: nextIndex, - score: nextScore, - correctCount: nextCorrectCount, - hasFinished: finished, - status: finished - ? current.opponentFinished - ? 'finalizing' - : 'waiting-result' - : 'playing', - awaitingResult: finished, - opponentProgress: current.opponentProgress, - error: null - } - } - }; - }); - - const latest = get().duels[duelId]; - latest?.sendAnswerSubmitted?.({ - questionIndex: duel.currentIndex ?? 0, - isCorrect: correct - }); - latest?.send?.({ - currentIndex: nextIndex, - score: nextScore, - status: finished ? 'waiting-result' : 'playing' - }); - - if (finished) { - latest?.sendFinished?.({ - totalCorrect: nextCorrectCount, - totalScore: nextScore - }); - get().maybeFinalizeDuel(duelId); - } - - if (correct) { - notifyAchievement({ type: 'arena', mode: 'duel', correct: true }); - } - } catch (error) { - console.warn('[arenaStore] submit duel answer failed', error); - set((state) => { - const duels = patchDuel(state, duelId, { - submitting: false, - error: error?.message ?? '提交答案失败' - }); - return duels ? { duels } : {}; - }); - } - }, - - async maybeFinalizeDuel(duelId) { - const duel = get().duels[duelId]; - if (!duel) return; - if (!duel.hasFinished || !duel.opponentFinished) return; - if (duel.finalizing || duel.status === 'completed') return; - - set((state) => { - const duels = patchDuel(state, duelId, { - finalizing: true, - awaitingResult: false, - status: 'finalizing', - error: null - }); - return duels ? { duels } : {}; - }); - - for ( - let attempt = 0; - attempt < DUEL_COMPLETE_MAX_ATTEMPTS; - attempt += 1 - ) { - try { - const result = await arenaService.completeDuel(duelId); - if (result) { - set((state) => { - const duels = patchDuel(state, duelId, { - finalizing: false, - awaitingResult: false, - status: 'completed', - result, - error: null - }); - return duels ? { duels } : {}; - }); - return; - } - } catch (error) { - console.warn('[arenaStore] finalize duel failed', error); - } - await sleep(DUEL_COMPLETE_RETRY_MS); - } - - set((state) => { - const duels = patchDuel(state, duelId, { - finalizing: false, - awaitingResult: true, - status: 'waiting-result', - error: '等待对手完成结算,稍后会自动同步结果。' - }); - return duels ? { duels } : {}; - }); - }, - - disposeDuel(duelId) { - if (!duelId) return; - clearDuelCountdown(duelId); - clearQueuedDuelEvents(duelId); - const duel = get().duels[duelId]; - duel?.unsubscribe?.(); - set((state) => { - const duels = { ...state.duels }; - delete duels[duelId]; - return { duels }; - }); - }, - - async acceptDeepLink(id) { - await get().refreshChallenges(); - return `/(tabs)/arena/duel/${id}`; - } - }; -}); +export const useArenaStore = create((set, get) => ({ + ...createSoloArenaSlice(set, get), + ...createDuelArenaSlice(set, get) +})); diff --git a/the-trash-rn/src/stores/authStore.js b/the-trash-rn/src/stores/authStore.js index 4d6e92a..86e8a19 100644 --- a/the-trash-rn/src/stores/authStore.js +++ b/the-trash-rn/src/stores/authStore.js @@ -1,100 +1,205 @@ import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; + import { authService } from 'src/services/auth'; -import { createPersistStorage } from 'src/utils/storage'; +import { supabase } from 'src/services/supabase'; +import { messageFromError } from 'src/utils/errors'; -export const useAuthStore = create( - persist( - (set, get) => ({ - status: 'checking', - session: null, - profile: null, - authenticating: false, - error: null, - bootstrap: async () => { - try { - const { session, profile } = await authService.restoreSession(); - if (session) { - set({ status: 'authenticated', session, profile, error: null }); - } else { - set({ status: 'guest', session: null, profile: null, error: null }); - } - } catch (error) { - console.warn('[authStore] bootstrap failed', error); - set({ status: 'guest', session: null, profile: null, error: error.message }); - } - }, - signInWithEmail: async ({ email, password }) => { - set({ authenticating: true, error: null }); - try { - const { session, profile } = await authService.signInWithEmail({ email, password }); - set({ status: 'authenticated', session, profile, authenticating: false, error: null }); - } catch (error) { - set({ authenticating: false, error: error.message }); - throw error; - } - }, - signUpWithEmail: async ({ email, password }) => { - set({ authenticating: true, error: null }); - try { - const { session, profile, requiresEmailConfirmation } = await authService.signUpWithEmail({ - email, - password +let authSubscription = null; + +const toGuestState = (currentProfile = null) => ({ + status: 'guest', + session: null, + profile: currentProfile, + error: null +}); + +export const useAuthStore = create((set, get) => ({ + status: 'checking', + session: null, + profile: null, + authenticating: false, + error: null, + + bootstrap: async () => { + if (!authSubscription) { + const { data } = supabase.auth.onAuthStateChange((_event, session) => { + const current = get(); + if (session?.user) { + set({ + status: 'authenticated', + session, + profile: { + id: session.user.id, + displayName: + session.user.user_metadata?.full_name ?? + session.user.email ?? + session.user.phone ?? + 'Trash Ranger', + email: session.user.email ?? null, + phone: session.user.phone ?? null, + level: current.profile?.level ?? 1 + }, + error: null }); - if (session) { - set({ status: 'authenticated', session, profile, authenticating: false, error: null }); - } else { - set({ status: 'guest', session: null, profile: null, authenticating: false, error: null }); - } - return { requiresEmailConfirmation }; - } catch (error) { - set({ authenticating: false, error: error.message }); - throw error; - } - }, - signInWithPhone: async ({ phone, code }) => { - set({ authenticating: true, error: null }); - try { - const { session, profile } = await authService.signInWithPhone({ phone, code }); - set({ status: 'authenticated', session, profile, authenticating: false, error: null }); - } catch (error) { - set({ authenticating: false, error: error.message }); - throw error; + return; } - }, - requestPhoneCode: async (phone) => { - try { - await authService.requestPhoneCode(phone); - set({ error: null }); - return true; - } catch (error) { - set({ error: error.message }); - throw error; - } - }, - signInAsGuest: () => - set({ - status: 'guest', - profile: { id: 'guest', displayName: '游客', level: 1 }, - session: null, - error: null - }), - setSession: (session, profile) => + + const keepGuestProfile = + current.status === 'guest' && current.profile?.id === 'guest' + ? current.profile + : null; + set(toGuestState(keepGuestProfile)); + }); + authSubscription = data.subscription; + } + + try { + const { session, profile } = await authService.restoreSession(); + if (session) { + set({ status: 'authenticated', session, profile, error: null }); + } else { + const current = get(); + const keepGuestProfile = + current.status === 'guest' && current.profile?.id === 'guest' + ? current.profile + : null; + set(toGuestState(keepGuestProfile)); + } + } catch (error) { + console.warn('[authStore] bootstrap failed', error); + set({ + status: 'guest', + session: null, + profile: null, + error: messageFromError(error, '初始化登录状态失败') + }); + } + }, + + signInWithEmail: async ({ email, password }) => { + set({ authenticating: true, error: null }); + try { + const { session, profile } = await authService.signInWithEmail({ + email, + password + }); + set({ + status: 'authenticated', + session, + profile, + authenticating: false, + error: null + }); + } catch (error) { + set({ + authenticating: false, + error: messageFromError(error, '登录失败') + }); + throw error; + } + }, + + signUpWithEmail: async ({ email, password }) => { + set({ authenticating: true, error: null }); + try { + const { session, profile, requiresEmailConfirmation } = + await authService.signUpWithEmail({ + email, + password + }); + if (session) { set({ status: 'authenticated', session, profile, + authenticating: false, + error: null + }); + } else { + set({ + status: 'guest', + session: null, + profile: null, + authenticating: false, error: null - }), - signOut: async () => { - await authService.signOut(); - set({ status: 'guest', session: null, profile: null, error: null }); + }); } - }), - { - name: 'the-trash-auth', - storage: createPersistStorage(), - partialize: (state) => ({ profile: state.profile, status: state.status, session: state.session }) + return { requiresEmailConfirmation }; + } catch (error) { + set({ + authenticating: false, + error: messageFromError(error, '注册失败') + }); + throw error; } - ) -); + }, + + signInWithPhone: async ({ phone, code }) => { + set({ authenticating: true, error: null }); + try { + const { session, profile } = await authService.signInWithPhone({ + phone, + code + }); + set({ + status: 'authenticated', + session, + profile, + authenticating: false, + error: null + }); + } catch (error) { + set({ + authenticating: false, + error: messageFromError(error, '手机登录失败') + }); + throw error; + } + }, + + requestPhoneCode: async (phone) => { + try { + await authService.requestPhoneCode(phone); + set({ error: null }); + return true; + } catch (error) { + set({ error: messageFromError(error, '验证码发送失败') }); + throw error; + } + }, + + refreshSession: async ({ keepGuestOnMissingSession = true } = {}) => { + try { + const { session, profile } = await authService.restoreSession(); + if (session) { + set({ status: 'authenticated', session, profile, error: null }); + return session; + } + + const current = get(); + const shouldKeepGuest = + keepGuestOnMissingSession && + current.status === 'guest' && + current.profile?.id === 'guest'; + set(toGuestState(shouldKeepGuest ? current.profile : null)); + return null; + } catch (error) { + const message = messageFromError(error, '刷新登录状态失败'); + set({ error: message }); + throw error; + } + }, + + signInAsGuest: () => + set({ + status: 'guest', + profile: { id: 'guest', displayName: '游客', level: 1 }, + session: null, + error: null + }), + + signOut: async () => { + await authService.signOut(); + set(toGuestState(null)); + } +})); diff --git a/the-trash-rn/src/stores/communityStore.js b/the-trash-rn/src/stores/communityStore.js index 351f6f5..9b0b22f 100644 --- a/the-trash-rn/src/stores/communityStore.js +++ b/the-trash-rn/src/stores/communityStore.js @@ -1,8 +1,16 @@ import { create } from 'zustand'; -import { communityService } from 'src/services/community'; + import { adminService } from 'src/services/admin'; +import { communityService } from 'src/services/community'; +import { AppError, ERROR_CODES, messageFromError } from 'src/utils/errors'; -const mapById = (items) => Object.fromEntries(items.map((item) => [item.id, item])); +const mapById = (items) => + Object.fromEntries(items.map((item) => [item.id, item])); +const resolveCityKey = (city) => { + if (!city) return null; + if (typeof city === 'string') return city; + return city.city ?? city.name ?? city.id ?? null; +}; export const useCommunityStore = create((set, get) => ({ events: [], @@ -14,15 +22,18 @@ export const useCommunityStore = create((set, get) => ({ adminDashboards: {}, activeCityId: null, async loadEvents(city) { - const cityId = typeof city === 'string' ? city : city?.id; - if (!cityId) return; - set({ eventsLoading: true, activeCityId: cityId }); + const cityKey = resolveCityKey(city); + if (!cityKey) return; + set({ eventsLoading: true, activeCityId: cityKey }); try { const events = await communityService.fetchEvents(city); set({ events, eventMap: mapById(events), eventsLoading: false }); } catch (error) { set({ eventsLoading: false }); - console.warn('[communityStore] loadEvents failed', error); + console.warn( + '[communityStore] loadEvents failed', + messageFromError(error, '加载活动失败') + ); } }, async loadGroups(city) { @@ -33,7 +44,10 @@ export const useCommunityStore = create((set, get) => ({ set({ groups, groupMap: mapById(groups), groupsLoading: false }); } catch (error) { set({ groupsLoading: false }); - console.warn('[communityStore] loadGroups failed', error); + console.warn( + '[communityStore] loadGroups failed', + messageFromError(error, '加载社群失败') + ); } }, async refreshEvent(eventId) { @@ -44,7 +58,10 @@ export const useCommunityStore = create((set, get) => ({ set((state) => ({ eventMap: { ...state.eventMap, [eventId]: event } })); return event; } catch (error) { - console.warn('[communityStore] refreshEvent failed', error); + console.warn( + '[communityStore] refreshEvent failed', + messageFromError(error, '刷新活动失败') + ); return null; } }, @@ -53,20 +70,25 @@ export const useCommunityStore = create((set, get) => ({ try { const community = await communityService.fetchCommunity(communityId); if (!community) return null; - set((state) => ({ groupMap: { ...state.groupMap, [communityId]: community } })); + set((state) => ({ + groupMap: { ...state.groupMap, [communityId]: community } + })); return community; } catch (error) { - console.warn('[communityStore] refreshCommunity failed', error); + console.warn( + '[communityStore] refreshCommunity failed', + messageFromError(error, '刷新社群失败') + ); return null; } }, async createEvent(payload) { const event = await communityService.createEvent(payload); if (!event) { - throw new Error('创建活动失败'); + throw new AppError('创建活动失败', { code: ERROR_CODES.BACKEND }); } set((state) => { - const shouldInsert = state.activeCityId === event.cityId; + const shouldInsert = state.activeCityId === resolveCityKey(event.cityId); const events = shouldInsert ? [event, ...state.events] : state.events; return { events, @@ -78,7 +100,7 @@ export const useCommunityStore = create((set, get) => ({ async createCommunity(payload) { const community = await communityService.createCommunity(payload); if (!community) { - throw new Error('创建社群失败'); + throw new AppError('创建社群失败', { code: ERROR_CODES.BACKEND }); } set((state) => ({ groups: [community, ...state.groups], @@ -95,14 +117,20 @@ export const useCommunityStore = create((set, get) => ({ if (!updated) return null; set((state) => ({ eventMap: { ...state.eventMap, [eventId]: updated }, - events: state.events.map((event) => (event.id === eventId ? updated : event)) + events: state.events.map((event) => + event.id === eventId ? updated : event + ) })); return updated; }, communityById: (id) => get().groupMap[id] ?? null, eventById: (id) => get().eventMap[id] ?? null, adminDashboard: (communityId) => - get().adminDashboards[communityId] ?? { requests: [], members: [], logs: [] }, + get().adminDashboards[communityId] ?? { + requests: [], + members: [], + logs: [] + }, loadAdminDashboard: async (communityId) => { if (!communityId) return; const dashboard = await adminService.fetchDashboard(communityId); diff --git a/the-trash-rn/src/stores/leaderboardStore.js b/the-trash-rn/src/stores/leaderboardStore.js index c91ea8e..f3420c6 100644 --- a/the-trash-rn/src/stores/leaderboardStore.js +++ b/the-trash-rn/src/stores/leaderboardStore.js @@ -1,6 +1,9 @@ import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; import { leaderboardService } from 'src/services/leaderboard'; +import { messageFromError, toAppError } from 'src/utils/errors'; +import { createPersistStorage } from 'src/utils/storage'; const withRank = (entries) => entries.map((entry, index) => ({ @@ -10,72 +13,123 @@ const withRank = (entries) => const pickMyRanking = (entries) => entries.find((entry) => entry.isMe) ?? null; -export const useLeaderboardStore = create((set, get) => ({ - entries: [], - myRanking: null, - myCommunities: [], - selectedCommunityId: null, - loading: false, - loadingCommunities: false, - syncingContacts: false, +export const useLeaderboardStore = create( + persist( + (set, get) => ({ + entries: [], + myRanking: null, + myCommunities: [], + selectedCommunityId: null, + error: null, + loading: false, + loadingCommunities: false, + syncingContacts: false, + contactsSyncOptIn: false, + contactsLastSyncedAt: null, + contactsLastSyncStats: null, - setCommunity(communityId) { - set({ selectedCommunityId: communityId }); - }, + setCommunity(communityId) { + set({ selectedCommunityId: communityId }); + }, - async loadMyCommunities() { - set({ loadingCommunities: true }); - try { - const myCommunities = await leaderboardService.fetchMyCommunities(); - const currentId = get().selectedCommunityId; - const nextId = myCommunities.some((item) => item.id === currentId) - ? currentId - : (myCommunities[0]?.id ?? null); - set({ - myCommunities, - selectedCommunityId: nextId, - loadingCommunities: false - }); - return nextId; - } catch (error) { - console.warn('[leaderboard] load communities failed', error); - set({ - myCommunities: [], - selectedCommunityId: null, - loadingCommunities: false - }); - return null; - } - }, + setContactsSyncOptIn(enabled) { + set({ contactsSyncOptIn: Boolean(enabled), error: null }); + }, - async load(filter = 'community') { - set({ loading: true }); - try { - let communityId = get().selectedCommunityId; - if (filter === 'community' && !communityId) { - communityId = await get().loadMyCommunities(); - } - const rawEntries = await leaderboardService.fetch(filter, { - communityId - }); - const entries = withRank(rawEntries); - const myRanking = pickMyRanking(entries); - set({ entries, myRanking, loading: false }); - } catch (error) { - console.warn('[leaderboard] load failed', error); - set({ entries: [], myRanking: null, loading: false }); - } - }, + async loadMyCommunities() { + set({ loadingCommunities: true, error: null }); + try { + const myCommunities = await leaderboardService.fetchMyCommunities(); + const currentId = get().selectedCommunityId; + const nextId = myCommunities.some((item) => item.id === currentId) + ? currentId + : (myCommunities[0]?.id ?? null); + set({ + myCommunities, + selectedCommunityId: nextId, + loadingCommunities: false + }); + return nextId; + } catch (error) { + console.warn('[leaderboard] load communities failed', error); + set({ + myCommunities: [], + selectedCommunityId: null, + loadingCommunities: false, + error: messageFromError(error, '加载社群失败') + }); + return null; + } + }, + + async load(filter = 'community') { + set({ loading: true, error: null }); + try { + if (filter === 'friends' && !get().contactsSyncOptIn) { + set({ entries: [], myRanking: null, loading: false, error: null }); + return; + } - async syncContacts() { - set({ syncingContacts: true }); - try { - const entries = withRank(await leaderboardService.syncContacts()); - const myRanking = pickMyRanking(entries); - set({ entries, myRanking, syncingContacts: false }); - } catch (error) { - console.warn('[leaderboard] sync contacts failed', error); - set({ syncingContacts: false }); + let communityId = get().selectedCommunityId; + if (filter === 'community' && !communityId) { + communityId = await get().loadMyCommunities(); + } + + const rawEntries = await leaderboardService.fetch(filter, { + communityId, + explicitSync: filter === 'friends', + allowPermissionPrompt: false + }); + const entries = withRank(rawEntries); + const myRanking = pickMyRanking(entries); + set({ entries, myRanking, loading: false, error: null }); + } catch (error) { + console.warn('[leaderboard] load failed', error); + set({ + entries: [], + myRanking: null, + loading: false, + error: messageFromError(error, '加载排行榜失败') + }); + } + }, + + async syncContacts({ allowPermissionPrompt = true } = {}) { + set({ syncingContacts: true, error: null }); + try { + const { entries: rawEntries, syncStats } = + await leaderboardService.syncContacts({ + allowPermissionPrompt + }); + const entries = withRank(rawEntries); + const myRanking = pickMyRanking(entries); + set({ + entries, + myRanking, + contactsSyncOptIn: true, + contactsLastSyncedAt: Date.now(), + contactsLastSyncStats: syncStats ?? null, + syncingContacts: false, + error: null + }); + } catch (error) { + const appError = toAppError(error, { + message: '同步通讯录失败' + }); + console.warn('[leaderboard] sync contacts failed', appError); + set({ + syncingContacts: false, + error: appError.message + }); + } + } + }), + { + name: 'the-trash-leaderboard', + storage: createPersistStorage(), + partialize: (state) => ({ + contactsSyncOptIn: state.contactsSyncOptIn + }) } - } -})); + ) +); diff --git a/the-trash-rn/src/stores/profileStore.js b/the-trash-rn/src/stores/profileStore.js index 07450df..f75cc89 100644 --- a/the-trash-rn/src/stores/profileStore.js +++ b/the-trash-rn/src/stores/profileStore.js @@ -1,9 +1,7 @@ import { create } from 'zustand'; -import { supabase } from 'src/services/supabase'; -const hasSupabaseConfig = Boolean( - process.env.EXPO_PUBLIC_SUPABASE_URL && process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY -); +import { hasSupabaseConfig } from 'src/services/config'; +import { supabase } from 'src/services/supabase'; const emptyStats = { scans: 0, @@ -20,7 +18,7 @@ const getCurrentUser = async () => { }; const fetchStats = async () => { - if (!hasSupabaseConfig) { + if (!hasSupabaseConfig()) { return { ...emptyStats }; } const user = await getCurrentUser(); @@ -28,15 +26,17 @@ const fetchStats = async () => { return { ...emptyStats }; } - const [{ data: profileRow, error: profileError }, { data: challengeRows, error: challengeError }] = - await Promise.all([ - supabase - .from('profiles') - .select('credits,total_scans') - .eq('id', user.id) - .maybeSingle(), - supabase.rpc('get_my_challenges', { p_status: 'completed' }) - ]); + const [ + { data: profileRow, error: profileError }, + { data: challengeRows, error: challengeError } + ] = await Promise.all([ + supabase + .from('profiles') + .select('credits,total_scans') + .eq('id', user.id) + .maybeSingle(), + supabase.rpc('get_my_challenges', { p_status: 'completed' }) + ]); if (profileError) { throw new Error(profileError.message); @@ -46,7 +46,9 @@ const fetchStats = async () => { } const completedChallenges = Array.isArray(challengeRows) ? challengeRows : []; - const arenaWins = completedChallenges.filter((item) => item.winner_id === user.id).length; + const arenaWins = completedChallenges.filter( + (item) => item.winner_id === user.id + ).length; return { scans: profileRow?.total_scans ?? 0, diff --git a/the-trash-rn/src/stores/themeStore.js b/the-trash-rn/src/stores/themeStore.js index c47828e..9cc7e06 100644 --- a/the-trash-rn/src/stores/themeStore.js +++ b/the-trash-rn/src/stores/themeStore.js @@ -1,5 +1,6 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; + import { THEMES, DEFAULT_THEME } from 'src/theme/themes'; import { createPersistStorage } from 'src/utils/storage'; diff --git a/the-trash-rn/src/theme/ThemeProvider.js b/the-trash-rn/src/theme/ThemeProvider.js index a20938d..648804a 100644 --- a/the-trash-rn/src/theme/ThemeProvider.js +++ b/the-trash-rn/src/theme/ThemeProvider.js @@ -1,12 +1,16 @@ import { createContext, useContext } from 'react'; -import { THEMES } from './themes'; + import { useThemeStore } from 'src/stores/themeStore'; +import { THEMES } from './themes'; + const ThemeContext = createContext(THEMES.neon); export default function ThemeProvider({ children }) { const theme = useThemeStore((state) => state.theme); - return {children}; + return ( + {children} + ); } export const useTheme = () => useContext(ThemeContext); diff --git a/the-trash-rn/src/utils/__tests__/errors.test.js b/the-trash-rn/src/utils/__tests__/errors.test.js new file mode 100644 index 0000000..707cd27 --- /dev/null +++ b/the-trash-rn/src/utils/__tests__/errors.test.js @@ -0,0 +1,41 @@ +const { + AppError, + ERROR_CODES, + fromSupabaseError, + messageFromError, + toAppError +} = require('src/utils/errors'); + +describe('errors utils', () => { + test('toAppError wraps unknown values with fallback message', () => { + const error = toAppError({ not: 'an-error' }, { message: 'fallback' }); + expect(error).toBeInstanceOf(AppError); + expect(error.message).toBe('fallback'); + expect(error.code).toBe(ERROR_CODES.UNKNOWN); + }); + + test('toAppError keeps existing AppError', () => { + const original = new AppError('already wrapped', { + code: ERROR_CODES.AUTH + }); + const error = toAppError(original, { message: 'fallback' }); + expect(error).toBe(original); + expect(error.code).toBe(ERROR_CODES.AUTH); + }); + + test('fromSupabaseError preserves status and uses backend code by default', () => { + const error = fromSupabaseError( + { message: 'db down', status: 500 }, + { message: '服务失败' } + ); + expect(error).toBeInstanceOf(AppError); + expect(error.message).toBe('db down'); + expect(error.code).toBe(ERROR_CODES.BACKEND); + expect(error.meta.status).toBe(500); + }); + + test('messageFromError extracts message from native errors', () => { + const message = messageFromError(new Error('boom'), 'fallback'); + expect(message).toBe('boom'); + }); +}); diff --git a/the-trash-rn/src/utils/errors.js b/the-trash-rn/src/utils/errors.js new file mode 100644 index 0000000..3d72acd --- /dev/null +++ b/the-trash-rn/src/utils/errors.js @@ -0,0 +1,66 @@ +export const ERROR_CODES = { + UNKNOWN: 'UNKNOWN', + VALIDATION: 'VALIDATION', + AUTH: 'AUTH', + CONTACTS_PERMISSION_REQUIRED: 'CONTACTS_PERMISSION_REQUIRED', + CONTACTS_EMPTY: 'CONTACTS_EMPTY', + BACKEND: 'BACKEND' +}; + +export class AppError extends Error { + constructor(message, options = {}) { + super(message); + this.name = 'AppError'; + this.code = options.code ?? ERROR_CODES.UNKNOWN; + this.cause = options.cause ?? null; + this.meta = options.meta ?? null; + } +} + +const isAppError = (value) => value instanceof AppError; + +const normalizeMessage = (error, fallbackMessage) => { + if (typeof error === 'string' && error.trim()) return error.trim(); + if (error instanceof Error && error.message?.trim()) + return error.message.trim(); + if ( + error && + typeof error === 'object' && + typeof error.message === 'string' && + error.message.trim() + ) { + return error.message.trim(); + } + return fallbackMessage; +}; + +export const toAppError = (error, fallback = {}) => { + if (isAppError(error)) return error; + + const message = normalizeMessage( + error, + fallback.message ?? '请求失败,请稍后再试' + ); + return new AppError(message, { + code: fallback.code ?? ERROR_CODES.UNKNOWN, + cause: error ?? null, + meta: fallback.meta ?? null + }); +}; + +export const fromSupabaseError = (error, fallback = {}) => + toAppError(error, { + message: fallback.message ?? '服务暂时不可用,请稍后重试', + code: fallback.code ?? ERROR_CODES.BACKEND, + meta: { + ...(fallback.meta ?? {}), + status: error?.status ?? null, + hint: error?.hint ?? null, + details: error?.details ?? null + } + }); + +export const messageFromError = ( + error, + fallbackMessage = '操作失败,请稍后再试' +) => toAppError(error, { message: fallbackMessage }).message;