cyberhybridhub/server/lib/question_service.dart

208 lines
6.5 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'questions_db.dart';
import 'signalr/questions_hub_connections.dart';
import 'trading/prospective_guess_assignments_db.dart';
/// Creates questions in Postgres and delivers them over SignalR.
class QuestionService {
QuestionService({
required QuestionsDb questionsDb,
required QuestionsHubConnections hubConnections,
}) : _questionsDb = questionsDb,
_hubConnections = hubConnections;
final QuestionsDb _questionsDb;
final QuestionsHubConnections _hubConnections;
ProspectiveGuessAssignmentsDb get _assignmentsDb =>
ProspectiveGuessAssignmentsDb(_questionsDb.connection);
/// Called at login: ensures one unanswered prospective question when possible.
Future<Map<String, dynamic>?> bootstrapOnLogin(
String firebaseUid,
) async {
await _questionsDb.ensureUserExists(firebaseUid);
return ensureProspectiveQuestionQueued(firebaseUid);
}
/// Creates the next slot-based prospective question when the queue is empty.
Future<Map<String, dynamic>?> ensureProspectiveQuestionQueued(
String firebaseUid, {
DateTime? now,
}) async {
await _questionsDb.ensureUserExists(firebaseUid);
final String? pendingQuestionId =
await _assignmentsDb.findPendingQuestionId(firebaseUid);
if (pendingQuestionId != null) {
final Map<String, dynamic>? question = await _questionsDb.findQuestionById(
questionId: pendingQuestionId,
assignedUserId: firebaseUid,
);
if (question != null && question['userResponse'] == null) {
final int unansweredCount =
await _questionsDb.countUnansweredQuestions(firebaseUid);
return _questionsDb.toClientPayload(
question,
unansweredCount: unansweredCount > 0 ? unansweredCount : 1,
);
}
}
final int queued =
await _questionsDb.countUnansweredQuestions(firebaseUid);
if (queued > 0) {
final Map<String, dynamic>? existing =
await _questionsDb.findUnansweredQuestion(firebaseUid);
if (existing == null) {
return null;
}
return _questionsDb.toClientPayload(
existing,
unansweredCount: queued,
);
}
final Map<String, dynamic>? prospective =
await _createProspectiveQuestionWithAssignment(
firebaseUid,
now: now,
);
if (prospective == null) {
return null;
}
return prospective;
}
/// Picks, creates, and records one prospective question when none is queued.
///
/// Retries when a concurrent request claims the same user/slot/symbol first.
Future<Map<String, dynamic>?> _createProspectiveQuestionWithAssignment(
String firebaseUid, {
DateTime? now,
}) async {
for (var attempt = 0; attempt < 8; attempt++) {
final Map<String, dynamic>? picked =
await _questionsDb.pickProspectiveQuestionForUser(
firebaseUid,
now: now,
);
if (picked == null) {
return null;
}
final DateTime olderSlotStart = DateTime.parse(
picked['olderSlotStart']! as String,
);
final DateTime newerSlotStart = DateTime.parse(
picked['newerSlotStart']! as String,
);
final String symbol = picked['symbol']! as String;
if (await _assignmentsDb.hasAssignmentForSymbolSlotPair(
firebaseUid: firebaseUid,
olderSlotStart: olderSlotStart,
newerSlotStart: newerSlotStart,
symbol: symbol,
)) {
continue;
}
final Map<String, dynamic> question = await _questionsDb.createQuestion(
assignedUserId: firebaseUid,
questionText: picked['questionText']! as String,
correctAnswer: picked['correctAnswer']! as num,
sourceTag: 'market_history:prospective',
pipelineKey: 'trading',
pipelineStep: 'guess_weekly_move:await_answer',
metadata: <String, dynamic>{
'prospective_question_id': picked['id'],
'symbol': symbol,
'older_slot_start': picked['olderSlotStart'],
'newer_slot_start': picked['newerSlotStart'],
'price_delta_pct': picked['priceDeltaPct'],
},
);
final bool assigned = await _assignmentsDb.insertPendingIfAbsent(
firebaseUid: firebaseUid,
olderSlotStart: olderSlotStart,
newerSlotStart: newerSlotStart,
symbol: symbol,
prospectiveQuestionId: picked['id']! as String,
questionId: question['id']! as String,
);
if (!assigned) {
await _questionsDb.deleteUnansweredQuestion(
questionId: question['id']! as String,
assignedUserId: firebaseUid,
);
continue;
}
return _questionsDb.toClientPayload(
question,
unansweredCount: 1,
);
}
return null;
}
/// Inserts a question and pushes it to connected SignalR clients.
Future<Map<String, dynamic>> createAndDeliverQuestion({
required String assignedUserId,
required String questionText,
required num correctAnswer,
String? sourceTag,
String? pipelineKey,
String? pipelineStep,
Map<String, dynamic>? metadata,
}) async {
final Map<String, dynamic> question = await _questionsDb.createQuestion(
assignedUserId: assignedUserId,
questionText: questionText,
correctAnswer: correctAnswer,
sourceTag: sourceTag,
pipelineKey: pipelineKey,
pipelineStep: pipelineStep,
metadata: metadata,
);
final int unansweredCount =
await _questionsDb.countUnansweredQuestions(assignedUserId);
final Map<String, dynamic> payload = _questionsDb.toClientPayload(
question,
unansweredCount: unansweredCount,
);
await _hubConnections.pushQuestion(assignedUserId, payload);
return payload;
}
/// On SignalR connect: deliver an existing unanswered question only (no create).
Future<void> deliverPendingQuestionOnConnect(
QuestionsHubConnection connection,
) async {
try {
final String uid = connection.firebaseUid;
final Map<String, dynamic>? question =
await _questionsDb.findUnansweredQuestion(uid);
if (question == null) {
return;
}
final int unansweredCount =
await _questionsDb.countUnansweredQuestions(uid);
final Map<String, dynamic> payload = _questionsDb.toClientPayload(
question,
unansweredCount: unansweredCount,
);
await _hubConnections.pushQuestionToConnection(connection, payload);
} catch (e, st) {
stderr.writeln(
'Failed to deliver pending question for ${connection.firebaseUid}: $e\n$st',
);
}
}
}