diff --git a/lib/screens/garage_screen.dart b/lib/screens/garage_screen.dart index 3fd1697..a6e8f51 100644 --- a/lib/screens/garage_screen.dart +++ b/lib/screens/garage_screen.dart @@ -629,6 +629,17 @@ class GarageScreenState extends State { const SizedBox(height: 10), ], + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => _reportCatalogEntry(car, context), + icon: const Icon(Icons.flag_outlined, size: 18), + label: const Text('Report Catalog Issue'), + ), + ), + + const SizedBox(height: 12), + // Edit & Delete buttons Row( children: [ @@ -787,6 +798,56 @@ class GarageScreenState extends State { } } + Future _reportCatalogEntry( + Map car, + BuildContext sheetContext, + ) async { + final user = supabase.auth.currentUser; + if (user == null) return; + + final payload = await showDialog<_CarReportDraft>( + context: sheetContext, + builder: (_) => const _ReportCarDialog(), + ); + + if (payload == null) return; + + final hwId = car['hw_id'] as String?; + if (hwId == null || hwId.isEmpty) { + showGlobalSnackBar('Cannot report this item: missing hw_id.', isError: true); + return; + } + + final hotwheelsId = car['id'] as int?; + + try { + final existingOpen = await supabase + .from('car_reports') + .select('id') + .eq('hw_id', hwId) + .eq('reporter_user_id', user.id) + .eq('status', 'open') + .maybeSingle(); + + if (existingOpen != null) { + showGlobalSnackBar('You already have an open report for this car.'); + return; + } + + await supabase.from('car_reports').insert({ + 'hw_id': hwId, + 'hotwheels_id': hotwheelsId, + 'reporter_user_id': user.id, + 'reason': payload.reason, + 'note': payload.note, + }); + + showGlobalSnackBar('Thanks for reporting. We will review this entry.'); + } catch (e) { + showGlobalSnackBar('Failed to submit report: $e', isError: true); + } + } + Future _pickTargetCollection() async { final collections = await CollectionService.getMyCollections(); if (!mounted) return null; @@ -1121,6 +1182,105 @@ class _EditCarDialogState extends State<_EditCarDialog> { } } +class _CarReportDraft { + final String reason; + final String? note; + + const _CarReportDraft({ + required this.reason, + this.note, + }); +} + +class _ReportCarDialog extends StatefulWidget { + const _ReportCarDialog(); + + @override + State<_ReportCarDialog> createState() => _ReportCarDialogState(); +} + +class _ReportCarDialogState extends State<_ReportCarDialog> { + static const List _reasons = [ + 'Wrong model name', + 'Wrong series or year', + 'Duplicate catalog entry', + 'Inappropriate or invalid image', + 'Other', + ]; + + String _selectedReason = _reasons.first; + final TextEditingController _noteCtrl = TextEditingController(); + + @override + void dispose() { + _noteCtrl.dispose(); + super.dispose(); + } + + void _submit() { + final note = _noteCtrl.text.trim(); + Navigator.pop( + context, + _CarReportDraft( + reason: _selectedReason, + note: note.isEmpty ? null : note, + ), + ); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + icon: const Icon(Icons.flag_outlined, color: AppColors.orange, size: 32), + title: const Text('Report catalog issue'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DropdownButtonFormField( + initialValue: _selectedReason, + decoration: const InputDecoration( + labelText: 'Reason', + ), + items: _reasons + .map( + (reason) => DropdownMenuItem( + value: reason, + child: Text(reason), + ), + ) + .toList(), + onChanged: (value) { + if (value == null) return; + setState(() => _selectedReason = value); + }, + ), + const SizedBox(height: 12), + TextField( + controller: _noteCtrl, + maxLines: 3, + decoration: const InputDecoration( + labelText: 'Details (optional)', + hintText: 'Add a short note to help moderation…', + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: _submit, + child: const Text('Submit Report'), + ), + ], + ); + } +} + class _DetailRow extends StatelessWidget { final IconData icon; final String label; diff --git a/supabase_migration.sql b/supabase_migration.sql new file mode 100644 index 0000000..5265522 --- /dev/null +++ b/supabase_migration.sql @@ -0,0 +1,235 @@ +-- ══════════════════════════════════════════════════════════════════════ +-- HW Collector Hub – Multi-Collection Migration +-- Run this in the Supabase SQL Editor (one-time). +-- ══════════════════════════════════════════════════════════════════════ + +-- 1) Collections table +CREATE TABLE IF NOT EXISTS collections ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + owner_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT now() +); + +-- 2) Collection members (join table) +CREATE TABLE IF NOT EXISTS collection_members ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + collection_id UUID NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'member' + CHECK (role IN ('owner', 'member')), + created_at TIMESTAMPTZ DEFAULT now(), + UNIQUE(collection_id, user_id) +); + +-- 3) Add collection_id column to hotwheels +ALTER TABLE hotwheels + ADD COLUMN IF NOT EXISTS collection_id UUID REFERENCES collections(id) ON DELETE CASCADE; + +-- ── RLS: collections ────────────────────────────────────────────────── +ALTER TABLE collections ENABLE ROW LEVEL SECURITY; + +-- Users can see collections they are a member of. +CREATE POLICY "Members can view collections" + ON collections FOR SELECT + USING ( + owner_id = auth.uid() + OR id IN ( + SELECT collection_id FROM collection_members + WHERE user_id = auth.uid() + ) + ); + +-- Any authenticated user can create a collection. +CREATE POLICY "Authenticated users can create collections" + ON collections FOR INSERT + WITH CHECK (auth.uid() = owner_id); + +-- Only the owner can update. +CREATE POLICY "Owner can update collection" + ON collections FOR UPDATE + USING (owner_id = auth.uid()); + +-- Only the owner can delete. +CREATE POLICY "Owner can delete collection" + ON collections FOR DELETE + USING (owner_id = auth.uid()); + +-- ── RLS: collection_members ────────────────────────────────────────── +ALTER TABLE collection_members ENABLE ROW LEVEL SECURITY; + +-- Users can see their own memberships (avoids infinite recursion). +CREATE POLICY "Users can view own memberships" + ON collection_members FOR SELECT + USING (user_id = auth.uid()); + +-- Owner can add members. +CREATE POLICY "Owner can add members" + ON collection_members FOR INSERT + WITH CHECK ( + collection_id IN ( + SELECT id FROM collections WHERE owner_id = auth.uid() + ) + ); + +-- Owner can remove members. +CREATE POLICY "Owner can remove members" + ON collection_members FOR DELETE + USING ( + collection_id IN ( + SELECT id FROM collections WHERE owner_id = auth.uid() + ) + OR user_id = auth.uid() -- members can remove themselves + ); + +-- ── RLS: hotwheels (update existing) ──────────────────────────────── +-- Drop old policies first (they were user_id based). +DROP POLICY IF EXISTS "Enable read access for all users" ON hotwheels; +DROP POLICY IF EXISTS "Enable insert for authenticated users only" ON hotwheels; +DROP POLICY IF EXISTS "Enable update for users based on user_id" ON hotwheels; +DROP POLICY IF EXISTS "Enable delete for users based on user_id" ON hotwheels; + +-- Members of a collection can see its cars. +CREATE POLICY "Collection members can view cars" + ON hotwheels FOR SELECT + USING ( + collection_id IN ( + SELECT collection_id FROM collection_members + WHERE user_id = auth.uid() + ) + ); + +-- Members can add cars. +CREATE POLICY "Collection members can insert cars" + ON hotwheels FOR INSERT + WITH CHECK ( + collection_id IN ( + SELECT collection_id FROM collection_members + WHERE user_id = auth.uid() + ) + ); + +-- Members can update cars in their collections. +CREATE POLICY "Collection members can update cars" + ON hotwheels FOR UPDATE + USING ( + collection_id IN ( + SELECT collection_id FROM collection_members + WHERE user_id = auth.uid() + ) + ); + +-- Members can delete cars from their collections. +CREATE POLICY "Collection members can delete cars" + ON hotwheels FOR DELETE + USING ( + collection_id IN ( + SELECT collection_id FROM collection_members + WHERE user_id = auth.uid() + ) + ); + +-- ══════════════════════════════════════════════════════════════════════ +-- MIGRATION HELPER: Move existing cars into a default collection +-- for each user that already has cars. +-- ══════════════════════════════════════════════════════════════════════ +DO $$ +DECLARE + _user RECORD; + _coll UUID; +BEGIN + FOR _user IN + SELECT DISTINCT user_id FROM hotwheels WHERE collection_id IS NULL + LOOP + -- Create a default "My Collection" for this user. + INSERT INTO collections (name, owner_id) + VALUES ('My Collection', _user.user_id) + RETURNING id INTO _coll; + + -- Owner is also a member. + INSERT INTO collection_members (collection_id, user_id, role) + VALUES (_coll, _user.user_id, 'owner'); + + -- Assign all existing cars to this collection. + UPDATE hotwheels + SET collection_id = _coll + WHERE user_id = _user.user_id AND collection_id IS NULL; + END LOOP; +END $$; + +-- After migration, make collection_id NOT NULL. +ALTER TABLE hotwheels ALTER COLUMN collection_id SET NOT NULL; + +-- ══════════════════════════════════════════════════════════════════════ +-- RPC: Look up a user's ID by email (for inviting) +-- ══════════════════════════════════════════════════════════════════════ +CREATE OR REPLACE FUNCTION get_user_id_by_email(lookup_email TEXT) +RETURNS UUID +LANGUAGE sql +SECURITY DEFINER -- runs as postgres, can read auth.users +SET search_path = '' +AS $$ + SELECT id FROM auth.users + WHERE email = lower(lookup_email) + LIMIT 1; +$$; + +-- ══════════════════════════════════════════════════════════════════════ +-- RPC: Get collection members with their emails +-- ══════════════════════════════════════════════════════════════════════ +CREATE OR REPLACE FUNCTION get_collection_members(p_collection_id UUID) +RETURNS TABLE( + id UUID, + user_id UUID, + email TEXT, + role TEXT, + created_at TIMESTAMPTZ +) +LANGUAGE sql +SECURITY DEFINER +SET search_path = '' +AS $$ + SELECT + cm.id, + cm.user_id, + u.email, + cm.role, + cm.created_at + FROM public.collection_members cm + JOIN auth.users u ON u.id = cm.user_id + WHERE cm.collection_id = p_collection_id + AND cm.collection_id IN ( + SELECT cm2.collection_id FROM public.collection_members cm2 + WHERE cm2.user_id = auth.uid() + ) + ORDER BY + CASE cm.role WHEN 'owner' THEN 0 ELSE 1 END, + cm.created_at; +$$; + +-- ══════════════════════════════════════════════════════════════════════ +-- Community reporting: car_reports +-- ══════════════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS car_reports ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + hw_id VARCHAR NOT NULL REFERENCES global_cars(hw_id) ON DELETE CASCADE, + hotwheels_id BIGINT REFERENCES hotwheels(id) ON DELETE SET NULL, + reporter_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + reason TEXT NOT NULL, + note TEXT, + status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'reviewed', 'resolved', 'dismissed')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +ALTER TABLE car_reports ENABLE ROW LEVEL SECURITY; + +-- Reporters can create reports only for themselves. +CREATE POLICY "Users can create own car reports" + ON car_reports FOR INSERT + WITH CHECK (reporter_user_id = auth.uid()); + +-- Reporters can see their own reports. +CREATE POLICY "Users can view own car reports" + ON car_reports FOR SELECT + USING (reporter_user_id = auth.uid());