feat(reporting): add in-app car report form with Supabase persistence
This commit is contained in:
parent
1395a75f1b
commit
e5773839e5
2 changed files with 395 additions and 0 deletions
|
|
@ -629,6 +629,17 @@ class GarageScreenState extends State<GarageScreen> {
|
|||
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<GarageScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _reportCatalogEntry(
|
||||
Map<String, dynamic> 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<String?> _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<String> _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<String>(
|
||||
initialValue: _selectedReason,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Reason',
|
||||
),
|
||||
items: _reasons
|
||||
.map(
|
||||
(reason) => DropdownMenuItem<String>(
|
||||
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;
|
||||
|
|
|
|||
235
supabase_migration.sql
Normal file
235
supabase_migration.sql
Normal file
|
|
@ -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());
|
||||
Loading…
Reference in a new issue