+
+
onChange(e.target.value)}
+ placeholder={placeholder}
+ disabled={disabled}
+ aria-invalid={trimmed.length > 0 && !hasValidHex}
+ />
+
+
+
onChange(e.target.value)}
+ disabled={disabled}
+ className={cn(
+ 'h-10 w-10 cursor-pointer rounded-md border border-input bg-background p-0',
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
+ disabled && 'cursor-not-allowed opacity-50',
+ )}
+ />
+
+
0 && !hasValidHex && 'border-dashed border-red-500',
+ )}
+ style={hasValidHex ? { backgroundColor: trimmed } : undefined}
+ />
+
+
+
+
+ {palette.map((color) => {
+ const isSelected = trimmed.toLowerCase() === color.toLowerCase()
+
+ return (
+
+
+ )
+}
diff --git a/mnemo_cards_admin/src/pages/PacksPage.tsx b/mnemo_cards_admin/src/pages/PacksPage.tsx
index 0cb0c28..0ecd3db 100644
--- a/mnemo_cards_admin/src/pages/PacksPage.tsx
+++ b/mnemo_cards_admin/src/pages/PacksPage.tsx
@@ -37,6 +37,7 @@ import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox'
import { ImageUpload } from '@/components/ui/image-upload'
+import { ColorPaletteInput } from '@/components/ui/color-palette-input'
import { PackCardsManager } from '@/components/PackCardsManager'
import { PackTestsManager } from '@/components/PackTestsManager'
import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react'
@@ -509,10 +510,11 @@ export default function PacksPage() {
- setFormData(prev => ({ ...prev, color: e.target.value }))}
+ onChange={(value) => setFormData(prev => ({ ...prev, color: value }))}
+ disabled={createMutation.isPending || updateMutation.isPending}
placeholder="#FF0000"
/>
diff --git a/mnemo_cards_backend/lib/api/v2/admin_auth_api_v2.dart b/mnemo_cards_backend/lib/api/v2/admin_auth_api_v2.dart
index 63a7942..749e42e 100644
--- a/mnemo_cards_backend/lib/api/v2/admin_auth_api_v2.dart
+++ b/mnemo_cards_backend/lib/api/v2/admin_auth_api_v2.dart
@@ -136,7 +136,12 @@ class AdminAuthApiV2 {
// Find or create admin user based on telegram user ID
var (user, _) = await _userManager.createOrGetUser(
externalId: authCode.telegramUserId,
- email: '',
+ email: null,
+ telegram: (authCode.telegramUsername?.trim().isNotEmpty ?? false)
+ ? (authCode.telegramUsername!.trim().startsWith('@')
+ ? authCode.telegramUsername!.trim()
+ : '@${authCode.telegramUsername!.trim()}')
+ : null,
name: authCode.telegramUsername ??
(authCode.firstName != null
? (authCode.lastName != null
@@ -170,6 +175,7 @@ class AdminAuthApiV2 {
'id': user.id,
'name': user.name,
'email': user.email,
+ 'telegram': user.telegram,
'admin': user.admin,
},
});
@@ -254,6 +260,7 @@ class AdminAuthApiV2 {
'id': user.id,
'name': user.name,
'email': user.email,
+ 'telegram': user.telegram,
'admin': user.admin,
},
});
diff --git a/mnemo_cards_backend/lib/api/v2/admin_cards_api_v2.dart b/mnemo_cards_backend/lib/api/v2/admin_cards_api_v2.dart
index 9cf5b5f..5136b19 100644
--- a/mnemo_cards_backend/lib/api/v2/admin_cards_api_v2.dart
+++ b/mnemo_cards_backend/lib/api/v2/admin_cards_api_v2.dart
@@ -13,6 +13,7 @@ import 'package:drift/drift.dart' as drift;
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:mnemo_cards_backend/api/v2/extensions/game_card_extensions.dart';
import 'package:mnemo_cards_backend/api/v2/extensions/voice_extensions.dart';
+import 'package:mnemo_cards_backend/packs/card_image_storage.dart';
part 'admin_cards_api_v2.g.dart';
@@ -22,6 +23,91 @@ class AdminCardsApiV2 {
const AdminCardsApiV2(this._db);
+ Future
_normalizeCardImageForDb({
+ required String cardId,
+ required String existingValue,
+ required String? incomingValue,
+ required bool isBack,
+ }) async {
+ if (incomingValue == null) return existingValue;
+
+ final v = incomingValue.trim();
+ if (v.isEmpty) return '';
+
+ // Admin UI often round-trips the already converted API URL.
+ // Never persist that URL into DB.
+ if (CardImageStorage.isApiImageUrl(v)) {
+ if (CardImageStorage.isRemoteUrl(existingValue)) {
+ return existingValue;
+ }
+
+ final existingFileName = CardImageStorage.sanitizeCardsFileName(existingValue);
+ if (existingFileName != null) {
+ return existingFileName;
+ }
+
+ // If the DB still contains base64 (legacy), persist it to file now
+ // and switch the DB value to a file name.
+ final migrated = await CardImageStorage.persistFromBase64(
+ cardId: cardId,
+ imageValue: existingValue,
+ preferredFileName: null,
+ isBack: isBack,
+ );
+ if (migrated != null) {
+ return migrated.fileName;
+ }
+
+ // Try to heal legacy-bad values by resolving a local file by cardId.
+ final resolved = await CardImageStorage.tryResolveLocalFile(
+ cardId: cardId,
+ imageValue: v,
+ isBack: isBack,
+ );
+ if (resolved != null) {
+ return resolved.fileName;
+ }
+
+ // Keep DB invariant (path only): if we can't resolve, clear the value.
+ return '';
+ }
+
+ // If the client sends a local file name (preferred DB format).
+ final fileName = CardImageStorage.sanitizeCardsFileName(v);
+ if (fileName != null) {
+ return fileName;
+ }
+
+ // Allow storing a remote URL in DB (served via redirect in PacksApiV2).
+ if (CardImageStorage.isRemoteUrl(v)) {
+ return v;
+ }
+
+ // Base64/data-url: persist and store a file name in DB.
+ final stored = await CardImageStorage.persistFromBase64(
+ cardId: cardId,
+ imageValue: v,
+ preferredFileName: existingValue,
+ isBack: isBack,
+ );
+ if (stored != null) {
+ return stored.fileName;
+ }
+
+ // UUID without extension: try resolving to an existing local file.
+ final resolved = await CardImageStorage.tryResolveLocalFile(
+ cardId: cardId,
+ imageValue: v,
+ isBack: isBack,
+ );
+ if (resolved != null) {
+ return resolved.fileName;
+ }
+
+ // Last resort: keep as-is (still a "path", but might be invalid).
+ return v;
+ }
+
Future _ensureAdmin(Request request) async {
try {
await request.access!
@@ -47,77 +133,49 @@ class AdminCardsApiV2 {
);
}
- // Helper function to check if string is base64 encoded
- bool _isBase64(String value) {
- if (value.isEmpty) return false;
- // Base64 strings are typically long and contain only base64 characters
- // Check length (base64 images are usually > 100 chars) and character set
- if (value.length < 50) return false;
- final base64Regex = RegExp(r'^[A-Za-z0-9+/]*={0,2}$');
- return base64Regex.hasMatch(value) && value.length > 100;
- }
-
- // Helper function to convert image value to URL
- // If image is base64 and we have packId and cardId, convert to URL
- // Otherwise return as is
+ // Helper function to convert card image reference to API URL.
+ //
+ // **DB invariant**: `GameCards.image` stores a file name (or a remote URL),
+ // not base64. We always expose images via `/api/v2/packs/.../cards//image`
+ // when `packId` is known, so admin UI never needs the raw file name.
String? _convertImageToUrl(String? imageValue, String? packId, String cardId) {
if (imageValue == null || imageValue.isEmpty) return imageValue;
- // If it's already a proper URL, return as is
- if (imageValue.startsWith('http://') ||
+ // If it's already a URL, return as is.
+ if (imageValue.startsWith('http://') ||
imageValue.startsWith('https://') ||
- (imageValue.startsWith('/api/') && imageValue.contains('/cards/') &&
- (imageValue.endsWith('/image') || imageValue.endsWith('/imageBack')))) {
+ (imageValue.startsWith('/api/') &&
+ imageValue.contains('/cards/') &&
+ (imageValue.endsWith('/image') ||
+ imageValue.endsWith('/imageBack')))) {
return imageValue;
}
// If packId is null, we can't convert to URL, return as is
if (packId == null) return imageValue;
-
- // Check if it's base64 encoded image
- if (_isBase64(imageValue)) {
- // Convert base64 to URL using card ID
- // The endpoint will decode base64 from card.image field
- return '/api/v2/packs/$packId/cards/$cardId/image';
- }
-
- // If it looks like a UUID (card ID), convert to URL
- if (RegExp(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', caseSensitive: false).hasMatch(imageValue)) {
- return '/api/v2/packs/$packId/cards/$imageValue/image';
- }
-
- // Otherwise, assume it's already a card ID and convert
- return '/api/v2/packs/$packId/cards/$imageValue/image';
+
+ // Always expose image via the cardId endpoint.
+ return '/api/v2/packs/$packId/cards/$cardId/image';
}
- // Helper function to convert imageBack value to URL
+ // Helper function to convert card back image reference to API URL.
String? _convertImageBackToUrl(String? imageValue, String? packId, String cardId) {
if (imageValue == null || imageValue.isEmpty) return imageValue;
- // If it's already a proper URL, return as is
- if (imageValue.startsWith('http://') ||
+ // If it's already a URL, return as is.
+ if (imageValue.startsWith('http://') ||
imageValue.startsWith('https://') ||
- (imageValue.startsWith('/api/') && imageValue.contains('/cards/') &&
- (imageValue.endsWith('/image') || imageValue.endsWith('/imageBack')))) {
+ (imageValue.startsWith('/api/') &&
+ imageValue.contains('/cards/') &&
+ (imageValue.endsWith('/image') ||
+ imageValue.endsWith('/imageBack')))) {
return imageValue;
}
// If packId is null, we can't convert to URL, return as is
if (packId == null) return imageValue;
-
- // Check if it's base64 encoded image
- if (_isBase64(imageValue)) {
- // Convert base64 to URL using card ID
- return '/api/v2/packs/$packId/cards/$cardId/imageBack';
- }
-
- // If it looks like a UUID (card ID), convert to URL
- if (RegExp(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', caseSensitive: false).hasMatch(imageValue)) {
- return '/api/v2/packs/$packId/cards/$imageValue/imageBack';
- }
-
- // Otherwise, assume it's already a card ID and convert
- return '/api/v2/packs/$packId/cards/$imageValue/imageBack';
+
+ return '/api/v2/packs/$packId/cards/$cardId/imageBack';
}
/// GET /api/v2/admin/cards
@@ -323,6 +381,19 @@ class AdminCardsApiV2 {
statusCode: 404,
);
}
+ final normalizedImage = await _normalizeCardImageForDb(
+ cardId: cardId,
+ existingValue: existing.image,
+ incomingValue: requestDto.image,
+ isBack: false,
+ );
+ final normalizedImageBack = await _normalizeCardImageForDb(
+ cardId: cardId,
+ existingValue: existing.imageBack ?? '',
+ incomingValue: requestDto.imageBack,
+ isBack: true,
+ );
+
// Update existing card
final updated = existing.copyWith(
original: requestDto.original ?? existing.original,
@@ -330,9 +401,9 @@ class AdminCardsApiV2 {
mnemo: requestDto.mnemo != null
? drift.Value(requestDto.mnemo)
: const drift.Value.absent(),
- image: requestDto.image ?? existing.image,
+ image: normalizedImage,
imageBack: requestDto.imageBack != null
- ? drift.Value(requestDto.imageBack)
+ ? drift.Value(normalizedImageBack)
: const drift.Value.absent(),
back: requestDto.back != null
? drift.Value(requestDto.back)
@@ -371,12 +442,12 @@ class AdminCardsApiV2 {
final companion = GameCardsCompanion.insert(
original: requestDto.original!,
translation: requestDto.translation!,
- image: requestDto.image ?? '',
+ image: '',
mnemo: requestDto.mnemo != null
? drift.Value(requestDto.mnemo)
: const drift.Value.absent(),
imageBack: requestDto.imageBack != null
- ? drift.Value(requestDto.imageBack)
+ ? drift.Value('')
: const drift.Value.absent(),
back: requestDto.back != null
? drift.Value(requestDto.back)
@@ -408,12 +479,34 @@ class AdminCardsApiV2 {
);
}
+ final normalizedImage = await _normalizeCardImageForDb(
+ cardId: cardId,
+ existingValue: created.image,
+ incomingValue: requestDto.image,
+ isBack: false,
+ );
+ final normalizedImageBack = await _normalizeCardImageForDb(
+ cardId: cardId,
+ existingValue: created.imageBack ?? '',
+ incomingValue: requestDto.imageBack,
+ isBack: true,
+ );
+
+ final updated = created.copyWith(
+ image: normalizedImage,
+ imageBack: requestDto.imageBack != null
+ ? drift.Value(normalizedImageBack)
+ : const drift.Value.absent(),
+ updatedAt: PgDateTime(DateTime.now()),
+ );
+ await _db.packDao.updateCard(updated);
+
// Получить паки для карточки
final packs = await _db.packDao.getPacksForCard(cardId);
final packId = packs.isNotEmpty ? packs.first.id : null;
// Конвертировать в DTO
- final cardDto = created.toGameCardDtoWithPack(
+ final cardDto = updated.toGameCardDtoWithPack(
packId,
_convertImageToUrl,
_convertImageBackToUrl,
@@ -486,15 +579,28 @@ class AdminCardsApiV2 {
);
}
+ final normalizedImage = await _normalizeCardImageForDb(
+ cardId: cardId,
+ existingValue: existing.image,
+ incomingValue: requestDto.image,
+ isBack: false,
+ );
+ final normalizedImageBack = await _normalizeCardImageForDb(
+ cardId: cardId,
+ existingValue: existing.imageBack ?? '',
+ incomingValue: requestDto.imageBack,
+ isBack: true,
+ );
+
final updated = existing.copyWith(
original: requestDto.original ?? existing.original,
translation: requestDto.translation ?? existing.translation,
mnemo: requestDto.mnemo != null
? drift.Value(requestDto.mnemo)
: const drift.Value.absent(),
- image: requestDto.image ?? existing.image,
+ image: normalizedImage,
imageBack: requestDto.imageBack != null
- ? drift.Value(requestDto.imageBack)
+ ? drift.Value(normalizedImageBack)
: const drift.Value.absent(),
back: requestDto.back != null
? drift.Value(requestDto.back)
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 abb119c..3e3285c 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
@@ -7,6 +7,7 @@ import 'package:mnemo_cards_backend/api/authorize/helpers.dart';
import 'package:drift/drift.dart' as drift;
import 'package:drift_postgres/drift_postgres.dart';
import 'package:mnemo_cards_backend/database/database.dart';
+import 'package:mnemo_cards_backend/packs/card_image_storage.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
@@ -33,11 +34,11 @@ class AdminTestsApiV2 {
// Helper function to convert base64 image to card and return card ID
Future _convertBase64ToCard(String base64Image, String? packId) async {
try {
- // Create a temporary card with the base64 image
+ // Create a temporary card and persist the image into `data/cards/`.
final companion = GameCardsCompanion.insert(
original: 'button_image',
translation: 'button_image',
- image: base64Image,
+ image: '',
mnemo: drift.Value('button_image'),
);
@@ -47,6 +48,28 @@ class AdminTestsApiV2 {
if (packId != null) {
await _db.packDao.addCardToPack(cardId: cardId, packId: packId);
}
+
+ final stored = await CardImageStorage.persistFromBase64(
+ cardId: cardId,
+ imageValue: base64Image,
+ preferredFileName: null,
+ isBack: false,
+ );
+ if (stored == null) {
+ return null;
+ }
+
+ final created = await _db.packDao.getCardById(cardId);
+ if (created == null) {
+ return null;
+ }
+
+ await _db.packDao.updateCard(
+ created.copyWith(
+ image: stored.fileName,
+ updatedAt: PgDateTime(DateTime.now()),
+ ),
+ );
return cardId;
} catch (e) {
diff --git a/mnemo_cards_backend/lib/api/v2/auth_api_v2.dart b/mnemo_cards_backend/lib/api/v2/auth_api_v2.dart
index 5f876a0..cc1a67d 100644
--- a/mnemo_cards_backend/lib/api/v2/auth_api_v2.dart
+++ b/mnemo_cards_backend/lib/api/v2/auth_api_v2.dart
@@ -35,6 +35,13 @@ class AuthApiV2 {
this._telegramAuthCodeService,
);
+ String? _normalizeTelegram(String? username) {
+ if (username == null) return null;
+ final trimmed = username.trim();
+ if (trimmed.isEmpty) return null;
+ return trimmed.startsWith('@') ? trimmed : '@$trimmed';
+ }
+
Response _ok(Object? object, {Map headers = const {}}) =>
Response.ok(
object == null ? null : jsonEncode(object),
@@ -273,7 +280,8 @@ class AuthApiV2 {
// Find or create user based on telegram user ID
var (user, _) = await _userManager.createOrGetUser(
externalId: userData.id,
- email: '',
+ email: null,
+ telegram: _normalizeTelegram(userData.username),
name: userData.username,
);
@@ -326,7 +334,8 @@ class AuthApiV2 {
// Get or create user
final (user, _) = await _userManager.createOrGetUser(
externalId: authCode.telegramUserId,
- email: authCode.telegramUsername ?? '',
+ email: null,
+ telegram: _normalizeTelegram(authCode.telegramUsername),
name: name ?? authCode.telegramUserId,
);
diff --git a/mnemo_cards_backend/lib/api/v2/packs_api_v2.dart b/mnemo_cards_backend/lib/api/v2/packs_api_v2.dart
index 2180fae..7068ad8 100644
--- a/mnemo_cards_backend/lib/api/v2/packs_api_v2.dart
+++ b/mnemo_cards_backend/lib/api/v2/packs_api_v2.dart
@@ -1,6 +1,7 @@
import 'dart:convert';
import 'dart:io';
+import 'package:drift/drift.dart' as d;
import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/api/authorize/acl_types.dart';
import 'package:mnemo_cards_backend/api/authorize/access_service.dart';
@@ -8,6 +9,7 @@ import 'package:mnemo_cards_backend/api/authorize/helpers.dart';
import 'package:mnemo_cards_backend/database/database.dart' hide VoiceModel;
import 'package:mnemo_cards_backend/database/database.dart' as drift show VoiceModel;
import 'package:mnemo_cards_backend/packs/card_pack_drift_extension.dart';
+import 'package:mnemo_cards_backend/packs/card_image_storage.dart';
import 'package:mnemo_cards_backend/packs/pack_manager.dart' show PackManager, PackManagerUtils;
import 'package:mnemo_cards_backend/tests/test_manager.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
@@ -406,21 +408,62 @@ class PacksApiV2 {
return _notFound('Card does not belong to this pack');
}
- // Get image bytes - card.image is already base64 or path
- // For now, assume it's base64 encoded or needs to be loaded
- final imageBase64 = card.image;
-
- if (imageBase64.isEmpty) {
+ final imageValue = card.image.trim();
+ if (imageValue.isEmpty) {
return _notFound('Image not found');
}
- // Decode base64 and return as image
- final imageBytes = base64Decode(imageBase64);
+ // Remote image: redirect (DB stores a path/URL)
+ if (CardImageStorage.isRemoteUrl(imageValue)) {
+ return Response.found(imageValue);
+ }
+
+ // Local image file name (preferred), or legacy values (API URL / UUID / base64)
+ final resolved = await CardImageStorage.tryResolveLocalFile(
+ cardId: cardId,
+ imageValue: imageValue,
+ isBack: false,
+ );
+
+ if (resolved != null) {
+ // Opportunistic migration: if DB accidentally contains an API URL/UUID,
+ // rewrite to the real file name once we successfully resolve it.
+ if (resolved.fileName != card.image) {
+ await _db.packDao.updateCard(card.copyWith(image: resolved.fileName));
+ }
+
+ return Response.ok(
+ resolved.bytes,
+ headers: {
+ 'Content-Type': resolved.contentType,
+ 'Cache-Control': 'public, max-age=86400', // Cache for 1 day
+ },
+ );
+ }
+
+ // Base64 fallback: persist to file and migrate DB.
+ final stored = await CardImageStorage.persistFromBase64(
+ cardId: cardId,
+ imageValue: imageValue,
+ preferredFileName: null,
+ isBack: false,
+ );
+
+ if (stored == null) {
+ return _notFound('Image not found');
+ }
+
+ await _db.packDao.updateCard(card.copyWith(image: stored.fileName));
+ final file = File('${PackManagerUtils.assetsDirectory.path}/cards/${stored.fileName}');
+ if (!file.existsSync()) {
+ return _notFound('Image not found');
+ }
+ final bytes = await file.readAsBytes();
return Response.ok(
- imageBytes,
+ bytes,
headers: {
- 'Content-Type': 'image/png',
+ 'Content-Type': stored.contentType,
'Cache-Control': 'public, max-age=86400', // Cache for 1 day
},
);
@@ -473,21 +516,62 @@ class PacksApiV2 {
return _notFound('Card does not belong to this pack');
}
- // Get image bytes - card.imageBack is already base64 or path
- // For now, assume it's base64 encoded or needs to be loaded
- final imageBase64 = card.imageBack!;
-
- if (imageBase64.isEmpty) {
+ final imageValue = card.imageBack!.trim();
+ if (imageValue.isEmpty) {
return _notFound('Back image not found');
}
- // Decode base64 and return as image
- final imageBytes = base64Decode(imageBase64);
+ if (CardImageStorage.isRemoteUrl(imageValue)) {
+ return Response.found(imageValue);
+ }
+
+ final resolved = await CardImageStorage.tryResolveLocalFile(
+ cardId: cardId,
+ imageValue: imageValue,
+ isBack: true,
+ );
+
+ if (resolved != null) {
+ if (resolved.fileName != card.imageBack) {
+ await _db.packDao.updateCard(
+ card.copyWith(imageBack: d.Value(resolved.fileName)),
+ );
+ }
+
+ return Response.ok(
+ resolved.bytes,
+ headers: {
+ 'Content-Type': resolved.contentType,
+ 'Cache-Control': 'public, max-age=86400', // Cache for 1 day
+ },
+ );
+ }
+
+ final stored = await CardImageStorage.persistFromBase64(
+ cardId: cardId,
+ imageValue: imageValue,
+ preferredFileName: null,
+ isBack: true,
+ );
+
+ if (stored == null) {
+ return _notFound('Back image not found');
+ }
+
+ await _db.packDao.updateCard(
+ card.copyWith(imageBack: d.Value(stored.fileName)),
+ );
+
+ final file = File('${PackManagerUtils.assetsDirectory.path}/cards/${stored.fileName}');
+ if (!file.existsSync()) {
+ return _notFound('Back image not found');
+ }
+ final bytes = await file.readAsBytes();
return Response.ok(
- imageBytes,
+ bytes,
headers: {
- 'Content-Type': 'image/png',
+ 'Content-Type': stored.contentType,
'Cache-Control': 'public, max-age=86400', // Cache for 1 day
},
);
diff --git a/mnemo_cards_backend/lib/cron/test_generator.dart b/mnemo_cards_backend/lib/cron/test_generator.dart
index a97693b..9640ace 100644
--- a/mnemo_cards_backend/lib/cron/test_generator.dart
+++ b/mnemo_cards_backend/lib/cron/test_generator.dart
@@ -52,6 +52,20 @@ class TestGeneratorTask with cron_task.Task {
}
}
print('Generated tests for $ok packs');
+ print('Purging old generated tests (hard delete TTL)...');
+ 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('Deleting old test stats');
// Delete test statistics where test doesn't exist (test was deleted)
final allTests = await _db.testDao.getAllTests();
diff --git a/mnemo_cards_backend/lib/database/daos/test_dao.dart b/mnemo_cards_backend/lib/database/daos/test_dao.dart
index 1cd61f8..3b643ce 100644
--- a/mnemo_cards_backend/lib/database/daos/test_dao.dart
+++ b/mnemo_cards_backend/lib/database/daos/test_dao.dart
@@ -59,6 +59,65 @@ class TestDao extends DatabaseAccessor with _$TestDaoMixin {
updatedAt: Value(PgDateTime(DateTime.now())),
));
}
+
+ /// 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.
+ Future hardDeleteOldSoftDeletedGeneratedTests({
+ required Duration olderThan,
+ }) async {
+ final threshold = DateTime.now().subtract(olderThan);
+
+ final toDelete = await (select(tests)
+ ..where(
+ (t) =>
+ t.isDeleted.equals(true) &
+ t.version.equals('generated') &
+ t.deletedAt.isSmallerThanValue(PgDateTime(threshold)),
+ ))
+ .get();
+
+ var deleted = 0;
+ for (final test in toDelete) {
+ deleted += await (delete(tests)..where((t) => t.id.equals(test.id))).go();
+ }
+ return deleted;
+ }
+
+ /// Hard delete generated tests that are not linked to any pack.
+ ///
+ /// These tests are unreachable from the product (no pack relation) and
+ /// should not accumulate forever. This also cleans up historical leftovers
+ /// from the earlier bug where generated tests were created but not linked.
+ Future hardDeleteOrphanGeneratedTests({
+ required Duration olderThan,
+ }) async {
+ final threshold = DateTime.now().subtract(olderThan);
+
+ final rows = await (select(tests).join([
+ leftOuterJoin(
+ testPackRelations,
+ testPackRelations.testId.equalsExp(tests.id),
+ ),
+ ])
+ ..where(
+ tests.version.equals('generated') &
+ tests.createdAt
+ .isSmallerThanValue(PgDateTime(threshold)) &
+ testPackRelations.testId.isNull(),
+ ))
+ .get();
+
+ final orphanTests = rows.map((r) => r.readTable(tests)).toList();
+
+ var deleted = 0;
+ for (final test in orphanTests) {
+ deleted += await (delete(tests)..where((t) => t.id.equals(test.id))).go();
+ }
+ return deleted;
+ }
/// Связать тест с паком
Future linkTestToPack(String testId, String packId) async {
diff --git a/mnemo_cards_backend/lib/database/database.dart b/mnemo_cards_backend/lib/database/database.dart
index b610544..62822f0 100644
--- a/mnemo_cards_backend/lib/database/database.dart
+++ b/mnemo_cards_backend/lib/database/database.dart
@@ -123,7 +123,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase(super.e);
@override
- int get schemaVersion => 2;
+ int get schemaVersion => 3;
/// Factory для подключения к PostgreSQL
static AppDatabase connect({
@@ -182,6 +182,11 @@ class AppDatabase extends _$AppDatabase {
if (from < 2) {
await _migrateToV2(m);
}
+
+ // Миграция с версии 2 на 3: добавление telegram в users
+ if (from < 3) {
+ await _migrateToV3(m);
+ }
},
beforeOpen: (details) async {
print('Opening database connection...');
@@ -201,6 +206,7 @@ class AppDatabase extends _$AppDatabase {
// Users indexes
await customStatement('CREATE INDEX IF NOT EXISTS idx_users_email ON users(email) WHERE email IS NOT NULL');
+ await customStatement('CREATE INDEX IF NOT EXISTS idx_users_telegram ON users(telegram) WHERE telegram IS NOT NULL');
await customStatement('CREATE INDEX IF NOT EXISTS idx_users_admin ON users(admin) WHERE admin = TRUE');
await customStatement('CREATE INDEX IF NOT EXISTS idx_users_not_deleted ON users(is_deleted) WHERE is_deleted = FALSE');
@@ -311,4 +317,37 @@ class AppDatabase extends _$AppDatabase {
rethrow;
}
}
+
+ /// Миграция с версии 2 на версию 3
+ /// Добавление поля telegram в users и перенос старых telegram-логинов из email
+ Future _migrateToV3(Migrator m) async {
+ print('Starting migration to v3: adding telegram to users...');
+
+ try {
+ await customStatement(
+ 'ALTER TABLE users ADD COLUMN IF NOT EXISTS telegram TEXT',
+ );
+
+ // Раньше Telegram-username сохранялся в email. Переносим "похожие на username"
+ // значения в telegram и очищаем email.
+ await customStatement(
+ 'UPDATE users '
+ 'SET telegram = email, email = NULL '
+ 'WHERE (telegram IS NULL OR telegram = \'\') '
+ 'AND email IS NOT NULL AND email != \'\' '
+ 'AND POSITION(\'@\' IN email) = 0',
+ );
+
+ // Индексы на existing db
+ await customStatement(
+ 'CREATE INDEX IF NOT EXISTS idx_users_telegram ON users(telegram) WHERE telegram IS NOT NULL',
+ );
+
+ print('Migration to v3 completed successfully!');
+ } catch (e, stackTrace) {
+ print('Error during migration to v3: $e');
+ print('Stack trace: $stackTrace');
+ rethrow;
+ }
+ }
}
diff --git a/mnemo_cards_backend/lib/database/database.g.dart b/mnemo_cards_backend/lib/database/database.g.dart
index bcaca22..9fbda2b 100644
--- a/mnemo_cards_backend/lib/database/database.g.dart
+++ b/mnemo_cards_backend/lib/database/database.g.dart
@@ -48,6 +48,17 @@ class $UsersTable extends Users with TableInfo<$UsersTable, User> {
type: DriftSqlType.string,
requiredDuringInsert: false,
);
+ static const VerificationMeta _telegramMeta = const VerificationMeta(
+ 'telegram',
+ );
+ @override
+ late final GeneratedColumn telegram = GeneratedColumn(
+ 'telegram',
+ aliasedName,
+ true,
+ type: DriftSqlType.string,
+ requiredDuringInsert: false,
+ );
static const VerificationMeta _adminMeta = const VerificationMeta('admin');
@override
late final GeneratedColumn admin = GeneratedColumn(
@@ -125,6 +136,7 @@ class $UsersTable extends Users with TableInfo<$UsersTable, User> {
externalUserId,
name,
email,
+ telegram,
admin,
userSettings,
purchases,
@@ -170,6 +182,12 @@ class $UsersTable extends Users with TableInfo<$UsersTable, User> {
email.isAcceptableOrUnknown(data['email']!, _emailMeta),
);
}
+ if (data.containsKey('telegram')) {
+ context.handle(
+ _telegramMeta,
+ telegram.isAcceptableOrUnknown(data['telegram']!, _telegramMeta),
+ );
+ }
if (data.containsKey('admin')) {
context.handle(
_adminMeta,
@@ -228,6 +246,10 @@ class $UsersTable extends Users with TableInfo<$UsersTable, User> {
DriftSqlType.string,
data['${effectivePrefix}email'],
),
+ telegram: attachedDatabase.typeMapping.read(
+ DriftSqlType.string,
+ data['${effectivePrefix}telegram'],
+ ),
admin: attachedDatabase.typeMapping.read(
DriftSqlType.bool,
data['${effectivePrefix}admin'],
@@ -271,6 +293,7 @@ class User extends DataClass implements Insertable {
final String externalUserId;
final String? name;
final String? email;
+ final String? telegram;
final bool admin;
final String? userSettings;
final List purchases;
@@ -282,6 +305,7 @@ class User extends DataClass implements Insertable {
required this.externalUserId,
this.name,
this.email,
+ this.telegram,
required this.admin,
this.userSettings,
required this.purchases,
@@ -300,6 +324,9 @@ class User extends DataClass implements Insertable {
if (!nullToAbsent || email != null) {
map['email'] = Variable(email);
}
+ if (!nullToAbsent || telegram != null) {
+ map['telegram'] = Variable(telegram);
+ }
map['admin'] = Variable(admin);
if (!nullToAbsent || userSettings != null) {
map['user_settings'] = Variable(userSettings);
@@ -329,6 +356,9 @@ class User extends DataClass implements Insertable {
email: email == null && nullToAbsent
? const Value.absent()
: Value(email),
+ telegram: telegram == null && nullToAbsent
+ ? const Value.absent()
+ : Value(telegram),
admin: Value(admin),
userSettings: userSettings == null && nullToAbsent
? const Value.absent()
@@ -350,6 +380,7 @@ class User extends DataClass implements Insertable {
externalUserId: serializer.fromJson(json['externalUserId']),
name: serializer.fromJson(json['name']),
email: serializer.fromJson(json['email']),
+ telegram: serializer.fromJson(json['telegram']),
admin: serializer.fromJson(json['admin']),
userSettings: serializer.fromJson(json['userSettings']),
purchases: serializer.fromJson>(json['purchases']),
@@ -366,6 +397,7 @@ class User extends DataClass implements Insertable {
'externalUserId': serializer.toJson(externalUserId),
'name': serializer.toJson(name),
'email': serializer.toJson(email),
+ 'telegram': serializer.toJson(telegram),
'admin': serializer.toJson(admin),
'userSettings': serializer.toJson(userSettings),
'purchases': serializer.toJson>(purchases),
@@ -380,6 +412,7 @@ class User extends DataClass implements Insertable {
String? externalUserId,
Value name = const Value.absent(),
Value email = const Value.absent(),
+ Value telegram = const Value.absent(),
bool? admin,
Value userSettings = const Value.absent(),
List? purchases,
@@ -391,6 +424,7 @@ class User extends DataClass implements Insertable {
externalUserId: externalUserId ?? this.externalUserId,
name: name.present ? name.value : this.name,
email: email.present ? email.value : this.email,
+ telegram: telegram.present ? telegram.value : this.telegram,
admin: admin ?? this.admin,
userSettings: userSettings.present ? userSettings.value : this.userSettings,
purchases: purchases ?? this.purchases,
@@ -406,6 +440,7 @@ class User extends DataClass implements Insertable {
: this.externalUserId,
name: data.name.present ? data.name.value : this.name,
email: data.email.present ? data.email.value : this.email,
+ telegram: data.telegram.present ? data.telegram.value : this.telegram,
admin: data.admin.present ? data.admin.value : this.admin,
userSettings: data.userSettings.present
? data.userSettings.value
@@ -424,6 +459,7 @@ class User extends DataClass implements Insertable {
..write('externalUserId: $externalUserId, ')
..write('name: $name, ')
..write('email: $email, ')
+ ..write('telegram: $telegram, ')
..write('admin: $admin, ')
..write('userSettings: $userSettings, ')
..write('purchases: $purchases, ')
@@ -440,6 +476,7 @@ class User extends DataClass implements Insertable {
externalUserId,
name,
email,
+ telegram,
admin,
userSettings,
purchases,
@@ -455,6 +492,7 @@ class User extends DataClass implements Insertable {
other.externalUserId == this.externalUserId &&
other.name == this.name &&
other.email == this.email &&
+ other.telegram == this.telegram &&
other.admin == this.admin &&
other.userSettings == this.userSettings &&
other.purchases == this.purchases &&
@@ -468,6 +506,7 @@ class UsersCompanion extends UpdateCompanion {
final Value externalUserId;
final Value name;
final Value email;
+ final Value telegram;
final Value admin;
final Value userSettings;
final Value> purchases;
@@ -480,6 +519,7 @@ class UsersCompanion extends UpdateCompanion {
this.externalUserId = const Value.absent(),
this.name = const Value.absent(),
this.email = const Value.absent(),
+ this.telegram = const Value.absent(),
this.admin = const Value.absent(),
this.userSettings = const Value.absent(),
this.purchases = const Value.absent(),
@@ -493,6 +533,7 @@ class UsersCompanion extends UpdateCompanion {
required String externalUserId,
this.name = const Value.absent(),
this.email = const Value.absent(),
+ this.telegram = const Value.absent(),
this.admin = const Value.absent(),
this.userSettings = const Value.absent(),
this.purchases = const Value.absent(),
@@ -506,6 +547,7 @@ class UsersCompanion extends UpdateCompanion {
Expression? externalUserId,
Expression? name,
Expression? email,
+ Expression? telegram,
Expression? admin,
Expression? userSettings,
Expression? purchases,
@@ -519,6 +561,7 @@ class UsersCompanion extends UpdateCompanion {
if (externalUserId != null) 'external_user_id': externalUserId,
if (name != null) 'name': name,
if (email != null) 'email': email,
+ if (telegram != null) 'telegram': telegram,
if (admin != null) 'admin': admin,
if (userSettings != null) 'user_settings': userSettings,
if (purchases != null) 'purchases': purchases,
@@ -534,6 +577,7 @@ class UsersCompanion extends UpdateCompanion {
Value? externalUserId,
Value? name,
Value? email,
+ Value? telegram,
Value? admin,
Value? userSettings,
Value>? purchases,
@@ -547,6 +591,7 @@ class UsersCompanion extends UpdateCompanion {
externalUserId: externalUserId ?? this.externalUserId,
name: name ?? this.name,
email: email ?? this.email,
+ telegram: telegram ?? this.telegram,
admin: admin ?? this.admin,
userSettings: userSettings ?? this.userSettings,
purchases: purchases ?? this.purchases,
@@ -572,6 +617,9 @@ class UsersCompanion extends UpdateCompanion {
if (email.present) {
map['email'] = Variable(email.value);
}
+ if (telegram.present) {
+ map['telegram'] = Variable(telegram.value);
+ }
if (admin.present) {
map['admin'] = Variable(admin.value);
}
@@ -611,6 +659,7 @@ class UsersCompanion extends UpdateCompanion {
..write('externalUserId: $externalUserId, ')
..write('name: $name, ')
..write('email: $email, ')
+ ..write('telegram: $telegram, ')
..write('admin: $admin, ')
..write('userSettings: $userSettings, ')
..write('purchases: $purchases, ')
@@ -19712,6 +19761,7 @@ typedef $$UsersTableCreateCompanionBuilder =
required String externalUserId,
Value name,
Value email,
+ Value telegram,
Value admin,
Value userSettings,
Value> purchases,
@@ -19726,6 +19776,7 @@ typedef $$UsersTableUpdateCompanionBuilder =
Value externalUserId,
Value name,
Value email,
+ Value telegram,
Value admin,
Value userSettings,
Value> purchases,
@@ -20029,6 +20080,11 @@ class $$UsersTableFilterComposer extends Composer<_$AppDatabase, $UsersTable> {
builder: (column) => ColumnFilters(column),
);
+ ColumnFilters get telegram => $composableBuilder(
+ column: $table.telegram,
+ builder: (column) => ColumnFilters(column),
+ );
+
ColumnFilters get admin => $composableBuilder(
column: $table.admin,
builder: (column) => ColumnFilters(column),
@@ -20415,6 +20471,11 @@ class $$UsersTableOrderingComposer
builder: (column) => ColumnOrderings(column),
);
+ ColumnOrderings get telegram => $composableBuilder(
+ column: $table.telegram,
+ builder: (column) => ColumnOrderings(column),
+ );
+
ColumnOrderings get admin => $composableBuilder(
column: $table.admin,
builder: (column) => ColumnOrderings(column),
@@ -20469,6 +20530,9 @@ class $$UsersTableAnnotationComposer
GeneratedColumn get email =>
$composableBuilder(column: $table.email, builder: (column) => column);
+ GeneratedColumn get telegram =>
+ $composableBuilder(column: $table.telegram, builder: (column) => column);
+
GeneratedColumn get admin =>
$composableBuilder(column: $table.admin, builder: (column) => column);
@@ -20864,6 +20928,7 @@ class $$UsersTableTableManager
Value externalUserId = const Value.absent(),
Value name = const Value.absent(),
Value email = const Value.absent(),
+ Value telegram = const Value.absent(),
Value admin = const Value.absent(),
Value userSettings = const Value.absent(),
Value> purchases = const Value.absent(),
@@ -20876,6 +20941,7 @@ class $$UsersTableTableManager
externalUserId: externalUserId,
name: name,
email: email,
+ telegram: telegram,
admin: admin,
userSettings: userSettings,
purchases: purchases,
@@ -20890,6 +20956,7 @@ class $$UsersTableTableManager
required String externalUserId,
Value name = const Value.absent(),
Value email = const Value.absent(),
+ Value telegram = const Value.absent(),
Value admin = const Value.absent(),
Value userSettings = const Value.absent(),
Value> purchases = const Value.absent(),
@@ -20902,6 +20969,7 @@ class $$UsersTableTableManager
externalUserId: externalUserId,
name: name,
email: email,
+ telegram: telegram,
admin: admin,
userSettings: userSettings,
purchases: purchases,
diff --git a/mnemo_cards_backend/lib/database/tables/users.dart b/mnemo_cards_backend/lib/database/tables/users.dart
index 46b29bb..f9ad348 100644
--- a/mnemo_cards_backend/lib/database/tables/users.dart
+++ b/mnemo_cards_backend/lib/database/tables/users.dart
@@ -10,6 +10,7 @@ class Users extends Table {
TextColumn get externalUserId => text().unique()();
TextColumn get name => text().nullable()();
TextColumn get email => text().nullable()();
+ TextColumn get telegram => text().nullable()();
BoolColumn get admin => boolean()
.customConstraint('NOT NULL DEFAULT FALSE')(); // PostgreSQL использует нативный BOOLEAN
diff --git a/mnemo_cards_backend/lib/tests/test_manager.dart b/mnemo_cards_backend/lib/tests/test_manager.dart
index 0468859..36a4f7d 100644
--- a/mnemo_cards_backend/lib/tests/test_manager.dart
+++ b/mnemo_cards_backend/lib/tests/test_manager.dart
@@ -355,14 +355,18 @@ class TestManager {
multiply: 1.0,
);
- // Save test to database
- await addTest(testDto);
-
- // Link test to pack
- await _db.testDao.linkTestToPack(testDto.id!, packId);
+ // Save test to database and link it to the pack in one transaction.
+ await addTest(
+ testDto,
+ packId: packId,
+ );
}
- Future addTest(TestDto testDto) async {
+ Future addTest(
+ TestDto testDto, {
+ String? packId,
+ }) async {
+ String? createdTestId;
await _db.transaction(() async {
// Create test
final testCompanion = TestsCompanion.insert(
@@ -374,7 +378,12 @@ class TestManager {
timeSubtitle: drift.Value(testDto.timeSubtitle),
);
- final testId = await _db.testDao.createTest(testCompanion);
+ createdTestId = await _db.testDao.createTest(testCompanion);
+ final testId = createdTestId!;
+
+ if (packId != null) {
+ await _db.testDao.linkTestToPack(testId, packId);
+ }
// Create questions
int orderIndex = 0;
@@ -406,5 +415,6 @@ class TestManager {
await _db.testDao.createTestQuestion(questionCompanion);
}
});
+ return createdTestId!;
}
}
\ No newline at end of file
diff --git a/mnemo_cards_backend/lib/user/user_drift_extension.dart b/mnemo_cards_backend/lib/user/user_drift_extension.dart
index 34121ae..36bdef8 100644
--- a/mnemo_cards_backend/lib/user/user_drift_extension.dart
+++ b/mnemo_cards_backend/lib/user/user_drift_extension.dart
@@ -9,6 +9,7 @@ extension UserToUserModel on User {
id: id,
name: name,
email: email,
+ telegram: telegram,
admin: admin,
purchases: purchases,
userSettings: userSettings,
@@ -26,6 +27,7 @@ extension UserModelToUser on UserModel {
externalUserId: const drift.Value.absent(),
name: drift.Value(name),
email: drift.Value(email),
+ telegram: drift.Value(telegram),
admin: drift.Value(admin),
purchases: drift.Value(purchases),
userSettings: drift.Value(userSettings),
diff --git a/mnemo_cards_backend/lib/user/user_manager.dart b/mnemo_cards_backend/lib/user/user_manager.dart
index 8395316..0e6e977 100644
--- a/mnemo_cards_backend/lib/user/user_manager.dart
+++ b/mnemo_cards_backend/lib/user/user_manager.dart
@@ -114,19 +114,41 @@ class UserManager {
Future<(UserModel, String)> createOrGetUser({
required String externalId,
- required String email,
+ String? email,
+ String? telegram,
String? name,
}) async {
// Check if user exists by externalId
final existingUser = await _db.userDao.getUserByExternalId(externalId);
if (existingUser != null) {
- print('User found $name $email');
+ print('User found $name $email $telegram');
+
+ // Обновляем контактные данные, если они пришли впервые/изменились
+ final shouldUpdateEmail =
+ email != null && email.isNotEmpty && existingUser.email != email;
+ final shouldUpdateTelegram = telegram != null &&
+ telegram.isNotEmpty &&
+ existingUser.telegram != telegram;
+
+ if (shouldUpdateEmail || shouldUpdateTelegram) {
+ await _db.userDao.updateUserPartial(
+ UsersCompanion(
+ id: drift.Value(existingUser.id),
+ email: shouldUpdateEmail ? drift.Value(email) : const drift.Value.absent(),
+ telegram: shouldUpdateTelegram
+ ? drift.Value(telegram)
+ : const drift.Value.absent(),
+ updatedAt: drift.Value(PgDateTime(DateTime.now())),
+ ),
+ );
+ }
+
final userModel = await existingUser.toUserModel();
final token = await createOrGetAuthToken(userModel, externalId);
return (userModel, token);
}
- print('Creating new user $name $email');
+ print('Creating new user $name $email $telegram');
// Create new user with user data in transaction
final now = DateTime.now();
@@ -134,6 +156,7 @@ class UserManager {
externalUserId: externalId,
name: drift.Value(name),
email: drift.Value(email),
+ telegram: drift.Value(telegram),
admin: drift.Value(false),
purchases: drift.Value([]),
createdAt: drift.Value(PgDateTime(now)),
@@ -162,7 +185,7 @@ class UserManager {
// Give free packs to new user
await _freePacksDistributor.giveFreePacksToUser(userModel);
- print('User $name $email created successfully');
+ print('User $name $email $telegram created successfully');
return (userModel, token);
}
diff --git a/mnemo_cards_backend/lib/user/user_manager_drift.dart b/mnemo_cards_backend/lib/user/user_manager_drift.dart
index 6de01c2..e6f8132 100644
--- a/mnemo_cards_backend/lib/user/user_manager_drift.dart
+++ b/mnemo_cards_backend/lib/user/user_manager_drift.dart
@@ -107,25 +107,46 @@ class UserManager {
Future<(UserModel, String)> createOrGetUser({
required String externalId,
- required String email,
+ String? email,
+ String? telegram,
String? name,
}) async {
// Check if user exists by externalId
final existingUser = await _db.userDao.getUserByExternalId(externalId);
if (existingUser != null) {
- print('User found $name $email');
+ print('User found $name $email $telegram');
+
+ final shouldUpdateEmail =
+ email != null && email.isNotEmpty && existingUser.email != email;
+ final shouldUpdateTelegram = telegram != null &&
+ telegram.isNotEmpty &&
+ existingUser.telegram != telegram;
+
+ if (shouldUpdateEmail || shouldUpdateTelegram) {
+ await _db.userDao.updateUserPartial(
+ UsersCompanion(
+ id: drift.Value(existingUser.id),
+ email: shouldUpdateEmail ? drift.Value(email) : const drift.Value.absent(),
+ telegram: shouldUpdateTelegram
+ ? drift.Value(telegram)
+ : const drift.Value.absent(),
+ ),
+ );
+ }
+
final userModel = await existingUser.toUserModel();
final token = await createOrGetAuthToken(userModel, externalId);
return (userModel, token);
}
- print('Creating new user $name $email');
+ print('Creating new user $name $email $telegram');
// Create new user
final userCompanion = UsersCompanion.insert(
externalUserId: externalId,
name: drift.Value(name),
email: drift.Value(email),
+ telegram: drift.Value(telegram),
);
final userId = await _db.userDao.createUser(userCompanion);
diff --git a/mnemo_cards_backend/lib/user/user_model.dart b/mnemo_cards_backend/lib/user/user_model.dart
index 4ea6964..088efb6 100644
--- a/mnemo_cards_backend/lib/user/user_model.dart
+++ b/mnemo_cards_backend/lib/user/user_model.dart
@@ -13,6 +13,7 @@ extension UserModelExtension on UserModel {
id: id,
name: name,
email: email,
+ telegram: telegram,
admin: admin,
packs: packs.map((e) => e.id?.toString()).whereNotNull().toList(),
subscription: activeSubscription,
@@ -36,6 +37,7 @@ extension UserModelExtension on UserModel {
id: id,
name: name,
email: email,
+ telegram: telegram,
admin: admin,
packs: packs.map((e) => e.id?.toString()).whereNotNull().toList(),
subscription: activeSubscription,
diff --git a/mnemo_cards_backend/test/database/daos/test_dao_generated_cleanup_test.dart b/mnemo_cards_backend/test/database/daos/test_dao_generated_cleanup_test.dart
new file mode 100644
index 0000000..4f84484
--- /dev/null
+++ b/mnemo_cards_backend/test/database/daos/test_dao_generated_cleanup_test.dart
@@ -0,0 +1,136 @@
+import 'dart:io';
+
+import 'package:drift/drift.dart' hide isNull;
+import 'package:drift_postgres/drift_postgres.dart';
+import 'package:mnemo_cards_backend/database/database.dart';
+import 'package:test/test.dart';
+
+void main() {
+ late AppDatabase db;
+
+ setUpAll(() async {
+ final host = Platform.environment['TEST_DB_HOST'] ?? 'localhost';
+ final port =
+ int.tryParse(Platform.environment['TEST_DB_PORT'] ?? '5432') ?? 5432;
+ final database =
+ Platform.environment['TEST_DB_NAME'] ?? 'mnemo_cards_test';
+ final username = Platform.environment['TEST_DB_USER'] ??
+ Platform.environment['DB_USER'] ??
+ 'mnemo_user';
+ final password = Platform.environment['TEST_DB_PASSWORD'] ??
+ Platform.environment['DB_PASSWORD'] ??
+ '';
+
+ db = AppDatabase.connect(
+ host: host,
+ port: port,
+ database: database,
+ username: username,
+ password: password,
+ );
+
+ // Ensure pgcrypto is available for gen_random_uuid().
+ await db.customStatement('CREATE EXTENSION IF NOT EXISTS pgcrypto');
+
+ // Create tables if needed (idempotent in drift for Postgres).
+ await Migrator(db).createAll();
+ });
+
+ tearDownAll(() async {
+ await db.close();
+ });
+
+ group('TestDao - generated cleanup', () {
+ tearDown(() async {
+ // Keep cleanup scoped to generated tests only to avoid touching other data
+ // that might exist in the shared test DB.
+ final allGenerated = await (db.select(db.tests)
+ ..where((t) => t.version.equals('generated')))
+ .get();
+
+ for (final t in allGenerated) {
+ await (db.delete(db.tests)..where((x) => x.id.equals(t.id))).go();
+ }
+ });
+
+ test('hardDeleteOrphanGeneratedTests removes old orphans', () async {
+ final now = DateTime.now();
+
+ final oldOrphanId = await db.testDao.createTest(
+ TestsCompanion.insert(
+ name: 'old orphan generated test',
+ version: const Value('generated'),
+ createdAt: Value(PgDateTime(now.subtract(const Duration(hours: 2)))),
+ updatedAt: Value(PgDateTime(now.subtract(const Duration(hours: 2)))),
+ ),
+ );
+
+ // Not old enough -> should survive.
+ await db.testDao.createTest(
+ TestsCompanion.insert(
+ name: 'fresh orphan generated test',
+ version: const Value('generated'),
+ createdAt: Value(PgDateTime(now)),
+ updatedAt: Value(PgDateTime(now)),
+ ),
+ );
+
+ final deleted = await db.testDao.hardDeleteOrphanGeneratedTests(
+ olderThan: const Duration(hours: 1),
+ );
+
+ expect(deleted, equals(1));
+ final stillThere = await db.testDao.getTestById(oldOrphanId);
+ expect(stillThere, isNull);
+ });
+
+ test('hardDeleteOldSoftDeletedGeneratedTests removes old soft-deleted tests',
+ () async {
+ final now = DateTime.now();
+
+ // Create a pack so we have a normal relation entry.
+ final packId = await db.packDao.createPack(
+ CardPacksCompanion.insert(
+ title: 'pack for generated test cleanup',
+ subtitle: 'subtitle',
+ size: 1,
+ ),
+ );
+
+ // Create a generated test and link it to the pack.
+ final testId = await db.testDao.createTest(
+ TestsCompanion.insert(
+ name: 'linked generated test',
+ version: const Value('generated'),
+ createdAt: Value(PgDateTime(now.subtract(const Duration(days: 10)))),
+ updatedAt: Value(PgDateTime(now.subtract(const Duration(days: 10)))),
+ ),
+ );
+ await db.testDao.linkTestToPack(testId, packId);
+
+ // Soft delete it long ago so it's eligible for TTL purge.
+ await (db.update(db.tests)..where((t) => t.id.equals(testId))).write(
+ TestsCompanion(
+ isDeleted: const Value(true),
+ deletedAt: Value(PgDateTime(now.subtract(const Duration(days: 9)))),
+ updatedAt: Value(PgDateTime(now.subtract(const Duration(days: 9)))),
+ ),
+ );
+
+ final deleted = await db.testDao.hardDeleteOldSoftDeletedGeneratedTests(
+ olderThan: const Duration(days: 7),
+ );
+
+ expect(deleted, equals(1));
+ final stillThere = await db.testDao.getTestById(testId);
+ expect(stillThere, isNull);
+
+ // Cleanup the pack relation/pack.
+ await (db.delete(db.testPackRelations)
+ ..where((r) => r.packId.equals(packId)))
+ .go();
+ await (db.delete(db.cardPacks)..where((p) => p.id.equals(packId))).go();
+ });
+ });
+}
+
diff --git a/mnemo_cards_backend/test/models/user_model_telegram_test.dart b/mnemo_cards_backend/test/models/user_model_telegram_test.dart
new file mode 100644
index 0000000..e5ea200
--- /dev/null
+++ b/mnemo_cards_backend/test/models/user_model_telegram_test.dart
@@ -0,0 +1,112 @@
+import 'package:test/test.dart';
+import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
+
+void main() {
+ group('UserModel telegram field', () {
+ test('should serialize and deserialize with telegram', () {
+ final user = UserModel(
+ id: 'test-id',
+ name: 'Test User',
+ email: 'test@example.com',
+ telegram: '@testuser',
+ admin: false,
+ purchases: ['pack1', 'pack2'],
+ );
+
+ final json = user.toJson();
+ expect(json['telegram'], equals('@testuser'));
+ expect(json['email'], equals('test@example.com'));
+
+ final deserializedUser = UserModel.fromJson(json);
+ expect(deserializedUser.telegram, equals('@testuser'));
+ expect(deserializedUser.email, equals('test@example.com'));
+ expect(deserializedUser.name, equals('Test User'));
+ });
+
+ test('should handle null telegram', () {
+ final user = UserModel(
+ id: 'test-id',
+ name: 'Test User',
+ email: 'test@example.com',
+ telegram: null,
+ admin: false,
+ );
+
+ final json = user.toJson();
+ expect(json['telegram'], isNull);
+
+ final deserializedUser = UserModel.fromJson(json);
+ expect(deserializedUser.telegram, isNull);
+ expect(deserializedUser.email, equals('test@example.com'));
+ });
+
+ test('should handle missing telegram in JSON', () {
+ final json = {
+ 'id': 'test-id',
+ 'name': 'Test User',
+ 'email': 'test@example.com',
+ 'admin': false,
+ 'purchases': [],
+ };
+
+ final user = UserModel.fromJson(json);
+ expect(user.telegram, isNull);
+ expect(user.email, equals('test@example.com'));
+ });
+
+ test('should support telegram without email', () {
+ final user = UserModel(
+ id: 'test-id',
+ name: 'Telegram User',
+ email: null,
+ telegram: '@telegram_only',
+ admin: false,
+ );
+
+ final json = user.toJson();
+ expect(json['telegram'], equals('@telegram_only'));
+ expect(json['email'], isNull);
+
+ final deserializedUser = UserModel.fromJson(json);
+ expect(deserializedUser.telegram, equals('@telegram_only'));
+ expect(deserializedUser.email, isNull);
+ });
+
+ test('should work with copyWith for telegram', () {
+ final user = UserModel(
+ id: 'test-id',
+ name: 'Test User',
+ email: 'test@example.com',
+ telegram: '@oldusername',
+ admin: false,
+ );
+
+ final updatedUser = user.copyWith(telegram: '@newusername');
+ expect(updatedUser.telegram, equals('@newusername'));
+ expect(updatedUser.email, equals('test@example.com'));
+ expect(user.telegram, equals('@oldusername')); // Original unchanged
+ });
+
+ test('should handle both email and telegram', () {
+ final user = UserModel(
+ id: 'test-id',
+ name: 'Test User',
+ email: 'user@example.com',
+ telegram: '@testuser',
+ admin: true,
+ purchases: ['pack1'],
+ );
+
+ expect(user.email, equals('user@example.com'));
+ expect(user.telegram, equals('@testuser'));
+ expect(user.admin, isTrue);
+
+ final json = user.toJson();
+ final deserialized = UserModel.fromJson(json);
+
+ expect(deserialized.email, equals('user@example.com'));
+ expect(deserialized.telegram, equals('@testuser'));
+ expect(deserialized.admin, isTrue);
+ });
+ });
+}
diff --git a/mnemo_cards_backend/test/packs/card_image_storage_test.dart b/mnemo_cards_backend/test/packs/card_image_storage_test.dart
new file mode 100644
index 0000000..c35af71
--- /dev/null
+++ b/mnemo_cards_backend/test/packs/card_image_storage_test.dart
@@ -0,0 +1,67 @@
+import 'dart:io';
+
+import 'package:mnemo_cards_backend/packs/card_image_storage.dart';
+import 'package:test/test.dart';
+
+void main() {
+ const oneByOnePngBase64 =
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO5n5p0AAAAASUVORK5CYII=';
+
+ group('CardImageStorage', () {
+ test('sanitizeCardsFileName strips cards/ prefix and rejects traversal', () {
+ expect(CardImageStorage.sanitizeCardsFileName('cards/a.png'), 'a.png');
+ expect(CardImageStorage.sanitizeCardsFileName('/cards/a.png'), 'a.png');
+ expect(CardImageStorage.sanitizeCardsFileName('a.png'), 'a.png');
+
+ expect(CardImageStorage.sanitizeCardsFileName('../a.png'), isNull);
+ expect(CardImageStorage.sanitizeCardsFileName('cards/../a.png'), isNull);
+ expect(CardImageStorage.sanitizeCardsFileName('cards/a/b.png'), isNull);
+ });
+
+ test('tryParseBase64Image parses data: url and detects png', () {
+ final parsed = CardImageStorage.tryParseBase64Image(
+ 'data:image/png;base64,$oneByOnePngBase64',
+ );
+
+ expect(parsed, isNotNull);
+ expect(parsed!.contentType, 'image/png');
+ expect(parsed.ext, 'png');
+ expect(parsed.bytes, isNotEmpty);
+ });
+
+ test('persistFromBase64 writes file into cards/ and resolves it', () async {
+ final tempDir = await Directory.systemTemp.createTemp('cards_assets_');
+ addTearDown(() async {
+ if (tempDir.existsSync()) {
+ await tempDir.delete(recursive: true);
+ }
+ });
+
+ const cardId = '79e268f3-243c-442d-84f5-96f15a90e296';
+
+ final stored = await CardImageStorage.persistFromBase64(
+ cardId: cardId,
+ imageValue: oneByOnePngBase64,
+ preferredFileName: null,
+ isBack: false,
+ assetsDirectory: tempDir,
+ );
+
+ expect(stored, isNotNull);
+ expect(stored!.fileName, '$cardId.png');
+
+ final resolved = await CardImageStorage.tryResolveLocalFile(
+ cardId: cardId,
+ imageValue: stored.fileName,
+ isBack: false,
+ assetsDirectory: tempDir,
+ );
+
+ expect(resolved, isNotNull);
+ expect(resolved!.fileName, stored.fileName);
+ expect(resolved.contentType, 'image/png');
+ expect(resolved.bytes, isNotEmpty);
+ });
+ });
+}
+
diff --git a/mnemo_cards_common/lib/src/dtos/user/user_dto.dart b/mnemo_cards_common/lib/src/dtos/user/user_dto.dart
index 3043a0d..6308cab 100644
--- a/mnemo_cards_common/lib/src/dtos/user/user_dto.dart
+++ b/mnemo_cards_common/lib/src/dtos/user/user_dto.dart
@@ -13,6 +13,7 @@ class UserDto {
String? id;
final String? name;
final String? email;
+ final String? telegram;
final bool admin;
final List packs;
final List purchases;
@@ -26,6 +27,7 @@ class UserDto {
this.id,
this.name,
this.email,
+ this.telegram,
this.admin = false,
this.packs = const [],
this.subscription = false,
diff --git a/mnemo_cards_common/lib/src/dtos/user/user_dto.g.dart b/mnemo_cards_common/lib/src/dtos/user/user_dto.g.dart
index 3842e74..df70622 100644
--- a/mnemo_cards_common/lib/src/dtos/user/user_dto.g.dart
+++ b/mnemo_cards_common/lib/src/dtos/user/user_dto.g.dart
@@ -13,6 +13,8 @@ abstract class _$UserDtoCWProxy {
UserDto email(String? email);
+ UserDto telegram(String? telegram);
+
UserDto admin(bool admin);
UserDto packs(List packs);
@@ -40,6 +42,7 @@ abstract class _$UserDtoCWProxy {
String? id,
String? name,
String? email,
+ String? telegram,
bool admin,
List packs,
bool? subscription,
@@ -66,6 +69,9 @@ class _$UserDtoCWProxyImpl implements _$UserDtoCWProxy {
@override
UserDto email(String? email) => call(email: email);
+ @override
+ UserDto telegram(String? telegram) => call(telegram: telegram);
+
@override
UserDto admin(bool admin) => call(admin: admin);
@@ -103,6 +109,7 @@ class _$UserDtoCWProxyImpl implements _$UserDtoCWProxy {
Object? id = const $CopyWithPlaceholder(),
Object? name = const $CopyWithPlaceholder(),
Object? email = const $CopyWithPlaceholder(),
+ Object? telegram = const $CopyWithPlaceholder(),
Object? admin = const $CopyWithPlaceholder(),
Object? packs = const $CopyWithPlaceholder(),
Object? subscription = const $CopyWithPlaceholder(),
@@ -124,6 +131,10 @@ class _$UserDtoCWProxyImpl implements _$UserDtoCWProxy {
? _value.email
// ignore: cast_nullable_to_non_nullable
: email as String?,
+ telegram: telegram == const $CopyWithPlaceholder()
+ ? _value.telegram
+ // ignore: cast_nullable_to_non_nullable
+ : telegram as String?,
admin: admin == const $CopyWithPlaceholder() || admin == null
? _value.admin
// ignore: cast_nullable_to_non_nullable
@@ -173,6 +184,7 @@ UserDto _$UserDtoFromJson(Map json) => UserDto(
id: json['id'] as String?,
name: json['name'] as String?,
email: json['email'] as String?,
+ telegram: json['telegram'] as String?,
admin: json['admin'] as bool? ?? false,
packs:
(json['packs'] as List?)?.map((e) => e as String).toList() ??
@@ -200,6 +212,7 @@ Map _$UserDtoToJson(UserDto instance) => {
'id': instance.id,
'name': instance.name,
'email': instance.email,
+ 'telegram': instance.telegram,
'admin': instance.admin,
'packs': instance.packs,
'purchases': instance.purchases,
diff --git a/mnemo_cards_common/pubspec.lock b/mnemo_cards_common/pubspec.lock
index 2f868a2..01fafb4 100644
--- a/mnemo_cards_common/pubspec.lock
+++ b/mnemo_cards_common/pubspec.lock
@@ -97,6 +97,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.3"
+ cli_config:
+ dependency: transitive
+ description:
+ name: cli_config
+ sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.2.0"
code_builder:
dependency: transitive
description:
@@ -137,6 +145,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "11.0.0"
+ coverage:
+ dependency: transitive
+ description:
+ name: coverage
+ sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.15.0"
crypto:
dependency: "direct main"
description:
@@ -177,6 +193,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
+ frontend_server_client:
+ dependency: transitive
+ description:
+ name: frontend_server_client
+ sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.0.0"
glob:
dependency: transitive
description:
@@ -265,6 +289,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.5"
+ node_preamble:
+ dependency: transitive
+ description:
+ name: node_preamble
+ sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.2"
package_config:
dependency: transitive
description:
@@ -313,6 +345,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
+ shelf_packages_handler:
+ dependency: transitive
+ description:
+ name: shelf_packages_handler
+ sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.2"
+ shelf_static:
+ dependency: transitive
+ description:
+ name: shelf_static
+ sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.3"
shelf_web_socket:
dependency: transitive
description:
@@ -337,6 +385,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.9"
+ source_map_stack_trace:
+ dependency: transitive
+ description:
+ name: source_map_stack_trace
+ sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.2"
+ source_maps:
+ dependency: transitive
+ description:
+ name: source_maps
+ sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.10.13"
source_span:
dependency: transitive
description:
@@ -385,14 +449,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.2.1"
+ test:
+ dependency: "direct dev"
+ description:
+ name: test
+ sha256: "77cc98ea27006c84e71a7356cf3daf9ddbde2d91d84f77dbfe64cf0e4d9611ae"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.28.0"
test_api:
dependency: transitive
description:
name: test_api
- sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b"
+ sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8"
url: "https://pub.dev"
source: hosted
- version: "0.6.1"
+ version: "0.7.8"
+ test_core:
+ dependency: transitive
+ description:
+ name: test_core
+ sha256: f1072617a6657e5fc09662e721307f7fb009b4ed89b19f47175d11d5254a62d4
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.6.14"
typed_data:
dependency: transitive
description:
@@ -409,6 +489,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.2"
+ vm_service:
+ dependency: transitive
+ description:
+ name: vm_service
+ sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
+ url: "https://pub.dev"
+ source: hosted
+ version: "15.0.2"
watcher:
dependency: transitive
description:
@@ -425,13 +513,21 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.0"
+ webkit_inspection_protocol:
+ dependency: transitive
+ description:
+ name: webkit_inspection_protocol
+ sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.1"
yaml:
dependency: transitive
description:
name: yaml
- sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5"
+ sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
- version: "3.1.2"
+ version: "3.1.3"
sdks:
dart: ">=3.9.0 <4.0.0"
diff --git a/mnemo_cards_common/pubspec.yaml b/mnemo_cards_common/pubspec.yaml
index bc8b015..e94e318 100644
--- a/mnemo_cards_common/pubspec.yaml
+++ b/mnemo_cards_common/pubspec.yaml
@@ -16,4 +16,5 @@ dependencies:
dev_dependencies:
build_runner: ^2.4.13
+ test: ^1.28.0
diff --git a/mnemo_cards_common_backend/lib/src/models/user/user_model.dart b/mnemo_cards_common_backend/lib/src/models/user/user_model.dart
index bdd2da5..d3da2e8 100644
--- a/mnemo_cards_common_backend/lib/src/models/user/user_model.dart
+++ b/mnemo_cards_common_backend/lib/src/models/user/user_model.dart
@@ -13,6 +13,7 @@ class UserModel {
String? id;
final String? name;
final String? email;
+ final String? telegram;
@JsonKey(defaultValue: false)
final bool admin;
// Relations - loaded separately from database
@@ -29,6 +30,7 @@ class UserModel {
this.id,
this.name,
this.email,
+ this.telegram,
this.admin = false,
this.purchases = const [],
this.userSettings,
diff --git a/mnemo_cards_common_backend/lib/src/models/user/user_model.g.dart b/mnemo_cards_common_backend/lib/src/models/user/user_model.g.dart
index 66a4b9a..bd5b588 100644
--- a/mnemo_cards_common_backend/lib/src/models/user/user_model.g.dart
+++ b/mnemo_cards_common_backend/lib/src/models/user/user_model.g.dart
@@ -13,6 +13,8 @@ abstract class _$UserModelCWProxy {
UserModel email(String? email);
+ UserModel telegram(String? telegram);
+
UserModel admin(bool admin);
UserModel purchases(List purchases);
@@ -30,6 +32,7 @@ abstract class _$UserModelCWProxy {
String? id,
String? name,
String? email,
+ String? telegram,
bool admin,
List purchases,
String? userSettings,
@@ -52,6 +55,9 @@ class _$UserModelCWProxyImpl implements _$UserModelCWProxy {
@override
UserModel email(String? email) => call(email: email);
+ @override
+ UserModel telegram(String? telegram) => call(telegram: telegram);
+
@override
UserModel admin(bool admin) => call(admin: admin);
@@ -74,6 +80,7 @@ class _$UserModelCWProxyImpl implements _$UserModelCWProxy {
Object? id = const $CopyWithPlaceholder(),
Object? name = const $CopyWithPlaceholder(),
Object? email = const $CopyWithPlaceholder(),
+ Object? telegram = const $CopyWithPlaceholder(),
Object? admin = const $CopyWithPlaceholder(),
Object? purchases = const $CopyWithPlaceholder(),
Object? userSettings = const $CopyWithPlaceholder(),
@@ -91,6 +98,10 @@ class _$UserModelCWProxyImpl implements _$UserModelCWProxy {
? _value.email
// ignore: cast_nullable_to_non_nullable
: email as String?,
+ telegram: telegram == const $CopyWithPlaceholder()
+ ? _value.telegram
+ // ignore: cast_nullable_to_non_nullable
+ : telegram as String?,
admin: admin == const $CopyWithPlaceholder() || admin == null
? _value.admin
// ignore: cast_nullable_to_non_nullable
@@ -123,6 +134,7 @@ UserModel _$UserModelFromJson(Map json) =>
id: json['id'] as String?,
name: json['name'] as String?,
email: json['email'] as String?,
+ telegram: json['telegram'] as String?,
admin: json['admin'] as bool? ?? false,
purchases:
(json['purchases'] as List?)
@@ -144,6 +156,7 @@ Map _$UserModelToJson(UserModel instance) => {
'id': instance.id,
'name': instance.name,
'email': instance.email,
+ 'telegram': instance.telegram,
'admin': instance.admin,
'userData': instance.userData,
'purchases': instance.purchases,
diff --git a/mnemo_cards_web_v2/lib/presentation/widgets/card_flipper/card_flipper.dart b/mnemo_cards_web_v2/lib/presentation/widgets/card_flipper/card_flipper.dart
index 6a37614..ad03104 100644
--- a/mnemo_cards_web_v2/lib/presentation/widgets/card_flipper/card_flipper.dart
+++ b/mnemo_cards_web_v2/lib/presentation/widgets/card_flipper/card_flipper.dart
@@ -164,10 +164,10 @@ class _CardFlipperContent extends StatelessWidget {
builder: (context, constraints) {
final breakpoint = _resolveBreakpoint(constraints);
return state.when(
- initial: () => const Center(child: CircularProgressIndicator()),
+ initial: () => const SizedBox.shrink(),
loaded: (cards, currentIndex, flippedCards, isShuffled) {
if (cards.isEmpty) {
- return const Center(child: Text('No cards available'));
+ return const Center(child: Text('Нет карточек'));
}
final currentCard = cards[currentIndex];
diff --git a/mnemo_cards_web_v2/lib/presentation/widgets/card_voice_controls.dart b/mnemo_cards_web_v2/lib/presentation/widgets/card_voice_controls.dart
index 8cb379e..86fd397 100644
--- a/mnemo_cards_web_v2/lib/presentation/widgets/card_voice_controls.dart
+++ b/mnemo_cards_web_v2/lib/presentation/widgets/card_voice_controls.dart
@@ -109,28 +109,6 @@ class _CardVoiceControlsState extends State {
return FutureBuilder>(
future: _voicesFuture,
builder: (context, snapshot) {
- if (snapshot.connectionState == ConnectionState.waiting) {
- return Padding(
- padding: const EdgeInsets.symmetric(vertical: 8),
- child: Center(
- child: SizedBox(
- height: 32,
- width: 32,
- child: CircularProgressIndicator(
- strokeWidth: 2,
- color: widget.accentColor,
- ),
- ),
- ),
- );
- }
-
- if (snapshot.hasError) {
- return _errorText(
- 'Не удалось загрузить озвучку: ${snapshot.error}',
- );
- }
-
final voices = snapshot.data ?? const [];
if (voices.isEmpty) {
return const SizedBox.shrink();