Merge branch 'fix/review-findings-2026-03-06' into v3.0
This commit is contained in:
commit
10b0ec5fcc
7 changed files with 259 additions and 75 deletions
48
DB_README.md
48
DB_README.md
|
|
@ -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
|
|
||||||
18
README.md
18
README.md
|
|
@ -63,7 +63,7 @@ Supabase config is required at runtime/build time (no embedded fallback values).
|
||||||
Use:
|
Use:
|
||||||
|
|
||||||
- `.env/flutter_defines.json` (local, ignored by git)
|
- `.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:
|
Expected shape:
|
||||||
|
|
||||||
|
|
@ -108,7 +108,7 @@ flutter run
|
||||||
|
|
||||||
### Run / Debug (`launch.json`)
|
### 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 - Debug)`
|
||||||
- `Flutter (Supabase Local - Profile)`
|
- `Flutter (Supabase Local - Profile)`
|
||||||
|
|
@ -126,6 +126,8 @@ Use **Terminal → Run Task**:
|
||||||
- `Flutter Build App Bundle (Release)`
|
- `Flutter Build App Bundle (Release)`
|
||||||
- `Flutter Build iOS IPA (Release)`
|
- `Flutter Build iOS IPA (Release)`
|
||||||
|
|
||||||
|
Task definition file: [.vscode/tasks.json](.vscode/tasks.json)
|
||||||
|
|
||||||
## Project Structure (high level)
|
## Project Structure (high level)
|
||||||
|
|
||||||
- `lib/screens/` UI screens and flows
|
- `lib/screens/` UI screens and flows
|
||||||
|
|
@ -173,7 +175,7 @@ Use **Terminal → Run Task**:
|
||||||
|
|
||||||
## Contributing
|
## 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
|
## 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.
|
- Sensitive local config files are git-ignored.
|
||||||
- User-facing errors are sanitized and shown via global overlays.
|
- 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
|
## License
|
||||||
|
|
||||||
This project is licensed under the MIT License.
|
This project is licensed under the MIT License.
|
||||||
See `LICENSE` for details.
|
See [LICENSE](LICENSE) for details.
|
||||||
|
|
||||||
## Reference
|
## Reference
|
||||||
|
|
||||||
- Contributor guidelines: `CONTRIBUTING.md`
|
- Contributor guidelines: [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||||
- Security policy: `SECURITY.md`
|
- Security policy: [SECURITY.md](SECURITY.md)
|
||||||
- Database and backend notes: `DB_README.md`
|
- DB schema export query pack: [db/export_schema.sql](db/export_schema.sql)
|
||||||
|
|
||||||
|
|
|
||||||
114
db/export_schema.sql
Normal file
114
db/export_schema.sql
Normal file
|
|
@ -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;
|
||||||
|
|
@ -209,6 +209,7 @@ class _AuthGateState extends State<AuthGate> {
|
||||||
bool _isInPasswordRecoveryFlow = false;
|
bool _isInPasswordRecoveryFlow = false;
|
||||||
Session? _session;
|
Session? _session;
|
||||||
String? _lastEnsuredUserId;
|
String? _lastEnsuredUserId;
|
||||||
|
StreamSubscription<AuthState>? _authStateSubscription;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -217,7 +218,7 @@ class _AuthGateState extends State<AuthGate> {
|
||||||
_session = supabase.auth.currentSession;
|
_session = supabase.auth.currentSession;
|
||||||
_ensureDefaultCollectionIfNeeded();
|
_ensureDefaultCollectionIfNeeded();
|
||||||
|
|
||||||
supabase.auth.onAuthStateChange.listen(
|
_authStateSubscription = supabase.auth.onAuthStateChange.listen(
|
||||||
(AuthState authState) {
|
(AuthState authState) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
|
|
@ -247,6 +248,12 @@ class _AuthGateState extends State<AuthGate> {
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_authStateSubscription?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _ensureDefaultCollectionIfNeeded() async {
|
Future<void> _ensureDefaultCollectionIfNeeded() async {
|
||||||
final userId = _session?.user.id;
|
final userId = _session?.user.id;
|
||||||
if (userId == null || userId == _lastEnsuredUserId) return;
|
if (userId == null || userId == _lastEnsuredUserId) return;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
//import 'package:supabase_flutter/supabase_flutter.dart';
|
//import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import '../main.dart';
|
import '../main.dart';
|
||||||
|
|
||||||
/// Data model for a collection.
|
/// Data model for a collection.
|
||||||
|
|
@ -120,7 +121,8 @@ class CollectionService {
|
||||||
'p_collection_id': collectionId,
|
'p_collection_id': collectionId,
|
||||||
});
|
});
|
||||||
memberCounts[collectionId] = (rows as List).length;
|
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;
|
if (collectionId == null) continue;
|
||||||
itemCounts[collectionId] = (row['total_count'] as num?)?.toInt() ?? 0;
|
itemCounts[collectionId] = (row['total_count'] as num?)?.toInt() ?? 0;
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (e) {
|
||||||
|
debugPrint('CollectionService.getCollectionItemCounts RPC error: $e');
|
||||||
|
}
|
||||||
|
|
||||||
final unresolvedIds = collectionIds
|
final unresolvedIds = collectionIds
|
||||||
.where((collectionId) => !itemCounts.containsKey(collectionId))
|
.where((collectionId) => !itemCounts.containsKey(collectionId))
|
||||||
|
|
@ -211,7 +215,9 @@ class CollectionService {
|
||||||
recent: (first['recent_count'] as num?)?.toInt() ?? 0,
|
recent: (first['recent_count'] as num?)?.toInt() ?? 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (e) {
|
||||||
|
debugPrint('CollectionService.getCollectionStats RPC error: $e');
|
||||||
|
}
|
||||||
|
|
||||||
final weekAgoIso = DateTime.now()
|
final weekAgoIso = DateTime.now()
|
||||||
.subtract(const Duration(days: 7))
|
.subtract(const Duration(days: 7))
|
||||||
|
|
@ -257,23 +263,77 @@ class CollectionService {
|
||||||
}) async {
|
}) async {
|
||||||
final userId = _requireUserId();
|
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<String, dynamic>
|
||||||
|
: <String, dynamic>{})
|
||||||
|
: rpcResult as Map<String, dynamic>;
|
||||||
|
|
||||||
|
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
|
final row = await supabase
|
||||||
.from('collections')
|
.from('collections')
|
||||||
.insert({
|
.insert({
|
||||||
'name': name,
|
'name': name,
|
||||||
'owner_id': userId,
|
'owner_id': userId,
|
||||||
if (description != null && description.isNotEmpty)
|
'description': normalizedDescription,
|
||||||
'description': description,
|
|
||||||
})
|
})
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
// Add owner as a member.
|
// Add owner as a member.
|
||||||
|
try {
|
||||||
await supabase.from('collection_members').insert({
|
await supabase.from('collection_members').insert({
|
||||||
'collection_id': row['id'],
|
'collection_id': row['id'],
|
||||||
'user_id': userId,
|
'user_id': userId,
|
||||||
'role': 'owner',
|
'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(
|
return Collection(
|
||||||
id: row['id'] as String,
|
id: row['id'] as String,
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,6 @@ class StorageService {
|
||||||
required int entryId,
|
required int entryId,
|
||||||
String? oldPath,
|
String? oldPath,
|
||||||
}) async {
|
}) async {
|
||||||
if (oldPath != null && oldPath.isNotEmpty) {
|
|
||||||
await deleteCarImage(oldPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
final user = supabase.auth.currentUser;
|
final user = supabase.auth.currentUser;
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
throw Exception('You must be signed in to upload images.');
|
throw Exception('You must be signed in to upload images.');
|
||||||
|
|
@ -50,6 +46,14 @@ class StorageService {
|
||||||
|
|
||||||
_signedUrlCache.remove(path);
|
_signedUrlCache.remove(path);
|
||||||
|
|
||||||
|
if (oldPath != null && oldPath.isNotEmpty && oldPath != path) {
|
||||||
|
try {
|
||||||
|
await deleteCarImage(oldPath);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('StorageService.uploadCarImage cleanup error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:hwhub/widgets/car_card.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
test('placeholder test', () {
|
testWidgets('CarCard renders model id and metadata', (tester) async {
|
||||||
expect(1 + 1, 2);
|
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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue