From 2a4b3ecc4e0dd974676066a8fc0d370c54746307 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sat, 24 Jan 2026 18:18:58 +0300 Subject: [PATCH] test --- .../lib/api/v2/admin_tests_api_v2.dart | 26 +++++++++++++++ .../lib/cron/test_generator.dart | 31 ++++++++++++++---- .../lib/database/daos/test_dao.dart | 32 ++++++++++++++++--- .../lib/database/tables/tests.dart | 5 +++ .../lib/storage/minio_service.dart | 21 ++++++++++-- .../lib/tests/test_manager.dart | 7 ++++ .../migrations/004_add_test_expires_at.sql | 17 ++++++++++ .../presentation/pages/game/game_page.dart | 9 +++--- .../pages/pack_details/pack_details_page.dart | 4 +-- .../presentation/pages/tasks/tasks_page.dart | 2 +- .../presentation/pages/test/test_page.dart | 2 +- .../lib/presentation/widgets/game_card.dart | 2 +- .../lib/presentation/widgets/pack_card.dart | 4 +-- .../widgets/pack_card_vertical.dart | 4 +-- .../lib/presentation/widgets/task_card.dart | 2 +- 15 files changed, 141 insertions(+), 27 deletions(-) create mode 100644 mnemo_cards_backend/migrations/004_add_test_expires_at.sql diff --git a/mnemo_cards_backend/lib/api/v2/admin_tests_api_v2.dart b/mnemo_cards_backend/lib/api/v2/admin_tests_api_v2.dart index 55da46d..892d516 100644 --- a/mnemo_cards_backend/lib/api/v2/admin_tests_api_v2.dart +++ b/mnemo_cards_backend/lib/api/v2/admin_tests_api_v2.dart @@ -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?; + + // 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, diff --git a/mnemo_cards_backend/lib/cron/test_generator.dart b/mnemo_cards_backend/lib/cron/test_generator.dart index d90fc1d..b74befc 100644 --- a/mnemo_cards_backend/lib/cron/test_generator.dart +++ b/mnemo_cards_backend/lib/cron/test_generator.dart @@ -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) diff --git a/mnemo_cards_backend/lib/database/daos/test_dao.dart b/mnemo_cards_backend/lib/database/daos/test_dao.dart index 3b69b94..c88ce9f 100644 --- a/mnemo_cards_backend/lib/database/daos/test_dao.dart +++ b/mnemo_cards_backend/lib/database/daos/test_dao.dart @@ -13,11 +13,12 @@ class TestDao extends DatabaseAccessor with _$TestDaoMixin { // ==================== Tests ==================== - /// Получить тест по ID (только активные) + /// Получить тест по ID (включая soft-deleted для доступа по прямой ссылке) + /// + /// Примечание: soft-deleted тесты не показываются в списке пака (getTestsByPackId), + /// но доступны по прямой ссылке для завершения начатых тестов. Future 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 with _$TestDaoMixin { ); } + /// Soft delete tests that have expired (expiresAt <= now) + /// + /// When expiresAt is reached, test becomes soft-deleted (available only by direct link) + Future 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 hardDeleteOldSoftDeletedGeneratedTests({ required Duration olderThan, }) async { diff --git a/mnemo_cards_backend/lib/database/tables/tests.dart b/mnemo_cards_backend/lib/database/tables/tests.dart index 62d6986..e22a4a5 100644 --- a/mnemo_cards_backend/lib/database/tables/tests.dart +++ b/mnemo_cards_backend/lib/database/tables/tests.dart @@ -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 get expiresAt => + customType(PgTypes.timestampWithTimezone).nullable()(); // Audit Column get createdAt => diff --git a/mnemo_cards_backend/lib/storage/minio_service.dart b/mnemo_cards_backend/lib/storage/minio_service.dart index aa98a7e..70e03e0 100644 --- a/mnemo_cards_backend/lib/storage/minio_service.dart +++ b/mnemo_cards_backend/lib/storage/minio_service.dart @@ -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'); diff --git a/mnemo_cards_backend/lib/tests/test_manager.dart b/mnemo_cards_backend/lib/tests/test_manager.dart index 9c8864a..4637b22 100644 --- a/mnemo_cards_backend/lib/tests/test_manager.dart +++ b/mnemo_cards_backend/lib/tests/test_manager.dart @@ -869,6 +869,12 @@ class TestManager { Future 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); diff --git a/mnemo_cards_backend/migrations/004_add_test_expires_at.sql b/mnemo_cards_backend/migrations/004_add_test_expires_at.sql new file mode 100644 index 0000000..422a23c --- /dev/null +++ b/mnemo_cards_backend/migrations/004_add_test_expires_at.sql @@ -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; diff --git a/mnemo_cards_web_v2/lib/presentation/pages/game/game_page.dart b/mnemo_cards_web_v2/lib/presentation/pages/game/game_page.dart index a9e1dcc..5f797e8 100644 --- a/mnemo_cards_web_v2/lib/presentation/pages/game/game_page.dart +++ b/mnemo_cards_web_v2/lib/presentation/pages/game/game_page.dart @@ -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 { 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( diff --git a/mnemo_cards_web_v2/lib/presentation/pages/pack_details/pack_details_page.dart b/mnemo_cards_web_v2/lib/presentation/pages/pack_details/pack_details_page.dart index ac66525..6f7c706 100644 --- a/mnemo_cards_web_v2/lib/presentation/pages/pack_details/pack_details_page.dart +++ b/mnemo_cards_web_v2/lib/presentation/pages/pack_details/pack_details_page.dart @@ -282,7 +282,7 @@ class _PackDetailsPageState extends State { } 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 { try { await Future.delayed(const Duration(milliseconds: 100)); if (mounted) { - context.go('/purchase/${widget.packId}'); + await context.push('/purchase/${widget.packId}'); } } finally { if (mounted) { diff --git a/mnemo_cards_web_v2/lib/presentation/pages/tasks/tasks_page.dart b/mnemo_cards_web_v2/lib/presentation/pages/tasks/tasks_page.dart index 506eaf7..afebf74 100644 --- a/mnemo_cards_web_v2/lib/presentation/pages/tasks/tasks_page.dart +++ b/mnemo_cards_web_v2/lib/presentation/pages/tasks/tasks_page.dart @@ -142,7 +142,7 @@ class _TasksPageState extends State 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), diff --git a/mnemo_cards_web_v2/lib/presentation/pages/test/test_page.dart b/mnemo_cards_web_v2/lib/presentation/pages/test/test_page.dart index 254d507..785886b 100644 --- a/mnemo_cards_web_v2/lib/presentation/pages/test/test_page.dart +++ b/mnemo_cards_web_v2/lib/presentation/pages/test/test_page.dart @@ -219,7 +219,7 @@ class _TestPageState extends State { } void _playGame(BuildContext context) { - context.go('/game/${widget.testId}'); + context.push('/game/${widget.testId}'); } void _handleBack() { diff --git a/mnemo_cards_web_v2/lib/presentation/widgets/game_card.dart b/mnemo_cards_web_v2/lib/presentation/widgets/game_card.dart index 870f745..23d811c 100644 --- a/mnemo_cards_web_v2/lib/presentation/widgets/game_card.dart +++ b/mnemo_cards_web_v2/lib/presentation/widgets/game_card.dart @@ -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, diff --git a/mnemo_cards_web_v2/lib/presentation/widgets/pack_card.dart b/mnemo_cards_web_v2/lib/presentation/widgets/pack_card.dart index cbe1b8a..46f8181 100644 --- a/mnemo_cards_web_v2/lib/presentation/widgets/pack_card.dart +++ b/mnemo_cards_web_v2/lib/presentation/widgets/pack_card.dart @@ -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( diff --git a/mnemo_cards_web_v2/lib/presentation/widgets/pack_card_vertical.dart b/mnemo_cards_web_v2/lib/presentation/widgets/pack_card_vertical.dart index d16d75b..f795bee 100644 --- a/mnemo_cards_web_v2/lib/presentation/widgets/pack_card_vertical.dart +++ b/mnemo_cards_web_v2/lib/presentation/widgets/pack_card_vertical.dart @@ -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( diff --git a/mnemo_cards_web_v2/lib/presentation/widgets/task_card.dart b/mnemo_cards_web_v2/lib/presentation/widgets/task_card.dart index e026709..ac799a0 100644 --- a/mnemo_cards_web_v2/lib/presentation/widgets/task_card.dart +++ b/mnemo_cards_web_v2/lib/presentation/widgets/task_card.dart @@ -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),