diff --git a/PROGRESS.md b/PROGRESS.md index 7a6e0d6..c9b5634 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -36,6 +36,9 @@ - Added "Voice (will be added on Save)" to card editor and upload on Create/Update - Ensured voices list still loads while the form is disabled during save - Added unit tests for the upsert+voice flow (`mnemo_cards_admin`) +- **Admin Packs: Color Palette Picker**: Added palette-based color selection for packs + - Replaced free-text color input with swatches + native color picker + - Added unit tests for the new color picker component (`mnemo_cards_admin`) - **Service Reliability**: Enhanced service management and monitoring - Port conflict detection and resolution @@ -114,6 +117,18 @@ - **Framework**: Dart with Shelf - **Status**: Production ready - **Key Features**: API, authentication, data management +- **Recent Updates**: + - **User Telegram Field**: Added telegram field to user model + - Added `telegram` field to `UserModel` and `UserDto` alongside `email` + - Added `telegram` column to `users` table in database (migration v2→v3) + - Updated all authentication flows (Google OAuth, Telegram Web App, Telegram bot) + - Telegram usernames now stored in `telegram` field instead of `email` + - Added comprehensive unit tests for UserModel and UserDto telegram field + - Migration automatically moves telegram usernames from email to telegram field + - **Generated Tests Cleanup**: Fixed generated tests accumulating over time + - Fixed linking bug: generated tests were created in DB but not linked to packs (caused orphan tests) + - Added cron cleanup to purge orphan `version='generated'` tests and hard-delete old soft-deleted generated tests (TTL) + - Added DAO helpers and a focused unit test for the cleanup logic ### Common Libraries - **mnemo_cards_common**: Shared models and utilities @@ -183,4 +198,4 @@ --- -*Last updated: December 10, 2025* +*Last updated: December 18, 2025* diff --git a/TODO.md b/TODO.md index b532ddd..36fcff4 100644 --- a/TODO.md +++ b/TODO.md @@ -52,6 +52,10 @@ - Frontend components: Target 70% coverage - Common libraries: Target 90% coverage - ✅ Added unit test for version display on auth page + - ✅ **User Telegram Field Tests**: Added comprehensive unit tests for telegram field + - Created `test/models/user_model_telegram_test.dart` with 6 test cases + - Created `test/user_dto_telegram_test.dart` with 7 test cases + - Tests cover serialization, deserialization, null handling, and copyWith functionality - ✅ Refactored authentication components - need to add unit tests for SignInWithGoogleButton and SignInWithTelegram - ✅ Added comprehensive unit tests for ThemeToggleWidget (7 test cases covering all functionality) - ✅ Game/Test flow cleanup: removed traditional test entry, themed game page, added widget coverage for question card surfaces, exit control, and interactive-only notice; timer/progress pulled from live session state @@ -102,6 +106,8 @@ - Database query optimization - Caching layer implementation - Response time optimization + - [x] **Card Images Storage**: Stop storing base64 in DB; always store path/file name and serve via image endpoints (фикс 500 на картинках) + - [x] **Generated Tests Cleanup**: Fix orphan generated tests and add TTL purge (cron/DB cleanup) ### Features - [x] **Card Voices**: Audio metadata & playback @@ -111,6 +117,9 @@ - [x] **Admin Card Voices**: Upload voice on card Create/Update - Added voice uploader to card editor (upload on Save) - Added unit tests for upsert+voice flow +- [x] **Admin Packs: Color Palette Picker**: Add palette-based pack color selection + - Replace free-text hex input with swatches + native picker + - Add unit tests for the picker component - [ ] **User Experience**: Enhanced user experience features - Offline mode support - Progressive Web App (PWA) @@ -170,4 +179,4 @@ --- -*Last updated: December 6, 2025* +*Last updated: December 18, 2025* diff --git a/mnemo_cards_admin/src/components/ui/color-palette-input.test.tsx b/mnemo_cards_admin/src/components/ui/color-palette-input.test.tsx new file mode 100644 index 0000000..cf53923 --- /dev/null +++ b/mnemo_cards_admin/src/components/ui/color-palette-input.test.tsx @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' + +import { ColorPaletteInput } from '@/components/ui/color-palette-input' + +describe('ColorPaletteInput', () => { + it('renders current value in text input', () => { + render( + <> + + undefined} + /> + , + ) + + expect(screen.getByLabelText('Color')).toHaveValue('#FF0000') + }) + + it('calls onChange when a swatch is clicked', () => { + const onChange = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Set color #ABCDEF' })) + + expect(onChange).toHaveBeenCalledWith('#ABCDEF') + }) + + it('calls onChange when native picker changes', () => { + const onChange = vi.fn() + + render( + , + ) + + fireEvent.change(screen.getByLabelText('Pick a color'), { + target: { value: '#00ff00' }, + }) + + expect(onChange).toHaveBeenCalledWith('#00ff00') + }) + + it('can clear value', () => { + const onChange = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Clear' })) + + expect(onChange).toHaveBeenCalledWith('') + }) +}) diff --git a/mnemo_cards_admin/src/components/ui/color-palette-input.tsx b/mnemo_cards_admin/src/components/ui/color-palette-input.tsx new file mode 100644 index 0000000..2ac4087 --- /dev/null +++ b/mnemo_cards_admin/src/components/ui/color-palette-input.tsx @@ -0,0 +1,124 @@ +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' + +const DEFAULT_COLOR_PALETTE = [ + '#EF4444', // red-500 + '#F97316', // orange-500 + '#F59E0B', // amber-500 + '#EAB308', // yellow-500 + '#22C55E', // green-500 + '#10B981', // emerald-500 + '#14B8A6', // teal-500 + '#06B6D4', // cyan-500 + '#3B82F6', // blue-500 + '#6366F1', // indigo-500 + '#A855F7', // purple-500 + '#EC4899', // pink-500 + '#6B7280', // gray-500 +] as const + +const isHexColor = (value: string): boolean => { + const trimmed = value.trim() + return /^#(?:[0-9a-fA-F]{3}){1,2}$/.test(trimmed) +} + +export type ColorPaletteInputProps = { + id: string + value: string + onChange: (value: string) => void + disabled?: boolean + palette?: readonly string[] + placeholder?: string +} + +export const ColorPaletteInput = ({ + id, + value, + onChange, + disabled = false, + palette = DEFAULT_COLOR_PALETTE, + placeholder = '#FF0000', +}: ColorPaletteInputProps) => { + const trimmed = value.trim() + const hasValidHex = trimmed.length > 0 && isHexColor(trimmed) + + // Native color input requires a valid 7-char hex (#RRGGBB) + const nativePickerValue = hasValidHex && trimmed.length === 7 ? trimmed : '#000000' + + return ( +
+
+ 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 ( + + + {trimmed.length > 0 && !hasValidHex && ( + Invalid hex color + )} +
+
+ ) +} 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();