test
Some checks failed
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
Web App CI / test (push) Has been cancelled
Web App CI / build (push) Has been cancelled

This commit is contained in:
Dmitry 2026-01-24 18:18:58 +03:00
parent ce564924f2
commit 2a4b3ecc4e
15 changed files with 141 additions and 27 deletions

View file

@ -587,6 +587,7 @@ class AdminTestsApiV2 {
'version': test.version ?? '1.0',
'time': test.time,
'timeSubtitle': test.timeSubtitle,
'expiresAt': test.expiresAt?.dateTime.toIso8601String(),
'questions': questionsWithUrls,
'packs': packs,
});
@ -628,7 +629,23 @@ class AdminTestsApiV2 {
final version = bodyJson['version'] as String?;
final time = bodyJson['time'] as String?;
final timeSubtitle = bodyJson['timeSubtitle'] as String?;
final expiresAtStr = bodyJson['expiresAt'] as String?;
final questions = bodyJson['questions'] as List<dynamic>?;
// Parse expiresAt if provided
PgDateTime? expiresAt;
if (expiresAtStr != null && expiresAtStr.isNotEmpty) {
try {
expiresAt = PgDateTime(DateTime.parse(expiresAtStr));
} catch (e) {
return _json({
'error': 'Invalid expiresAt format',
'message': 'expiresAt must be a valid ISO 8601 datetime string',
'field': 'expiresAt',
'details': 'Please provide expiresAt in ISO 8601 format (e.g., "2025-01-24T12:00:00Z").',
}, statusCode: 400);
}
}
if (name == null || name.isEmpty) {
return _json({
@ -676,6 +693,9 @@ class AdminTestsApiV2 {
timeSubtitle: timeSubtitle != null
? drift.Value(timeSubtitle)
: const drift.Value.absent(),
expiresAt: expiresAt != null
? drift.Value(expiresAt)
: const drift.Value.absent(),
updatedAt: PgDateTime(DateTime.now()),
);
await _db.testDao.updateTest(updatedTest);
@ -779,6 +799,7 @@ class AdminTestsApiV2 {
'version': updatedTest.version,
'time': updatedTest.time,
'timeSubtitle': updatedTest.timeSubtitle,
'expiresAt': updatedTest.expiresAt?.dateTime.toIso8601String(),
}
: {
'id': testId,
@ -788,6 +809,7 @@ class AdminTestsApiV2 {
'version': version,
'time': time,
'timeSubtitle': timeSubtitle,
'expiresAt': expiresAtStr,
},
});
} else {
@ -812,6 +834,9 @@ class AdminTestsApiV2 {
timeSubtitle: timeSubtitle != null
? drift.Value(timeSubtitle)
: const drift.Value.absent(),
expiresAt: expiresAt != null
? drift.Value(expiresAt)
: const drift.Value.absent(),
),
);
@ -908,6 +933,7 @@ class AdminTestsApiV2 {
'version': createdTest.version,
'time': createdTest.time,
'timeSubtitle': createdTest.timeSubtitle,
'expiresAt': createdTest.expiresAt?.dateTime.toIso8601String(),
}
: {
'id': newTest,

View file

@ -52,19 +52,36 @@ class TestGeneratorTask with cron_task.Task {
}
}
print('Generated tests for $ok packs');
print('Purging old generated tests (hard delete TTL)...');
// Soft delete tests that have expired (expiresAt <= now)
print('Soft deleting expired tests...');
try {
final expiredDeleted = await _db.testDao.softDeleteExpiredTests();
print('soft deleted $expiredDeleted expired tests');
} catch (e) {
print('failed to soft delete expired tests: $e');
}
// Hard delete orphan generated tests (not linked to any pack)
print('Purging orphan generated tests...');
try {
final orphanDeleted = await _db.testDao.hardDeleteOrphanGeneratedTests(
olderThan: const Duration(hours: 1),
);
print('purged $orphanDeleted orphan generated tests');
final deleted = await _db.testDao.hardDeleteOldSoftDeletedGeneratedTests(
olderThan: const Duration(days: 7),
);
print('purged $deleted old generated tests');
} catch (e) {
print('failed to purge old generated tests: $e');
print('failed to purge orphan generated tests: $e');
}
// Hard delete old soft-deleted generated tests (1 hour after soft delete)
print('Purging old soft-deleted generated tests (hard delete TTL)...');
try {
final deleted = await _db.testDao.hardDeleteOldSoftDeletedGeneratedTests(
olderThan: const Duration(hours: 1),
);
print('purged $deleted old soft-deleted generated tests');
} catch (e) {
print('failed to purge old soft-deleted generated tests: $e');
}
print('Deleting old test stats');
// Delete test statistics where test doesn't exist (test was deleted)

View file

@ -13,11 +13,12 @@ class TestDao extends DatabaseAccessor<AppDatabase> with _$TestDaoMixin {
// ==================== Tests ====================
/// Получить тест по ID (только активные)
/// Получить тест по ID (включая soft-deleted для доступа по прямой ссылке)
///
/// Примечание: soft-deleted тесты не показываются в списке пака (getTestsByPackId),
/// но доступны по прямой ссылке для завершения начатых тестов.
Future<Test?> getTestById(String id) {
return (select(tests)
..where((t) => t.id.equals(id) & t.isDeleted.equals(false)))
.getSingleOrNull();
return (select(tests)..where((t) => t.id.equals(id))).getSingleOrNull();
}
/// Получить все тесты (только активные)
@ -60,11 +61,34 @@ class TestDao extends DatabaseAccessor<AppDatabase> with _$TestDaoMixin {
);
}
/// Soft delete tests that have expired (expiresAt <= now)
///
/// When expiresAt is reached, test becomes soft-deleted (available only by direct link)
Future<int> softDeleteExpiredTests() async {
final now = DateTime.now();
final expiredTests = await (select(tests)..where(
(t) =>
t.expiresAt.isSmallerOrEqualValue(PgDateTime(now)) &
t.isDeleted.equals(false),
))
.get();
var deleted = 0;
for (final test in expiredTests) {
await softDeleteTest(test.id);
deleted++;
}
return deleted;
}
/// Hard delete old generated tests that were soft-deleted.
///
/// This is important because we use soft delete for regular operations,
/// but generated tests are ephemeral and otherwise will accumulate in DB
/// (along with their questions/stats). Hard delete triggers FK cascades.
///
/// Hard deletes tests that were soft-deleted more than [olderThan] ago.
Future<int> hardDeleteOldSoftDeletedGeneratedTests({
required Duration olderThan,
}) async {

View file

@ -15,6 +15,11 @@ class Tests extends Table {
TextColumn get version => text().nullable()();
TextColumn get time => text().nullable()();
TextColumn get timeSubtitle => text().nullable()();
// Время, когда тест перестанет быть доступен (soft limit)
// Когда expiresAt наступает, тест soft-delete (доступен только по прямой ссылке)
Column<PgDateTime> get expiresAt =>
customType(PgTypes.timestampWithTimezone).nullable()();
// Audit
Column<PgDateTime> get createdAt =>

View file

@ -70,15 +70,32 @@ class MinioService {
final id = objectId ?? _uuid.v4();
try {
// Определяем Cache-Control в зависимости от типа файла
// Для изображений: 7 дней, immutable (не изменяются)
// Для аудио: 7 дней
final cacheControl = contentType.startsWith('image/')
? 'public, max-age=604800, immutable' // 7 дней для изображений
: contentType.startsWith('audio/')
? 'public, max-age=604800' // 7 дней для аудио
: 'public, max-age=86400'; // 1 день для других
await _client.putObject(
bucket,
id,
Stream.value(bytes),
size: bytes.length,
metadata: {'Content-Type': contentType},
metadata: {
'Content-Type': contentType,
// Попытка установить Cache-Control через metadata
// В некоторых SDK это может добавить префикс X-Amz-Meta-
// Но MinIO может обработать это как стандартный заголовок
'Cache-Control': cacheControl,
},
);
print('✅ Uploaded file to MinIO: $bucket/$id (${bytes.length} bytes)');
print(
'✅ Uploaded file to MinIO: $bucket/$id (${bytes.length} bytes, Cache-Control: $cacheControl)',
);
return id;
} catch (e) {
print('❌ Error uploading file to MinIO: $e');

View file

@ -869,6 +869,12 @@ class TestManager {
Future<String> addTest(TestDto testDto, {String? packId}) async {
String? createdTestId;
await _db.transaction(() async {
// Set expiresAt for generated tests (1 hour from now by default)
// For manually created tests, expiresAt can be set via admin panel
final expiresAt = testDto.version == 'generated'
? PgDateTime(DateTime.now().add(const Duration(hours: 1)))
: null;
// Create test
final testCompanion = TestsCompanion.insert(
name: testDto.name,
@ -877,6 +883,7 @@ class TestManager {
version: drift.Value(testDto.version),
time: drift.Value(testDto.time),
timeSubtitle: drift.Value(testDto.timeSubtitle),
expiresAt: drift.Value(expiresAt),
);
createdTestId = await _db.testDao.createTest(testCompanion);

View file

@ -0,0 +1,17 @@
-- Migration 004: Add expiresAt field to tests table
-- Date: 2025-01-24
-- Description: Add expiresAt field to manage test availability (soft limit)
-- Add expiresAt column to tests table
ALTER TABLE tests
ADD COLUMN IF NOT EXISTS expires_at TIMESTAMP WITH TIME ZONE;
-- Add index for efficient queries on expired tests
CREATE INDEX IF NOT EXISTS idx_tests_expires_at
ON tests(expires_at)
WHERE expires_at IS NOT NULL AND is_deleted = false;
-- Add index for soft-deleted tests that need hard delete
CREATE INDEX IF NOT EXISTS idx_tests_deleted_at_generated
ON tests(deleted_at, version)
WHERE is_deleted = true AND version = 'generated' AND deleted_at IS NOT NULL;

View file

@ -3,6 +3,7 @@ import 'dart:developer';
import 'package:audioplayers/audioplayers.dart';
import 'package:confetti/confetti.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:go_router/go_router.dart';
@ -156,10 +157,10 @@ class _GamePageState extends State<GamePage> {
context,
listen: false,
);
final userScope = appScope?.userScopeHolder.scope;
final sessionElapsed =
userScope?.testsModule.gameSessionManager.sessionElapsed ??
Duration.zero;
final userScope = appScope?.userScopeHolder.scope;
final sessionElapsed =
userScope?.testsModule.gameSessionManager.sessionElapsed ??
Duration.zero;
return SafeArea(
child: state.when(

View file

@ -282,7 +282,7 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
}
log('Launching test: ${test.name}', name: 'PackDetailsPage');
context.go('/game/${test.id}', extra: '/pack/${widget.packId}');
context.push('/game/${test.id}', extra: '/pack/${widget.packId}');
}
@override
@ -412,7 +412,7 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
try {
await Future<void>.delayed(const Duration(milliseconds: 100));
if (mounted) {
context.go('/purchase/${widget.packId}');
await context.push('/purchase/${widget.packId}');
}
} finally {
if (mounted) {

View file

@ -142,7 +142,7 @@ class _TasksPageState extends State<TasksPage> with TickerProviderStateMixin {
mainAxisSize: MainAxisSize.min,
children: [
FloatingActionButton(
onPressed: () => context.go('/tasks/create'),
onPressed: () => context.push('/tasks/create'),
tooltip: 'Создать задание',
heroTag: 'create_task',
child: const Icon(Icons.add),

View file

@ -219,7 +219,7 @@ class _TestPageState extends State<TestPage> {
}
void _playGame(BuildContext context) {
context.go('/game/${widget.testId}');
context.push('/game/${widget.testId}');
}
void _handleBack() {

View file

@ -21,7 +21,7 @@ class GameCard extends StatelessWidget {
onTap ??
() {
// Navigate to game page
context.go('/game/${game.id}');
context.push('/game/${game.id}');
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,

View file

@ -37,9 +37,9 @@ class PackCard extends StatelessWidget {
GestureDetector(
onTap: () {
if (pack.isAvailable) {
context.go('/pack/${pack.id}');
context.push('/pack/${pack.id}');
} else {
context.go('/purchase/${pack.id}');
context.push('/purchase/${pack.id}');
}
},
child: Container(

View file

@ -33,9 +33,9 @@ class PackCardVertical extends StatelessWidget {
child: GestureDetector(
onTap: () {
if (pack.isAvailable) {
context.go('/pack/${pack.id}');
context.push('/pack/${pack.id}');
} else {
context.go('/purchase/${pack.id}');
context.push('/purchase/${pack.id}');
}
},
child: Container(

View file

@ -35,7 +35,7 @@ class TaskCard extends StatelessWidget {
onTap ??
() {
// Default navigation to task details
context.go('/tasks/${task.id}');
context.push('/tasks/${task.id}');
},
child: Container(
margin: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 4.0),