diff --git a/DB_README.md b/DB_README.md deleted file mode 100644 index a16f9e8..0000000 --- a/DB_README.md +++ /dev/null @@ -1,48 +0,0 @@ -# Database & Backend Notes - -> Status: **TBD** (to be completed later) - -This document is currently a placeholder and will be filled with the real as-is database state, scripts, migrations, RPCs, and RLS details. - -This document is the central place for database-related setup, schema notes, and backend conventions for car64. - -## Stack - -- Supabase Postgres -- Supabase Auth -- Supabase Storage -- Supabase RPC functions - -## Environments - -Document your environment endpoints and key handling policy here. - -## Schema Overview - -Document important tables and relations here, for example: - -- `collections` -- `collection_members` -- `hotwheels` -- `global_cars` -- `car_votes` -- `car_reports` - -## RLS Policies - -Document Row Level Security rules and expected access behavior. - -## RPC Functions - -List all RPC functions and their purpose. - -## Migration Workflow - -Describe how migrations are authored, reviewed, and applied. - -## Operational Checklist - -- Verify schema changes in staging first -- Confirm RLS policy impact -- Validate app compatibility with query/RPC changes -- Keep rollback strategy documented diff --git a/README.md b/README.md index 17f19c2..1db737a 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Supabase config is required at runtime/build time (no embedded fallback values). Use: - `.env/flutter_defines.json` (local, ignored by git) -- `.env/flutter_defines.example.json` (tracked template) +- [.env/flutter_defines.example.json](.env/flutter_defines.example.json) (tracked template) Expected shape: @@ -108,7 +108,7 @@ flutter run ### Run / Debug (`launch.json`) -Use **Run and Debug** with: +Use **Run and Debug** with your local launch config. - `Flutter (Supabase Local - Debug)` - `Flutter (Supabase Local - Profile)` @@ -126,6 +126,8 @@ Use **Terminal → Run Task**: - `Flutter Build App Bundle (Release)` - `Flutter Build iOS IPA (Release)` +Task definition file: [.vscode/tasks.json](.vscode/tasks.json) + ## Project Structure (high level) - `lib/screens/` UI screens and flows @@ -173,7 +175,7 @@ Use **Terminal → Run Task**: ## Contributing -See `CONTRIBUTING.md` for branching strategy, commit message rules, and PR guidelines. +See [CONTRIBUTING.md](CONTRIBUTING.md) for branching strategy, commit message rules, and PR guidelines. ## Security Notes @@ -181,16 +183,16 @@ See `CONTRIBUTING.md` for branching strategy, commit message rules, and PR guide - Sensitive local config files are git-ignored. - User-facing errors are sanitized and shown via global overlays. -For reporting vulnerabilities, see `SECURITY.md`. +For reporting vulnerabilities, see [SECURITY.md](SECURITY.md). ## License This project is licensed under the MIT License. -See `LICENSE` for details. +See [LICENSE](LICENSE) for details. ## Reference -- Contributor guidelines: `CONTRIBUTING.md` -- Security policy: `SECURITY.md` -- Database and backend notes: `DB_README.md` +- Contributor guidelines: [CONTRIBUTING.md](CONTRIBUTING.md) +- Security policy: [SECURITY.md](SECURITY.md) +- DB schema export query pack: [db/export_schema.sql](db/export_schema.sql) diff --git a/db/export_schema.sql b/db/export_schema.sql new file mode 100644 index 0000000..15f41e3 --- /dev/null +++ b/db/export_schema.sql @@ -0,0 +1,114 @@ +-- Run in Supabase SQL Editor or psql and export results as CSV/JSON. + +-- 1) App tables and columns (public only) +select + c.table_name, + c.ordinal_position, + c.column_name, + c.data_type, + c.udt_name, + c.is_nullable, + c.column_default +from information_schema.columns c +join information_schema.tables t + on t.table_schema = c.table_schema + and t.table_name = c.table_name +where c.table_schema = 'public' + and t.table_type = 'BASE TABLE' +order by c.table_name, c.ordinal_position; + +-- 2) Constraints +select + rel.relname as table_name, + con.conname as constraint_name, + case con.contype + when 'p' then 'PRIMARY KEY' + when 'u' then 'UNIQUE' + when 'f' then 'FOREIGN KEY' + when 'c' then 'CHECK' + else con.contype::text + end as constraint_type, + pg_get_constraintdef(con.oid, true) as definition +from pg_constraint con +join pg_class rel on rel.oid = con.conrelid +join pg_namespace n on n.oid = rel.relnamespace +where n.nspname = 'public' +order by rel.relname, con.conname; + +-- 3) Indexes +select + tablename, + indexname, + indexdef +from pg_indexes +where schemaname = 'public' +order by tablename, indexname; + +-- 4) Views +select + c.relname as view_name, + case c.relkind when 'v' then 'VIEW' when 'm' then 'MATERIALIZED VIEW' end as view_type, + pg_get_viewdef(c.oid, true) as definition +from pg_class c +join pg_namespace n on n.oid = c.relnamespace +where n.nspname = 'public' + and c.relkind in ('v', 'm') +order by c.relname; + +-- 5) Functions / Procedures +select + p.proname as routine_name, + case p.prokind when 'p' then 'PROCEDURE' else 'FUNCTION' end as routine_type, + l.lanname as language, + pg_get_function_identity_arguments(p.oid) as args, + pg_get_functiondef(p.oid) as definition +from pg_proc p +join pg_namespace n on n.oid = p.pronamespace +join pg_language l on l.oid = p.prolang +where n.nspname = 'public' +order by p.proname; + +-- 6) Triggers +select + c.relname as table_name, + t.tgname as trigger_name, + pg_get_triggerdef(t.oid, true) as definition +from pg_trigger t +join pg_class c on c.oid = t.tgrelid +join pg_namespace n on n.oid = c.relnamespace +where n.nspname = 'public' + and not t.tgisinternal +order by c.relname, t.tgname; + +-- 7) RLS status +select + c.relname as table_name, + c.relrowsecurity as rls_enabled, + c.relforcerowsecurity as rls_forced +from pg_class c +join pg_namespace n on n.oid = c.relnamespace +where n.nspname = 'public' + and c.relkind = 'r' +order by c.relname; + +-- 8) RLS policies +select + tablename, + policyname, + permissive, + roles, + cmd, + qual, + with_check +from pg_policies +where schemaname = 'public' +order by tablename, policyname; + +-- 9) Grants (tables) +select + table_name, + privilege_type, + grantee +from information_schema.role_table_grants +where table_schema = 'public' +order by table_name, grantee, privilege_type; diff --git a/lib/main.dart b/lib/main.dart index d1a040b..abaf0e7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -209,6 +209,7 @@ class _AuthGateState extends State { bool _isInPasswordRecoveryFlow = false; Session? _session; String? _lastEnsuredUserId; + StreamSubscription? _authStateSubscription; @override void initState() { @@ -217,7 +218,7 @@ class _AuthGateState extends State { _session = supabase.auth.currentSession; _ensureDefaultCollectionIfNeeded(); - supabase.auth.onAuthStateChange.listen( + _authStateSubscription = supabase.auth.onAuthStateChange.listen( (AuthState authState) { if (!mounted) return; @@ -247,6 +248,12 @@ class _AuthGateState extends State { setState(() => _isLoading = false); } + @override + void dispose() { + _authStateSubscription?.cancel(); + super.dispose(); + } + Future _ensureDefaultCollectionIfNeeded() async { final userId = _session?.user.id; if (userId == null || userId == _lastEnsuredUserId) return; diff --git a/lib/services/collection_service.dart b/lib/services/collection_service.dart index 023a52d..162be06 100644 --- a/lib/services/collection_service.dart +++ b/lib/services/collection_service.dart @@ -1,4 +1,5 @@ //import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:flutter/foundation.dart'; import '../main.dart'; /// Data model for a collection. @@ -120,7 +121,8 @@ class CollectionService { 'p_collection_id': collectionId, }); memberCounts[collectionId] = (rows as List).length; - } catch (_) { + } catch (e) { + debugPrint('CollectionService.getMyCollections member RPC error: $e'); } })); @@ -182,7 +184,9 @@ class CollectionService { if (collectionId == null) continue; itemCounts[collectionId] = (row['total_count'] as num?)?.toInt() ?? 0; } - } catch (_) {} + } catch (e) { + debugPrint('CollectionService.getCollectionItemCounts RPC error: $e'); + } final unresolvedIds = collectionIds .where((collectionId) => !itemCounts.containsKey(collectionId)) @@ -211,7 +215,9 @@ class CollectionService { recent: (first['recent_count'] as num?)?.toInt() ?? 0, ); } - } catch (_) {} + } catch (e) { + debugPrint('CollectionService.getCollectionStats RPC error: $e'); + } final weekAgoIso = DateTime.now() .subtract(const Duration(days: 7)) @@ -257,23 +263,77 @@ class CollectionService { }) async { final userId = _requireUserId(); + final trimmedDescription = description?.trim(); + final normalizedDescription = + (trimmedDescription != null && trimmedDescription.isNotEmpty) + ? trimmedDescription + : null; + + try { + final rpcResult = await supabase.rpc( + 'create_collection_with_owner', + params: { + 'p_name': name, + 'p_description': normalizedDescription, + }, + ); + + final row = rpcResult is List + ? (rpcResult.isNotEmpty + ? rpcResult.first as Map + : {}) + : rpcResult as Map; + + if (row.isNotEmpty) { + return Collection( + id: row['id'] as String, + name: row['name'] as String, + description: row['description'] as String?, + ownerId: row['owner_id'] as String, + createdAt: DateTime.parse(row['created_at'] as String), + role: 'owner', + itemCount: 0, + memberCount: 1, + ); + } + } catch (e) { + final message = e.toString(); + final rpcUnavailable = message.contains('create_collection_with_owner') && + (message.contains('not found') || + message.contains('does not exist') || + message.contains('PGRST202')); + if (!rpcUnavailable) { + rethrow; + } + debugPrint('CollectionService.create RPC unavailable, using fallback: $e'); + } + final row = await supabase .from('collections') .insert({ 'name': name, 'owner_id': userId, - if (description != null && description.isNotEmpty) - 'description': description, + 'description': normalizedDescription, }) .select() .single(); // Add owner as a member. - await supabase.from('collection_members').insert({ - 'collection_id': row['id'], - 'user_id': userId, - 'role': 'owner', - }); + try { + await supabase.from('collection_members').insert({ + 'collection_id': row['id'], + 'user_id': userId, + 'role': 'owner', + }); + } catch (e) { + debugPrint('CollectionService.create member insert failed: $e'); + try { + await supabase.from('collections').delete().eq('id', row['id']); + } catch (cleanupError) { + debugPrint('CollectionService.create rollback failed: $cleanupError'); + } + rethrow; + } return Collection( id: row['id'] as String, diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 5814152..73c3707 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -27,10 +27,6 @@ class StorageService { required int entryId, String? oldPath, }) async { - if (oldPath != null && oldPath.isNotEmpty) { - await deleteCarImage(oldPath); - } - final user = supabase.auth.currentUser; if (user == null) { throw Exception('You must be signed in to upload images.'); @@ -50,6 +46,14 @@ class StorageService { _signedUrlCache.remove(path); + if (oldPath != null && oldPath.isNotEmpty && oldPath != path) { + try { + await deleteCarImage(oldPath); + } catch (e) { + debugPrint('StorageService.uploadCarImage cleanup error: $e'); + } + } + return path; } diff --git a/test/widget_test.dart b/test/widget_test.dart index f485109..8901f03 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,9 +1,54 @@ -// Basic smoke test placeholder — will be updated when UI is finalized. - +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:hwhub/widgets/car_card.dart'; void main() { - test('placeholder test', () { - expect(1 + 1, 2); + testWidgets('CarCard renders model id and metadata', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 220, + height: 360, + child: CarCard( + hwId: 'ABC12', + name: 'Die-Cast Model', + series: 'Collector Series', + year: 2024, + isVerified: true, + ), + ), + ), + ), + ), + ); + + expect(find.text('ABC12'), findsOneWidget); + expect(find.text('Die-Cast Model'), findsOneWidget); + expect(find.text('Collector Series'), findsOneWidget); + expect(find.text('2024'), findsOneWidget); + expect(find.text('Verified'), findsOneWidget); + }); + + testWidgets('CarCard shows selected state label', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 220, + height: 360, + child: CarCard( + hwId: 'XYZ99', + isSelected: true, + ), + ), + ), + ), + ), + ); + + expect(find.text('Selected'), findsOneWidget); }); }