From 63524c2575160ca4a9b10f7632ed3ea091e7c182 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sat, 24 Jan 2026 19:07:29 +0300 Subject: [PATCH] voice --- mnemo_cards_backend/Dockerfile | 3 + .../lib/api/v2/admin_tests_api_v2.dart | 11 ++- .../lib/api/v2/media_api_v2.dart | 14 ++-- .../lib/api/v2/media_api_v2.g.dart | 1 + .../lib/cron/test_generator.dart | 4 +- .../lib/database/daos/test_dao.dart | 21 +++--- .../lib/database/database.g.dart | 75 +++++++++++++++++++ .../lib/database/tables/tests.dart | 2 +- .../lib/storage/minio_service.dart | 4 +- .../lib/tests/test_manager.dart | 2 +- 10 files changed, 109 insertions(+), 28 deletions(-) diff --git a/mnemo_cards_backend/Dockerfile b/mnemo_cards_backend/Dockerfile index d9350e9..c2ca2df 100644 --- a/mnemo_cards_backend/Dockerfile +++ b/mnemo_cards_backend/Dockerfile @@ -22,6 +22,9 @@ RUN dart pub get WORKDIR /app/mnemo_cards_backend RUN dart pub get +# Generate code (drift, json_serializable, etc.) +RUN dart run build_runner build --delete-conflicting-outputs + # Compile to native executable RUN dart compile exe lib/main.dart -o /app/server 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 892d516..b1d9941 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 @@ -631,7 +631,7 @@ class AdminTestsApiV2 { 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) { @@ -642,7 +642,8 @@ class AdminTestsApiV2 { '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").', + 'details': + 'Please provide expiresAt in ISO 8601 format (e.g., "2025-01-24T12:00:00Z").', }, statusCode: 400); } } @@ -799,7 +800,8 @@ class AdminTestsApiV2 { 'version': updatedTest.version, 'time': updatedTest.time, 'timeSubtitle': updatedTest.timeSubtitle, - 'expiresAt': updatedTest.expiresAt?.dateTime.toIso8601String(), + 'expiresAt': updatedTest.expiresAt?.dateTime + .toIso8601String(), } : { 'id': testId, @@ -933,7 +935,8 @@ class AdminTestsApiV2 { 'version': createdTest.version, 'time': createdTest.time, 'timeSubtitle': createdTest.timeSubtitle, - 'expiresAt': createdTest.expiresAt?.dateTime.toIso8601String(), + 'expiresAt': createdTest.expiresAt?.dateTime + .toIso8601String(), } : { 'id': newTest, diff --git a/mnemo_cards_backend/lib/api/v2/media_api_v2.dart b/mnemo_cards_backend/lib/api/v2/media_api_v2.dart index dd84f2e..5e83bfb 100644 --- a/mnemo_cards_backend/lib/api/v2/media_api_v2.dart +++ b/mnemo_cards_backend/lib/api/v2/media_api_v2.dart @@ -125,12 +125,10 @@ class MediaApiV2 { } /// Parses multipart/form-data request and extracts file and optional objectId - Future<({ - Uint8List bytes, - String contentType, - String? filename, - String? objectId, - })?> _parseMultipartFileWithObjectId(Request request) async { + Future< + ({Uint8List bytes, String contentType, String? filename, String? objectId})? + > + _parseMultipartFileWithObjectId(Request request) async { try { final multipart = request.multipart(); if (multipart == null) { @@ -144,7 +142,7 @@ class MediaApiV2 { await for (final part in multipart.parts) { final contentDisposition = part.headers['content-disposition'] ?? ''; - + if (contentDisposition.contains('name="file"')) { // Read file bytes final chunks = >[]; @@ -375,7 +373,7 @@ class MediaApiV2 { r'^[0-9a-f]{32}$', caseSensitive: false, ).hasMatch(objectId); - + if (!isValidUuid && !isValidHash) { return _badRequest( 'Invalid objectId format. Must be UUID or MD5 hash (32 hex characters)', diff --git a/mnemo_cards_backend/lib/api/v2/media_api_v2.g.dart b/mnemo_cards_backend/lib/api/v2/media_api_v2.g.dart index 13a75b0..fae8f7b 100644 --- a/mnemo_cards_backend/lib/api/v2/media_api_v2.g.dart +++ b/mnemo_cards_backend/lib/api/v2/media_api_v2.g.dart @@ -13,5 +13,6 @@ Router _$MediaApiV2Router(MediaApiV2 service) { router.add('POST', r'/media/upload/voice', service.uploadVoice); router.add('GET', r'/media///url', service.getPresignedUrl); router.add('DELETE', r'/media//', service.deleteFile); + router.add('POST', r'/media/generate-tts', service.generateTTS); return router; } diff --git a/mnemo_cards_backend/lib/cron/test_generator.dart b/mnemo_cards_backend/lib/cron/test_generator.dart index b74befc..6158fe9 100644 --- a/mnemo_cards_backend/lib/cron/test_generator.dart +++ b/mnemo_cards_backend/lib/cron/test_generator.dart @@ -52,7 +52,7 @@ class TestGeneratorTask with cron_task.Task { } } print('Generated tests for $ok packs'); - + // Soft delete tests that have expired (expiresAt <= now) print('Soft deleting expired tests...'); try { @@ -61,7 +61,7 @@ class TestGeneratorTask with cron_task.Task { } 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 { diff --git a/mnemo_cards_backend/lib/database/daos/test_dao.dart b/mnemo_cards_backend/lib/database/daos/test_dao.dart index c88ce9f..b3779d9 100644 --- a/mnemo_cards_backend/lib/database/daos/test_dao.dart +++ b/mnemo_cards_backend/lib/database/daos/test_dao.dart @@ -14,7 +14,7 @@ class TestDao extends DatabaseAccessor with _$TestDaoMixin { // ==================== Tests ==================== /// Получить тест по ID (включая soft-deleted для доступа по прямой ссылке) - /// + /// /// Примечание: soft-deleted тесты не показываются в списке пака (getTestsByPackId), /// но доступны по прямой ссылке для завершения начатых тестов. Future getTestById(String id) { @@ -62,17 +62,18 @@ 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(); + + 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) { @@ -87,7 +88,7 @@ class TestDao extends DatabaseAccessor with _$TestDaoMixin { /// 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, diff --git a/mnemo_cards_backend/lib/database/database.g.dart b/mnemo_cards_backend/lib/database/database.g.dart index 9fbda2b..f9739db 100644 --- a/mnemo_cards_backend/lib/database/database.g.dart +++ b/mnemo_cards_backend/lib/database/database.g.dart @@ -8548,6 +8548,18 @@ class $TestsTable extends Tests with TableInfo<$TestsTable, Test> { type: DriftSqlType.string, requiredDuringInsert: false, ); + static const VerificationMeta _expiresAtMeta = const VerificationMeta( + 'expiresAt', + ); + @override + late final GeneratedColumn expiresAt = + GeneratedColumn( + 'expires_at', + aliasedName, + true, + type: PgTypes.timestampWithTimezone, + requiredDuringInsert: false, + ); static const VerificationMeta _createdAtMeta = const VerificationMeta( 'createdAt', ); @@ -8608,6 +8620,7 @@ class $TestsTable extends Tests with TableInfo<$TestsTable, Test> { version, time, timeSubtitle, + expiresAt, createdAt, updatedAt, isDeleted, @@ -8669,6 +8682,12 @@ class $TestsTable extends Tests with TableInfo<$TestsTable, Test> { ), ); } + if (data.containsKey('expires_at')) { + context.handle( + _expiresAtMeta, + expiresAt.isAcceptableOrUnknown(data['expires_at']!, _expiresAtMeta), + ); + } if (data.containsKey('created_at')) { context.handle( _createdAtMeta, @@ -8730,6 +8749,10 @@ class $TestsTable extends Tests with TableInfo<$TestsTable, Test> { DriftSqlType.string, data['${effectivePrefix}time_subtitle'], ), + expiresAt: attachedDatabase.typeMapping.read( + PgTypes.timestampWithTimezone, + data['${effectivePrefix}expires_at'], + ), createdAt: attachedDatabase.typeMapping.read( PgTypes.timestampWithTimezone, data['${effectivePrefix}created_at'], @@ -8763,6 +8786,7 @@ class Test extends DataClass implements Insertable { final String? version; final String? time; final String? timeSubtitle; + final PgDateTime? expiresAt; final PgDateTime createdAt; final PgDateTime updatedAt; final bool isDeleted; @@ -8775,6 +8799,7 @@ class Test extends DataClass implements Insertable { this.version, this.time, this.timeSubtitle, + this.expiresAt, required this.createdAt, required this.updatedAt, required this.isDeleted, @@ -8800,6 +8825,12 @@ class Test extends DataClass implements Insertable { if (!nullToAbsent || timeSubtitle != null) { map['time_subtitle'] = Variable(timeSubtitle); } + if (!nullToAbsent || expiresAt != null) { + map['expires_at'] = Variable( + expiresAt, + PgTypes.timestampWithTimezone, + ); + } map['created_at'] = Variable( createdAt, PgTypes.timestampWithTimezone, @@ -8835,6 +8866,9 @@ class Test extends DataClass implements Insertable { timeSubtitle: timeSubtitle == null && nullToAbsent ? const Value.absent() : Value(timeSubtitle), + expiresAt: expiresAt == null && nullToAbsent + ? const Value.absent() + : Value(expiresAt), createdAt: Value(createdAt), updatedAt: Value(updatedAt), isDeleted: Value(isDeleted), @@ -8857,6 +8891,7 @@ class Test extends DataClass implements Insertable { version: serializer.fromJson(json['version']), time: serializer.fromJson(json['time']), timeSubtitle: serializer.fromJson(json['timeSubtitle']), + expiresAt: serializer.fromJson(json['expiresAt']), createdAt: serializer.fromJson(json['createdAt']), updatedAt: serializer.fromJson(json['updatedAt']), isDeleted: serializer.fromJson(json['isDeleted']), @@ -8874,6 +8909,7 @@ class Test extends DataClass implements Insertable { 'version': serializer.toJson(version), 'time': serializer.toJson(time), 'timeSubtitle': serializer.toJson(timeSubtitle), + 'expiresAt': serializer.toJson(expiresAt), 'createdAt': serializer.toJson(createdAt), 'updatedAt': serializer.toJson(updatedAt), 'isDeleted': serializer.toJson(isDeleted), @@ -8889,6 +8925,7 @@ class Test extends DataClass implements Insertable { Value version = const Value.absent(), Value time = const Value.absent(), Value timeSubtitle = const Value.absent(), + Value expiresAt = const Value.absent(), PgDateTime? createdAt, PgDateTime? updatedAt, bool? isDeleted, @@ -8901,6 +8938,7 @@ class Test extends DataClass implements Insertable { version: version.present ? version.value : this.version, time: time.present ? time.value : this.time, timeSubtitle: timeSubtitle.present ? timeSubtitle.value : this.timeSubtitle, + expiresAt: expiresAt.present ? expiresAt.value : this.expiresAt, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, isDeleted: isDeleted ?? this.isDeleted, @@ -8917,6 +8955,7 @@ class Test extends DataClass implements Insertable { timeSubtitle: data.timeSubtitle.present ? data.timeSubtitle.value : this.timeSubtitle, + expiresAt: data.expiresAt.present ? data.expiresAt.value : this.expiresAt, createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, isDeleted: data.isDeleted.present ? data.isDeleted.value : this.isDeleted, @@ -8934,6 +8973,7 @@ class Test extends DataClass implements Insertable { ..write('version: $version, ') ..write('time: $time, ') ..write('timeSubtitle: $timeSubtitle, ') + ..write('expiresAt: $expiresAt, ') ..write('createdAt: $createdAt, ') ..write('updatedAt: $updatedAt, ') ..write('isDeleted: $isDeleted, ') @@ -8951,6 +8991,7 @@ class Test extends DataClass implements Insertable { version, time, timeSubtitle, + expiresAt, createdAt, updatedAt, isDeleted, @@ -8967,6 +9008,7 @@ class Test extends DataClass implements Insertable { other.version == this.version && other.time == this.time && other.timeSubtitle == this.timeSubtitle && + other.expiresAt == this.expiresAt && other.createdAt == this.createdAt && other.updatedAt == this.updatedAt && other.isDeleted == this.isDeleted && @@ -8981,6 +9023,7 @@ class TestsCompanion extends UpdateCompanion { final Value version; final Value time; final Value timeSubtitle; + final Value expiresAt; final Value createdAt; final Value updatedAt; final Value isDeleted; @@ -8994,6 +9037,7 @@ class TestsCompanion extends UpdateCompanion { this.version = const Value.absent(), this.time = const Value.absent(), this.timeSubtitle = const Value.absent(), + this.expiresAt = const Value.absent(), this.createdAt = const Value.absent(), this.updatedAt = const Value.absent(), this.isDeleted = const Value.absent(), @@ -9008,6 +9052,7 @@ class TestsCompanion extends UpdateCompanion { this.version = const Value.absent(), this.time = const Value.absent(), this.timeSubtitle = const Value.absent(), + this.expiresAt = const Value.absent(), this.createdAt = const Value.absent(), this.updatedAt = const Value.absent(), this.isDeleted = const Value.absent(), @@ -9022,6 +9067,7 @@ class TestsCompanion extends UpdateCompanion { Expression? version, Expression? time, Expression? timeSubtitle, + Expression? expiresAt, Expression? createdAt, Expression? updatedAt, Expression? isDeleted, @@ -9036,6 +9082,7 @@ class TestsCompanion extends UpdateCompanion { if (version != null) 'version': version, if (time != null) 'time': time, if (timeSubtitle != null) 'time_subtitle': timeSubtitle, + if (expiresAt != null) 'expires_at': expiresAt, if (createdAt != null) 'created_at': createdAt, if (updatedAt != null) 'updated_at': updatedAt, if (isDeleted != null) 'is_deleted': isDeleted, @@ -9052,6 +9099,7 @@ class TestsCompanion extends UpdateCompanion { Value? version, Value? time, Value? timeSubtitle, + Value? expiresAt, Value? createdAt, Value? updatedAt, Value? isDeleted, @@ -9066,6 +9114,7 @@ class TestsCompanion extends UpdateCompanion { version: version ?? this.version, time: time ?? this.time, timeSubtitle: timeSubtitle ?? this.timeSubtitle, + expiresAt: expiresAt ?? this.expiresAt, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, isDeleted: isDeleted ?? this.isDeleted, @@ -9098,6 +9147,12 @@ class TestsCompanion extends UpdateCompanion { if (timeSubtitle.present) { map['time_subtitle'] = Variable(timeSubtitle.value); } + if (expiresAt.present) { + map['expires_at'] = Variable( + expiresAt.value, + PgTypes.timestampWithTimezone, + ); + } if (createdAt.present) { map['created_at'] = Variable( createdAt.value, @@ -9135,6 +9190,7 @@ class TestsCompanion extends UpdateCompanion { ..write('version: $version, ') ..write('time: $time, ') ..write('timeSubtitle: $timeSubtitle, ') + ..write('expiresAt: $expiresAt, ') ..write('createdAt: $createdAt, ') ..write('updatedAt: $updatedAt, ') ..write('isDeleted: $isDeleted, ') @@ -27689,6 +27745,7 @@ typedef $$TestsTableCreateCompanionBuilder = Value version, Value time, Value timeSubtitle, + Value expiresAt, Value createdAt, Value updatedAt, Value isDeleted, @@ -27704,6 +27761,7 @@ typedef $$TestsTableUpdateCompanionBuilder = Value version, Value time, Value timeSubtitle, + Value expiresAt, Value createdAt, Value updatedAt, Value isDeleted, @@ -27819,6 +27877,11 @@ class $$TestsTableFilterComposer extends Composer<_$AppDatabase, $TestsTable> { builder: (column) => ColumnFilters(column), ); + ColumnFilters get expiresAt => $composableBuilder( + column: $table.expiresAt, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get createdAt => $composableBuilder( column: $table.createdAt, builder: (column) => ColumnFilters(column), @@ -27959,6 +28022,11 @@ class $$TestsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get expiresAt => $composableBuilder( + column: $table.expiresAt, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get createdAt => $composableBuilder( column: $table.createdAt, builder: (column) => ColumnOrderings(column), @@ -28012,6 +28080,9 @@ class $$TestsTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get expiresAt => + $composableBuilder(column: $table.expiresAt, builder: (column) => column); + GeneratedColumn get createdAt => $composableBuilder(column: $table.createdAt, builder: (column) => column); @@ -28140,6 +28211,7 @@ class $$TestsTableTableManager Value version = const Value.absent(), Value time = const Value.absent(), Value timeSubtitle = const Value.absent(), + Value expiresAt = const Value.absent(), Value createdAt = const Value.absent(), Value updatedAt = const Value.absent(), Value isDeleted = const Value.absent(), @@ -28153,6 +28225,7 @@ class $$TestsTableTableManager version: version, time: time, timeSubtitle: timeSubtitle, + expiresAt: expiresAt, createdAt: createdAt, updatedAt: updatedAt, isDeleted: isDeleted, @@ -28168,6 +28241,7 @@ class $$TestsTableTableManager Value version = const Value.absent(), Value time = const Value.absent(), Value timeSubtitle = const Value.absent(), + Value expiresAt = const Value.absent(), Value createdAt = const Value.absent(), Value updatedAt = const Value.absent(), Value isDeleted = const Value.absent(), @@ -28181,6 +28255,7 @@ class $$TestsTableTableManager version: version, time: time, timeSubtitle: timeSubtitle, + expiresAt: expiresAt, createdAt: createdAt, updatedAt: updatedAt, isDeleted: isDeleted, diff --git a/mnemo_cards_backend/lib/database/tables/tests.dart b/mnemo_cards_backend/lib/database/tables/tests.dart index e22a4a5..f08822c 100644 --- a/mnemo_cards_backend/lib/database/tables/tests.dart +++ b/mnemo_cards_backend/lib/database/tables/tests.dart @@ -15,7 +15,7 @@ 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 => diff --git a/mnemo_cards_backend/lib/storage/minio_service.dart b/mnemo_cards_backend/lib/storage/minio_service.dart index 70e03e0..ef0e9f1 100644 --- a/mnemo_cards_backend/lib/storage/minio_service.dart +++ b/mnemo_cards_backend/lib/storage/minio_service.dart @@ -76,8 +76,8 @@ class MinioService { final cacheControl = contentType.startsWith('image/') ? 'public, max-age=604800, immutable' // 7 дней для изображений : contentType.startsWith('audio/') - ? 'public, max-age=604800' // 7 дней для аудио - : 'public, max-age=86400'; // 1 день для других + ? 'public, max-age=604800' // 7 дней для аудио + : 'public, max-age=86400'; // 1 день для других await _client.putObject( bucket, diff --git a/mnemo_cards_backend/lib/tests/test_manager.dart b/mnemo_cards_backend/lib/tests/test_manager.dart index 4637b22..e822411 100644 --- a/mnemo_cards_backend/lib/tests/test_manager.dart +++ b/mnemo_cards_backend/lib/tests/test_manager.dart @@ -874,7 +874,7 @@ class TestManager { final expiresAt = testDto.version == 'generated' ? PgDateTime(DateTime.now().add(const Duration(hours: 1))) : null; - + // Create test final testCompanion = TestsCompanion.insert( name: testDto.name,