import 'dart:convert'; import 'package:postgres/postgres.dart'; /// Per-user trading worker cursor ([user_trading_state]). class UserTradingStateDb { UserTradingStateDb(this._connection); final Connection _connection; static const String ingestContextKey = 'ingest_last_fetch'; static const String rulesContextKey = 'rules'; static const String pendingOrdersContextKey = 'pending_orders'; static const String skippedContextKey = 'skipped'; static const String guessScoreContextKey = 'guess_score'; static const String guessSymbolCooldownContextKey = 'guess_symbol_cooldown'; Future ensureExists(String firebaseUid) async { await _connection.execute( Sql.named( ''' INSERT INTO user_trading_state (firebase_uid) VALUES (@uid) ON CONFLICT (firebase_uid) DO NOTHING ''', ), parameters: {'uid': firebaseUid}, ); } Future> getContext(String firebaseUid) async { await ensureExists(firebaseUid); final Result result = await _connection.execute( Sql.named( 'SELECT context FROM user_trading_state WHERE firebase_uid = @uid', ), parameters: {'uid': firebaseUid}, ); if (result.isEmpty) { return {}; } return _readJsonMap(result.first[0]); } /// ISO-8601 timestamp of last successful fetch for [dataInputId], or null. Future getInputLastFetch( String firebaseUid, String dataInputId, ) async { final Map context = await getContext(firebaseUid); final Map ingest = Map.from( context[ingestContextKey] as Map? ?? {}, ); final String? raw = ingest[dataInputId] as String?; if (raw == null || raw.isEmpty) { return null; } return DateTime.parse(raw).toUtc(); } Future recordInputFetch( String firebaseUid, String dataInputId, DateTime fetchedAt, ) async { await ensureExists(firebaseUid); final Map context = await getContext(firebaseUid); final Map ingest = Map.from( context[ingestContextKey] as Map? ?? {}, ); ingest[dataInputId] = fetchedAt.toUtc().toIso8601String(); context[ingestContextKey] = ingest; await _connection.execute( Sql.named( ''' UPDATE user_trading_state SET context = @context::jsonb, last_ingest_at = @last_ingest_at, updated_at = now() WHERE firebase_uid = @uid ''', ), parameters: { 'uid': firebaseUid, 'context': jsonEncode(context), 'last_ingest_at': fetchedAt.toUtc(), }, ); } Future?> getRuleState( String firebaseUid, String ruleId, ) async { final Map context = await getContext(firebaseUid); final Map rules = Map.from( context[rulesContextKey] as Map? ?? {}, ); final Map? raw = rules[ruleId] as Map?; if (raw == null) { return null; } return Map.from(raw); } Future getRuleLastFiredAt( String firebaseUid, String ruleId, ) async { final Map? state = await getRuleState(firebaseUid, ruleId); final String? raw = state?['last_fired_at'] as String?; if (raw == null || raw.isEmpty) { return null; } return DateTime.parse(raw).toUtc(); } Future setRuleState({ required String firebaseUid, required String ruleId, required Map state, }) async { await ensureExists(firebaseUid); final Map context = await getContext(firebaseUid); final Map rules = Map.from( context[rulesContextKey] as Map? ?? {}, ); rules[ruleId] = state; context[rulesContextKey] = rules; await _writeContext(firebaseUid, context, touchEvalAt: true); } Future>> listPendingOrders( String firebaseUid, ) async { final Map context = await getContext(firebaseUid); final List raw = context[pendingOrdersContextKey] as List? ?? []; return raw .whereType() .map((Map m) => Map.from(m)) .toList(); } Future addPendingOrder({ required String firebaseUid, required Map order, }) async { await ensureExists(firebaseUid); final Map context = await getContext(firebaseUid); final List existing = context[pendingOrdersContextKey] as List? ?? []; final List> orders = >[ ...existing.whereType().map( (Map m) => Map.from(m), ), order, ]; context[pendingOrdersContextKey] = orders; await _writeContext(firebaseUid, context, touchEvalAt: true); } Future removePendingOrder({ required String firebaseUid, required String clientOrderId, }) async { await ensureExists(firebaseUid); final Map context = await getContext(firebaseUid); final List existing = context[pendingOrdersContextKey] as List? ?? []; final List> kept = existing .whereType() .map((Map m) => Map.from(m)) .where((Map m) => m['client_order_id'] != clientOrderId) .toList(); context[pendingOrdersContextKey] = kept; await _writeContext(firebaseUid, context, touchEvalAt: false); } Future?> getGuessScore(String firebaseUid) async { final Map context = await getContext(firebaseUid); final Object? raw = context[guessScoreContextKey]; if (raw is Map) { return Map.from(raw); } return null; } Future recordGuessScore({ required String firebaseUid, required int scoreDelta, required String symbol, required DateTime at, }) async { await ensureExists(firebaseUid); final Map context = await getContext(firebaseUid); final Map prior = Map.from( context[guessScoreContextKey] as Map? ?? {}, ); final int total = ((prior['total'] as num?)?.toInt() ?? 0) + scoreDelta; context[guessScoreContextKey] = { 'total': total, 'last': { 'score_delta': scoreDelta, 'symbol': symbol, 'at': at.toUtc().toIso8601String(), }, }; await _writeContext(firebaseUid, context, touchEvalAt: true); } Future getGuessSymbolLastPickedAt( String firebaseUid, String symbol, ) async { final Map context = await getContext(firebaseUid); final Map cooldown = Map.from( context[guessSymbolCooldownContextKey] as Map? ?? {}, ); final String? raw = cooldown[symbol] as String?; if (raw == null || raw.isEmpty) { return null; } return DateTime.parse(raw).toUtc(); } Future isGuessSymbolOnCooldown({ required String firebaseUid, required String symbol, required DateTime now, int cooldownHours = 24, }) async { final DateTime? last = await getGuessSymbolLastPickedAt(firebaseUid, symbol); if (last == null) { return false; } return now.difference(last) < Duration(hours: cooldownHours); } Future recordGuessSymbolPicked({ required String firebaseUid, required String symbol, required DateTime at, }) async { await ensureExists(firebaseUid); final Map context = await getContext(firebaseUid); final Map cooldown = Map.from( context[guessSymbolCooldownContextKey] as Map? ?? {}, ); cooldown[symbol] = at.toUtc().toIso8601String(); context[guessSymbolCooldownContextKey] = cooldown; await _writeContext(firebaseUid, context, touchEvalAt: true); } Future recordSkip({ required String firebaseUid, required String ruleId, required String questionId, required DateTime at, }) async { await ensureExists(firebaseUid); final Map context = await getContext(firebaseUid); final List existing = context[skippedContextKey] as List? ?? []; final List> skipped = >[ ...existing.whereType().map( (Map m) => Map.from(m), ), { 'rule_id': ruleId, 'question_id': questionId, 'at': at.toUtc().toIso8601String(), }, ]; context[skippedContextKey] = skipped; await _writeContext(firebaseUid, context, touchEvalAt: false); } Future _writeContext( String firebaseUid, Map context, { required bool touchEvalAt, }) async { final String setEval = touchEvalAt ? ', last_eval_at = now()' : ''; await _connection.execute( Sql.named( ''' UPDATE user_trading_state SET context = @context::jsonb, updated_at = now() $setEval WHERE firebase_uid = @uid ''', ), parameters: { 'uid': firebaseUid, 'context': jsonEncode(context), }, ); } Map _readJsonMap(Object? value) { if (value is Map) { return value; } if (value is Map) { return Map.from(value); } if (value == null) { return {}; } return jsonDecode(value.toString()) as Map; } }