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
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:
parent
ce564924f2
commit
2a4b3ecc4e
15 changed files with 141 additions and 27 deletions
|
|
@ -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,8 +629,24 @@ 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({
|
||||
'error': 'Invalid test data',
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ class Tests extends Table {
|
|||
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 =>
|
||||
customType(PgTypes.timestampWithTimezone).withDefault(now())();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
17
mnemo_cards_backend/migrations/004_add_test_expires_at.sql
Normal file
17
mnemo_cards_backend/migrations/004_add_test_expires_at.sql
Normal 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;
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in a new issue