188 lines
6.2 KiB
Dart
188 lines
6.2 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'questions_db.dart';
|
|
import 'signalr/questions_hub_connections.dart';
|
|
import 'trading/prospective_guess_selection.dart';
|
|
import 'trading/prospective_guess_assignments_db.dart';
|
|
import 'trading/market_history_question_audit.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);
|
|
}
|
|
|
|
/// Ensures the active slot's top-half batch exists, then returns the FIFO head.
|
|
Future<Map<String, dynamic>?> ensureProspectiveQuestionQueued(
|
|
String firebaseUid, {
|
|
DateTime? now,
|
|
}) async {
|
|
await _questionsDb.ensureUserExists(firebaseUid);
|
|
await _ensureCurrentSlotBatch(firebaseUid, now: now);
|
|
|
|
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,
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// Creates every top-half question for the user's active slot pair.
|
|
Future<void> _ensureCurrentSlotBatch(
|
|
String firebaseUid, {
|
|
DateTime? now,
|
|
}) async {
|
|
final ProspectiveGuessSelection selection = ProspectiveGuessSelection(
|
|
connection: _questionsDb.connection,
|
|
);
|
|
|
|
for (var attempt = 0; attempt < 8; attempt++) {
|
|
final ProspectiveSlotBatch? batch =
|
|
await selection.resolveUnassignedBatch(firebaseUid, now: now);
|
|
if (batch == null || batch.unassignedAssets.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
final DateTime baseOrder = DateTime.now().toUtc();
|
|
var createdAny = false;
|
|
|
|
for (var index = 0; index < batch.unassignedAssets.length; index++) {
|
|
final QuestionAuditAsset asset = batch.unassignedAssets[index];
|
|
if (await _assignmentsDb.hasAssignmentForSymbolSlotPair(
|
|
firebaseUid: firebaseUid,
|
|
olderSlotStart: batch.olderSlotStart,
|
|
newerSlotStart: batch.newerSlotStart,
|
|
symbol: asset.symbol,
|
|
)) {
|
|
continue;
|
|
}
|
|
|
|
final Map<String, dynamic> picked = await selection.buildPickForAsset(
|
|
asset: asset,
|
|
olderSlotStart: batch.olderSlotStart,
|
|
newerSlotStart: batch.newerSlotStart,
|
|
);
|
|
|
|
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': asset.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: batch.olderSlotStart,
|
|
newerSlotStart: batch.newerSlotStart,
|
|
symbol: asset.symbol,
|
|
prospectiveQuestionId: picked['id']! as String,
|
|
questionId: question['id']! as String,
|
|
viewOrderAt: baseOrder.add(Duration(milliseconds: index)),
|
|
);
|
|
if (!assigned) {
|
|
await _questionsDb.deleteUnansweredQuestion(
|
|
questionId: question['id']! as String,
|
|
assignedUserId: firebaseUid,
|
|
);
|
|
continue;
|
|
}
|
|
createdAny = true;
|
|
}
|
|
|
|
if (createdAny) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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',
|
|
);
|
|
}
|
|
}
|
|
}
|