import 'dart:convert'; import 'package:postgres/postgres.dart'; import 'package:uuid/uuid.dart'; import 'question_source_mix.dart'; import 'trading/market_history_config.dart'; import 'trading/prospective_answer_scoring.dart'; import 'trading/guess_score_store.dart'; import 'trading/prospective_guess_assignments_db.dart'; import 'trading/prospective_guess_selection.dart'; import 'trading/user_trading_state_db.dart'; /// Postgres access for the questions table. class QuestionsDb { QuestionsDb(this._connection); final Pool _connection; Pool get connection => _connection; static const Uuid _uuid = Uuid(); Future ensureUserExists(String firebaseUid) async { await _connection.execute( Sql.named( ''' INSERT INTO users (firebase_uid) VALUES (@uid) ON CONFLICT (firebase_uid) DO NOTHING ''', ), parameters: {'uid': firebaseUid}, ); } static const String _questionsSelectColumns = ''' q.id, q.assigned_user_id, q.question_text, q.user_response, q.correct_answer, q.created_at, q.modified_at, q.source_tag, q.pipeline_key, q.pipeline_step, q.metadata, q.user_response_text'''; static const String _unansweredQuestionsJoin = ''' FROM questions q LEFT JOIN market_history_prospective_assignments a ON a.question_id = q.id AND a.assigned_user_id = q.assigned_user_id AND a.status = 'pending' WHERE q.assigned_user_id = @uid AND q.user_response IS NULL'''; static const String _unansweredQuestionsOrder = ''' ORDER BY COALESCE(a.view_order_at, q.created_at) ASC, q.id ASC'''; /// Latest unanswered question for [assignedUserId], or null if none. Future?> findUnansweredQuestion( String assignedUserId, ) async { final Result result = await _connection.execute( Sql.named( ''' SELECT $_questionsSelectColumns $_unansweredQuestionsJoin $_unansweredQuestionsOrder LIMIT 1 ''', ), parameters: {'uid': assignedUserId}, ); if (result.isEmpty) { return null; } return _rowFromResult(result.first); } /// All unanswered questions for [assignedUserId], oldest first. Future>> listUnansweredQuestions( String assignedUserId, ) async { final Result result = await _connection.execute( Sql.named( ''' SELECT $_questionsSelectColumns $_unansweredQuestionsJoin $_unansweredQuestionsOrder ''', ), parameters: {'uid': assignedUserId}, ); return result.map(_rowFromResult).toList(); } /// FIFO head after deferring [deferredQuestionId] (falls back to sole item). Future?> findNextUnansweredAfterDefer({ required String assignedUserId, required String deferredQuestionId, }) async { final List> rows = await listUnansweredQuestions(assignedUserId); if (rows.isEmpty) { return null; } for (final Map row in rows) { if (row['id'] != deferredQuestionId) { return row; } } return rows.first; } /// Deletes every spelling pipeline question row for [firebaseUid]. Future deleteAllSpellingQuestionsForUser(String firebaseUid) async { await _connection.execute( Sql.named( ''' DELETE FROM questions WHERE assigned_user_id = @uid AND ( pipeline_key = 'spelling' OR source_tag LIKE 'spelling:%' ) ''', ), parameters: {'uid': firebaseUid}, ); } /// Clears spelling pipeline state when [assignedUserId] is on that branch. Future clearSpellingPipelineState(String assignedUserId) async { await _connection.execute( Sql.named( ''' DELETE FROM user_pipeline_state WHERE assigned_user_id = @uid AND pipeline_key = 'spelling' ''', ), parameters: {'uid': assignedUserId}, ); } /// Deletes every guess-game question row for [firebaseUid]. /// /// Assignments and answer snapshots referencing these rows cascade away. Future deleteAllProspectiveGuessQuestionsForUser( String firebaseUid, ) async { await _connection.execute( Sql.named( ''' DELETE FROM questions WHERE assigned_user_id = @uid AND source_tag = 'market_history:prospective' ''', ), parameters: {'uid': firebaseUid}, ); } /// Removes an unanswered question owned by [assignedUserId]. Future deleteUnansweredQuestion({ required String questionId, required String assignedUserId, }) async { await _connection.execute( Sql.named( ''' DELETE FROM questions WHERE id = @id::uuid AND assigned_user_id = @uid AND user_response IS NULL ''', ), parameters: { 'id': questionId, 'uid': assignedUserId, }, ); } /// Records [userResponse] for an unanswered question owned by [assignedUserId]. /// /// When the answered question metadata includes `prospective_question_id`, /// also snapshots the answer into `market_history_prospective_answers`. Future?> submitAnswer({ required String questionId, required String assignedUserId, num? userResponse, String? userResponseText, }) async { if (userResponse == null && userResponseText == null) { throw ArgumentError('userResponse or userResponseText is required'); } num effectiveResponse = userResponse ?? 0; String? textToStore = userResponseText?.trim(); if (textToStore != null) { final Map? existing = await findQuestionById( questionId: questionId, assignedUserId: assignedUserId, ); if (existing == null || resolveSourceId(existing) != 'spelling') { return null; } } final DateTime now = DateTime.now().toUtc(); final Result result = await _connection.execute( Sql.named( ''' UPDATE questions SET user_response = @user_response, user_response_text = @user_response_text, modified_at = @modified_at WHERE id = @id::uuid AND assigned_user_id = @uid AND user_response IS NULL RETURNING id, assigned_user_id, question_text, user_response, correct_answer, created_at, modified_at, source_tag, pipeline_key, pipeline_step, metadata, user_response_text ''', ), parameters: { 'id': questionId, 'uid': assignedUserId, 'user_response': effectiveResponse, 'user_response_text': textToStore, 'modified_at': now, }, ); if (result.isEmpty) { return null; } final Map updated = _rowFromResult(result.first); if (resolveSourceId(updated) == 'market_history') { await _recordProspectiveAnswer(updated); await ProspectiveGuessAssignmentsDb(_connection).markAnsweredByQuestionId( questionId: questionId, answeredAt: now, ); await _gradeProspectiveAnswerIfNeeded(updated); } return updated; } /// Answered questions grouped by client [resolveSourceId]. Future> countDeliveredBySourceId(String assignedUserId) async { final Result result = await _connection.execute( Sql.named( ''' SELECT source_tag, pipeline_key FROM questions WHERE assigned_user_id = @uid AND user_response IS NOT NULL ''', ), parameters: {'uid': assignedUserId}, ); final Map counts = {}; for (final ResultRow row in result) { final String sourceId = resolveSourceId({ 'sourceTag': row[0], 'pipelineKey': row[1], }); counts[sourceId] = (counts[sourceId] ?? 0) + 1; } return counts; } /// Mix-ordered head of the unanswered queue for display after an answer or panel open. Future?> findHeadUnansweredForMix( String assignedUserId, { required Map effectiveMixPercent, Map? deliveredCounts, String? afterAnsweringSourceId, Set? allowedSourceIds, }) async { List> rows = await listUnansweredQuestions(assignedUserId); if (allowedSourceIds != null) { rows = filterByAllowedSources(rows, allowedSourceIds); } if (rows.isEmpty) { return null; } if (afterAnsweringSourceId == 'spelling') { for (final Map row in rows) { if (resolveSourceId(row) == 'spelling') { return row; } } } final Map delivered = deliveredCounts ?? await countDeliveredBySourceId(assignedUserId); final String preferred = QuestionSourceMix.pickNextSourceId( effectiveMixPercent: effectiveMixPercent, deliveredCounts: delivered, ); final List> ordered = QuestionSourceMix.orderUnansweredByPreferredSource( rows, preferredSourceId: preferred, ); return ordered.first; } /// Full unanswered list ordered for panel display (preferred mix source first). Future>> listUnansweredOrderedForMix( String assignedUserId, { required Map effectiveMixPercent, Set? allowedSourceIds, }) async { List> rows = await listUnansweredQuestions(assignedUserId); if (allowedSourceIds != null) { rows = filterByAllowedSources(rows, allowedSourceIds); } if (rows.isEmpty) { return rows; } final Map delivered = await countDeliveredBySourceId(assignedUserId); final String preferred = QuestionSourceMix.pickNextSourceId( effectiveMixPercent: effectiveMixPercent, deliveredCounts: delivered, ); return QuestionSourceMix.orderUnansweredByPreferredSource( rows, preferredSourceId: preferred, ); } /// Keeps only rows whose [resolveSourceId] is in [allowedSourceIds]. static List> filterByAllowedSources( List> rows, Set allowedSourceIds, ) { if (allowedSourceIds.isEmpty) { return >[]; } return rows .where( (Map row) => allowedSourceIds.contains(resolveSourceId(row)), ) .toList(growable: false); } Future?> findQuestionById({ required String questionId, required String assignedUserId, }) async { final Result result = await _connection.execute( Sql.named( ''' SELECT id, assigned_user_id, question_text, user_response, correct_answer, created_at, modified_at, source_tag, pipeline_key, pipeline_step, metadata, user_response_text FROM questions WHERE id = @id::uuid AND assigned_user_id = @uid ''', ), parameters: { 'id': questionId, 'uid': assignedUserId, }, ); if (result.isEmpty) { return null; } return _rowFromResult(result.first); } /// All Firebase UIDs that have a profile row (pipeline targets). Future> listAllUserFirebaseUids() async { final Result result = await _connection.execute( 'SELECT firebase_uid FROM users ORDER BY updated_at DESC', ); return result .map((ResultRow row) => row[0]! as String) .toList(); } Future?> getPipelineState(String assignedUserId) async { final Result result = await _connection.execute( Sql.named( ''' SELECT pipeline_key, step, context, updated_at FROM user_pipeline_state WHERE assigned_user_id = @uid ''', ), parameters: {'uid': assignedUserId}, ); if (result.isEmpty) { return null; } final ResultRow row = result.first; final Object? contextRaw = row[2]; final Map context = contextRaw is Map ? contextRaw : jsonDecode(contextRaw.toString()) as Map; return { 'assignedUserId': assignedUserId, 'pipelineKey': row[0]! as String, 'step': row[1]! as String, 'context': context, 'updatedAt': (row[3]! as DateTime).toIso8601String(), }; } Future upsertPipelineState({ required String assignedUserId, required String pipelineKey, required String step, required Map context, }) async { await ensureUserExists(assignedUserId); final DateTime now = DateTime.now().toUtc(); await _connection.execute( Sql.named( ''' INSERT INTO user_pipeline_state ( assigned_user_id, pipeline_key, step, context, updated_at ) VALUES ( @uid, @pipeline_key, @step, @context::jsonb, @updated_at ) ON CONFLICT (assigned_user_id) DO UPDATE SET pipeline_key = EXCLUDED.pipeline_key, step = EXCLUDED.step, context = EXCLUDED.context, updated_at = EXCLUDED.updated_at ''', ), parameters: { 'uid': assignedUserId, 'pipeline_key': pipelineKey, 'step': step, 'context': jsonEncode(context), 'updated_at': now, }, ); } /// Moves an unanswered question to the end of the user's queue. Future?> deferQuestion({ required String questionId, required String assignedUserId, }) async { final DateTime now = DateTime.now().toUtc(); final ProspectiveGuessAssignmentsDb assignmentsDb = ProspectiveGuessAssignmentsDb(_connection); final bool pushed = await assignmentsDb.pushToBackOfQueue( questionId: questionId, firebaseUid: assignedUserId, now: now, ); if (!pushed) { return _deferQuestionWithoutAssignment( questionId: questionId, assignedUserId: assignedUserId, modifiedAt: now, ); } final Result result = await _connection.execute( Sql.named( ''' UPDATE questions q SET modified_at = @modified_at WHERE q.id = @id::uuid AND q.assigned_user_id = @uid AND q.user_response IS NULL RETURNING q.id, q.assigned_user_id, q.question_text, q.user_response, q.correct_answer, q.created_at, q.modified_at, q.source_tag, q.pipeline_key, q.pipeline_step, q.metadata ''', ), parameters: { 'id': questionId, 'uid': assignedUserId, 'modified_at': now, }, ); if (result.isEmpty) { return null; } return _rowFromResult(result.first); } /// Legacy defer for questions without a pending assignment row. Future?> _deferQuestionWithoutAssignment({ required String questionId, required String assignedUserId, required DateTime modifiedAt, }) async { final Result result = await _connection.execute( Sql.named( ''' UPDATE questions q SET created_at = sub.max_ts + INTERVAL '1 millisecond', modified_at = @modified_at FROM ( SELECT COALESCE(MAX(q.created_at), @modified_at) AS max_ts FROM questions q WHERE q.assigned_user_id = @uid AND q.user_response IS NULL AND q.id <> @id::uuid ) sub WHERE q.id = @id::uuid AND q.assigned_user_id = @uid AND q.user_response IS NULL RETURNING q.id, q.assigned_user_id, q.question_text, q.user_response, q.correct_answer, q.created_at, q.modified_at, q.source_tag, q.pipeline_key, q.pipeline_step, q.metadata ''', ), parameters: { 'id': questionId, 'uid': assignedUserId, 'modified_at': modifiedAt, }, ); if (result.isEmpty) { return null; } return _rowFromResult(result.first); } /// Count of unanswered questions assigned to [assignedUserId]. /// Unanswered [spelling_word_quiz] row for [assignedUserId], if any. Future hasUnansweredSpellingWordQuiz(String assignedUserId) async { final Result result = await _connection.execute( Sql.named( ''' SELECT 1 FROM questions WHERE assigned_user_id = @uid AND user_response IS NULL AND pipeline_step = 'spelling_word_quiz' AND ( source_tag LIKE 'spelling:%' OR pipeline_key = 'spelling' ) LIMIT 1 ''', ), parameters: {'uid': assignedUserId}, ); return result.isNotEmpty; } /// Counts unanswered rows whose [resolveSourceId] is in [allowedSourceIds]. Future countUnansweredForAllowedSources( String assignedUserId, Set allowedSourceIds, ) async { if (allowedSourceIds.isEmpty) { return 0; } final List> rows = await listUnansweredQuestions(assignedUserId); return filterByAllowedSources(rows, allowedSourceIds).length; } Future countUnansweredQuestions(String assignedUserId) async { final Result result = await _connection.execute( Sql.named( ''' SELECT COUNT(*)::int AS count FROM questions WHERE assigned_user_id = @uid AND user_response IS NULL ''', ), parameters: {'uid': assignedUserId}, ); return (result.first[0]! as num).toInt(); } /// Cumulative guess score for [firebaseUid] (Firebase auth UID), persisted in /// `user_trading_state.context.guess_score` and repaired from answer history. Future> getGuessScoreSummary(String firebaseUid) async { await ensureUserExists(firebaseUid); return GuessScoreStore.loadSummary(_connection, firebaseUid); } /// Resets guess score to a blank slate and clears all guess questions/links. Future> resetGuessScoreSummary( String firebaseUid, { DateTime? now, }) async { final DateTime tick = (now ?? DateTime.now()).toUtc(); final DateTime earliest = ProspectiveGuessSelection.earliestPlayableSlotStart( tick, MarketHistoryConfig.windowDays, ); await _connection.execute( Sql.named( ''' DELETE FROM market_history_prospective_answers WHERE assigned_user_id = @uid ''', ), parameters: {'uid': firebaseUid}, ); await ProspectiveGuessAssignmentsDb(_connection).deleteAllForUser( firebaseUid, ); await deleteAllProspectiveGuessQuestionsForUser(firebaseUid); await UserTradingStateDb(_connection).resetGuessScore( firebaseUid, slotStart: earliest, ); return getGuessScoreSummary(firebaseUid); } /// Slot-progressive prospective question for [firebaseUid], or null when caught up. Future?> pickProspectiveQuestionForUser( String firebaseUid, { DateTime? now, }) async { return ProspectiveGuessSelection(connection: _connection).pickForUser( firebaseUid, now: now, ); } Future> createQuestion({ required String assignedUserId, required String questionText, required num correctAnswer, String? sourceTag, String? pipelineKey, String? pipelineStep, Map? metadata, }) async { await ensureUserExists(assignedUserId); final String id = _uuid.v4(); final DateTime now = DateTime.now().toUtc(); await _connection.execute( Sql.named( ''' INSERT INTO questions ( id, assigned_user_id, question_text, user_response, correct_answer, created_at, modified_at, source_tag, pipeline_key, pipeline_step, metadata ) VALUES ( @id::uuid, @assigned_user_id, @question_text, NULL, @correct_answer, @created_at, @modified_at, @source_tag, @pipeline_key, @pipeline_step, @metadata::jsonb ) ''', ), parameters: { 'id': id, 'assigned_user_id': assignedUserId, 'question_text': questionText, 'correct_answer': correctAnswer, 'created_at': now, 'modified_at': now, 'source_tag': sourceTag, 'pipeline_key': pipelineKey, 'pipeline_step': pipelineStep, 'metadata': jsonEncode(metadata ?? {}), }, ); return _rowToJson( id: id, assignedUserId: assignedUserId, questionText: questionText, userResponse: null, correctAnswer: correctAnswer, createdAt: now, modifiedAt: now, sourceTag: sourceTag, pipelineKey: pipelineKey, pipelineStep: pipelineStep, metadata: metadata ?? {}, ); } /// Stable client source id derived from pipeline metadata. static String resolveSourceId(Map question) { final String? sourceTag = question['sourceTag'] as String?; if (sourceTag != null && sourceTag.startsWith('spelling:')) { return 'spelling'; } if (sourceTag != null && sourceTag.startsWith('market_history:')) { return 'market_history'; } final String? pipelineKey = question['pipelineKey'] as String?; if (pipelineKey == 'spelling') { return 'spelling'; } return 'market_history'; } /// UI presentation key for [resolveSourceId]. static String resolvePresentation(Map question) { final String? step = question['pipelineStep'] as String?; final String? sourceTag = question['sourceTag'] as String?; if (step == 'spelling_follow_up' || sourceTag == 'spelling:follow_up') { return 'market_glyph_slider'; } if (resolveSourceId(question) == 'spelling') { return 'spelling_word'; } return 'market_glyph_slider'; } /// Payload sent to the Flutter client over SignalR (excludes correct answer). Map toClientPayload( Map question, { required int unansweredCount, }) { final Map metadata = Map.from( question['metadata'] as Map? ?? {}, ); final String? spokenWord = metadata['displayed_word'] as String?; final bool isSpelling = resolveSourceId(question) == 'spelling'; final bool needsPrepare = isSpelling && (spokenWord == null || spokenWord.isEmpty) && metadata['spelling_pending'] == true; final bool isPreparedSpelling = isSpelling && spokenWord != null && spokenWord.isNotEmpty && !needsPrepare; return { 'id': question['id'], 'assignedUserId': question['assignedUserId'], 'text': question['text'], 'sentAt': question['createdAt'], 'unansweredCount': unansweredCount, 'sourceId': resolveSourceId(question), 'presentation': resolvePresentation(question), if (needsPrepare) 'needsPrepare': true, if (isPreparedSpelling) ...{ 'spokenWord': spokenWord, 'audioTitle': question['id'], if (metadata['canonical_word'] is String) 'canonicalWord': metadata['canonical_word'], }, }; } /// Fills in a reserved spelling quiz after word selection at tile load. Future?> updateSpellingQuizQuestion({ required String questionId, required String assignedUserId, required String questionText, required num correctAnswer, required Map metadata, }) async { final DateTime now = DateTime.now().toUtc(); final Result result = await _connection.execute( Sql.named( ''' UPDATE questions SET question_text = @text, correct_answer = @answer, metadata = @metadata::jsonb, modified_at = @now WHERE id = @id::uuid AND assigned_user_id = @uid AND user_response IS NULL AND ( pipeline_step = 'spelling_word_quiz' OR source_tag = 'spelling:word_quiz' ) RETURNING id, assigned_user_id, question_text, user_response, correct_answer, created_at, modified_at, source_tag, pipeline_key, pipeline_step, metadata, user_response_text ''', ), parameters: { 'id': questionId, 'uid': assignedUserId, 'text': questionText, 'answer': correctAnswer, 'metadata': jsonEncode(metadata), 'now': now, }, ); if (result.isEmpty) { return null; } return _rowFromResult(result.first); } Map _rowFromResult(ResultRow row) { final Object idValue = row[0]!; final String id = idValue is String ? idValue : idValue.toString(); final DateTime createdAt = row[5]! as DateTime; final DateTime modifiedAt = row[6]! as DateTime; return _rowToJson( id: id, assignedUserId: row[1]! as String, questionText: row[2]! as String, userResponse: _readOptionalNumeric(row[3]), correctAnswer: _readNumeric(row[4]), createdAt: createdAt, modifiedAt: modifiedAt, sourceTag: row.length > 7 ? row[7] as String? : null, pipelineKey: row.length > 8 ? row[8] as String? : null, pipelineStep: row.length > 9 ? row[9] as String? : null, metadata: _readJsonMap(row.length > 10 ? row[10] : null), userResponseText: row.length > 11 ? row[11] as String? : null, ); } 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; } /// Postgres NUMERIC columns may decode as [String] or [num]. static num _readNumeric(Object? value) { if (value == null) { return 0; } if (value is num) { return value; } if (value is String) { return num.parse(value); } return num.parse(value.toString()); } static num? _readOptionalNumeric(Object? value) { if (value == null) { return null; } return _readNumeric(value); } Future _gradeProspectiveAnswerIfNeeded( Map answeredQuestion, ) async { final Map metadata = Map.from( answeredQuestion['metadata'] as Map? ?? {}, ); final bool isProspective = metadata['prospective_question_id'] != null || answeredQuestion['sourceTag'] == 'market_history:prospective'; if (!isProspective) { return; } final num? userResponse = _readOptionalNumeric(answeredQuestion['userResponse']); if (userResponse == null) { return; } final ProspectiveAnswerGrade grade = gradeProspectiveAnswer( userResponse: userResponse, correctAnswer: _readNumeric(answeredQuestion['correctAnswer']), ); final String symbol = metadata['symbol'] as String? ?? ''; final String? modifiedAtWire = answeredQuestion['modifiedAt'] as String?; final DateTime at = modifiedAtWire == null ? DateTime.now().toUtc() : DateTime.parse(modifiedAtWire).toUtc(); await recordProspectiveGuessScore( tradingStateDb: UserTradingStateDb(_connection), firebaseUid: answeredQuestion['assignedUserId']! as String, grade: grade, symbol: symbol, at: at, ); } Future _recordProspectiveAnswer(Map answeredQuestion) async { final Map metadata = Map.from( answeredQuestion['metadata'] as Map? ?? {}, ); final String? prospectiveQuestionId = metadata['prospective_question_id'] as String?; if (prospectiveQuestionId == null || prospectiveQuestionId.isEmpty) { return; } final num? userSliderValue = _readOptionalNumeric(answeredQuestion['userResponse']); if (userSliderValue == null) { return; } final String questionId = answeredQuestion['id']! as String; final String assignedUserId = answeredQuestion['assignedUserId']! as String; final String? modifiedAtWire = answeredQuestion['modifiedAt'] as String?; final DateTime answeredAt = modifiedAtWire == null ? DateTime.now().toUtc() : DateTime.parse(modifiedAtWire).toUtc(); await _connection.execute( Sql.named( ''' INSERT INTO market_history_prospective_answers ( question_id, assigned_user_id, prospective_question_id, symbol, older_slot_start, newer_slot_start, expected_percent_increase_price, user_slider_value, answered_at ) SELECT @question_id::uuid, @assigned_user_id, p.id, p.symbol, p.older_slot_start, p.newer_slot_start, p.price_delta_pct, @user_slider_value, @answered_at FROM market_history_prospective_questions p WHERE p.id = @prospective_question_id::uuid ON CONFLICT (question_id) DO UPDATE SET prospective_question_id = EXCLUDED.prospective_question_id, symbol = EXCLUDED.symbol, older_slot_start = EXCLUDED.older_slot_start, newer_slot_start = EXCLUDED.newer_slot_start, expected_percent_increase_price = EXCLUDED.expected_percent_increase_price, user_slider_value = EXCLUDED.user_slider_value, answered_at = EXCLUDED.answered_at ''', ), parameters: { 'question_id': questionId, 'assigned_user_id': assignedUserId, 'prospective_question_id': prospectiveQuestionId, 'user_slider_value': userSliderValue, 'answered_at': answeredAt, }, ); } Map _rowToJson({ required String id, required String assignedUserId, required String questionText, required Object? userResponse, required num correctAnswer, required DateTime createdAt, required DateTime modifiedAt, String? sourceTag, String? pipelineKey, String? pipelineStep, Map? metadata, String? userResponseText, }) { return { 'id': id, 'assignedUserId': assignedUserId, 'text': questionText, 'userResponse': userResponse, if (userResponseText != null) 'userResponseText': userResponseText, 'correctAnswer': correctAnswer, 'createdAt': createdAt.toIso8601String(), 'modifiedAt': modifiedAt.toIso8601String(), if (sourceTag != null) 'sourceTag': sourceTag, if (pipelineKey != null) 'pipelineKey': pipelineKey, if (pipelineStep != null) 'pipelineStep': pipelineStep, if (metadata != null && metadata.isNotEmpty) 'metadata': metadata, }; } }