feat(reporting): add in-app car report form with Supabase persistence

This commit is contained in:
Lukas Müllner 2026-03-04 11:41:41 +01:00
parent 1395a75f1b
commit e5773839e5
2 changed files with 395 additions and 0 deletions

View file

@ -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
View 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());