import 'dart:convert'; import 'dart:math'; import 'package:postgres/postgres.dart'; import 'package:uuid/uuid.dart'; /// Postgres access for the questions table. class QuestionsDb { QuestionsDb(this._connection); final 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}, ); } /// Latest unanswered question for [assignedUserId], or null if none. Future?> findUnansweredQuestion( 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 FROM questions WHERE assigned_user_id = @uid AND user_response IS NULL ORDER BY created_at ASC 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 id, assigned_user_id, question_text, user_response, correct_answer, created_at, modified_at, source_tag, pipeline_key, pipeline_step FROM questions WHERE assigned_user_id = @uid AND user_response IS NULL ORDER BY created_at ASC ''', ), parameters: {'uid': assignedUserId}, ); return result.map(_rowFromResult).toList(); } /// Records [userResponse] for an unanswered question owned by [assignedUserId]. Future?> submitAnswer({ required String questionId, required String assignedUserId, required num userResponse, }) async { final DateTime now = DateTime.now().toUtc(); final Result result = await _connection.execute( Sql.named( ''' UPDATE questions SET user_response = @user_response, 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 ''', ), parameters: { 'id': questionId, 'uid': assignedUserId, 'user_response': userResponse, 'modified_at': now, }, ); 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 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(created_at), @modified_at) AS max_ts FROM questions WHERE assigned_user_id = @uid AND user_response IS NULL ) 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 ''', ), parameters: { 'id': questionId, 'uid': assignedUserId, 'modified_at': now, }, ); if (result.isEmpty) { return null; } return _rowFromResult(result.first); } /// Count of unanswered questions assigned to [assignedUserId]. 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(); } /// Returns an existing unanswered question or creates the starter question. Future> getOrCreateStarterQuestion( String assignedUserId, ) async { final Map? existing = await findUnansweredQuestion(assignedUserId); if (existing != null) { return existing; } return createStarterQuestion(assignedUserId); } /// Creates the starter test question with a random correct answer in [-10, 10]. Future> createStarterQuestion(String assignedUserId) async { await ensureUserExists(assignedUserId); final int correctAnswer = Random().nextInt(21) - 10; final String id = _uuid.v4(); final DateTime now = DateTime.now().toUtc(); const String questionText = 'Starter question: enter a whole number between -10 and 10.'; await _connection.execute( Sql.named( ''' INSERT INTO questions ( id, assigned_user_id, question_text, user_response, correct_answer, created_at, modified_at ) VALUES ( @id::uuid, @assigned_user_id, @question_text, NULL, @correct_answer, @created_at, @modified_at ) ''', ), parameters: { 'id': id, 'assigned_user_id': assignedUserId, 'question_text': questionText, 'correct_answer': correctAnswer, 'created_at': now, 'modified_at': now, }, ); return { 'id': id, 'assignedUserId': assignedUserId, 'text': questionText, 'userResponse': null, 'correctAnswer': correctAnswer, 'createdAt': now.toIso8601String(), 'modifiedAt': now.toIso8601String(), }; } Future> createQuestion({ required String assignedUserId, required String questionText, required num correctAnswer, String? sourceTag, String? pipelineKey, String? pipelineStep, }) 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 ) VALUES ( @id::uuid, @assigned_user_id, @question_text, NULL, @correct_answer, @created_at, @modified_at, @source_tag, @pipeline_key, @pipeline_step ) ''', ), 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, }, ); return _rowToJson( id: id, assignedUserId: assignedUserId, questionText: questionText, userResponse: null, correctAnswer: correctAnswer, createdAt: now, modifiedAt: now, sourceTag: sourceTag, pipelineKey: pipelineKey, pipelineStep: pipelineStep, ); } /// Payload sent to the Flutter client over SignalR (excludes correct answer). Map toClientPayload( Map question, { required int unansweredCount, }) { return { 'id': question['id'], 'assignedUserId': question['assignedUserId'], 'text': question['text'], 'sentAt': question['createdAt'], 'unansweredCount': unansweredCount, }; } 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, ); } /// 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); } 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, }) { return { 'id': id, 'assignedUserId': assignedUserId, 'text': questionText, 'userResponse': userResponse, 'correctAnswer': correctAnswer, 'createdAt': createdAt.toIso8601String(), 'modifiedAt': modifiedAt.toIso8601String(), if (sourceTag != null) 'sourceTag': sourceTag, if (pipelineKey != null) 'pipelineKey': pipelineKey, if (pipelineStep != null) 'pipelineStep': pipelineStep, }; } }