fixes and stuff
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Mobile App CI / test (push) Waiting to run
Mobile App CI / build-android (push) Blocked by required conditions
Mobile App CI / build-ios (push) Blocked by required conditions
Web App CI / test (push) Waiting to run
Web App CI / build (push) Blocked by required conditions
Deploy Admin Panel / Deploy Admin Panel (push) Waiting to run
Deploy Admin Panel / Admin Panel Verification (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
Deploy Telegram Bot / Deploy Telegram Bot (push) Waiting to run

This commit is contained in:
Dmitry 2025-12-18 23:40:48 +03:00
parent a5d1628828
commit aa30ef516a
31 changed files with 1238 additions and 128 deletions

View file

@ -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*

11
TODO.md
View file

@ -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*

View file

@ -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(
<>
<label htmlFor="color">Color</label>
<ColorPaletteInput
id="color"
value="#FF0000"
onChange={() => undefined}
/>
</>,
)
expect(screen.getByLabelText('Color')).toHaveValue('#FF0000')
})
it('calls onChange when a swatch is clicked', () => {
const onChange = vi.fn()
render(
<ColorPaletteInput
id="color"
value=""
onChange={onChange}
palette={['#123456', '#ABCDEF']}
/>,
)
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(
<ColorPaletteInput
id="color"
value="#000000"
onChange={onChange}
/>,
)
fireEvent.change(screen.getByLabelText('Pick a color'), {
target: { value: '#00ff00' },
})
expect(onChange).toHaveBeenCalledWith('#00ff00')
})
it('can clear value', () => {
const onChange = vi.fn()
render(
<ColorPaletteInput
id="color"
value="#123456"
onChange={onChange}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Clear' }))
expect(onChange).toHaveBeenCalledWith('')
})
})

View file

@ -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 (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Input
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
aria-invalid={trimmed.length > 0 && !hasValidHex}
/>
<div className="flex items-center gap-2">
<input
aria-label="Pick a color"
type="color"
value={nativePickerValue}
onChange={(e) => 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',
)}
/>
<div
aria-label={hasValidHex ? `Selected color ${trimmed}` : 'Selected color preview'}
className={cn(
'h-10 w-10 rounded-md border border-input',
trimmed.length > 0 && !hasValidHex && 'border-dashed border-red-500',
)}
style={hasValidHex ? { backgroundColor: trimmed } : undefined}
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{palette.map((color) => {
const isSelected = trimmed.toLowerCase() === color.toLowerCase()
return (
<button
key={color}
type="button"
aria-label={`Set color ${color}`}
disabled={disabled}
onClick={() => onChange(color)}
className={cn(
'h-8 w-8 rounded-md border border-input transition-shadow',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
disabled && 'cursor-not-allowed opacity-50',
isSelected && 'ring-2 ring-ring ring-offset-2',
)}
style={{ backgroundColor: color }}
/>
)
})}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onChange('')}
disabled={disabled || value.length === 0}
>
Clear
</Button>
{trimmed.length > 0 && !hasValidHex && (
<span className="text-xs text-red-500">Invalid hex color</span>
)}
</div>
</div>
)
}

View file

@ -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() {
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="color">Color</Label>
<Input
<ColorPaletteInput
id="color"
value={formData.color}
onChange={(e) => setFormData(prev => ({ ...prev, color: e.target.value }))}
onChange={(value) => setFormData(prev => ({ ...prev, color: value }))}
disabled={createMutation.isPending || updateMutation.isPending}
placeholder="#FF0000"
/>
</div>

View file

@ -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,
},
});

View file

@ -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<String> _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<Response> _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/<cardId>/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 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 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)

View file

@ -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<String?> _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'),
);
@ -48,6 +49,28 @@ class AdminTestsApiV2 {
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) {
print('Error converting base64 to card: $e');

View file

@ -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<String, String> 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,
);

View file

@ -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
},
);

View file

@ -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();

View file

@ -60,6 +60,65 @@ class TestDao extends DatabaseAccessor<AppDatabase> with _$TestDaoMixin {
));
}
/// 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<int> 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<int> 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<void> linkTestToPack(String testId, String packId) async {
await into(testPackRelations).insert(

View file

@ -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<void> _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;
}
}
}

View file

@ -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<String> telegram = GeneratedColumn<String>(
'telegram',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
static const VerificationMeta _adminMeta = const VerificationMeta('admin');
@override
late final GeneratedColumn<bool> admin = GeneratedColumn<bool>(
@ -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<User> {
final String externalUserId;
final String? name;
final String? email;
final String? telegram;
final bool admin;
final String? userSettings;
final List<String> purchases;
@ -282,6 +305,7 @@ class User extends DataClass implements Insertable<User> {
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<User> {
if (!nullToAbsent || email != null) {
map['email'] = Variable<String>(email);
}
if (!nullToAbsent || telegram != null) {
map['telegram'] = Variable<String>(telegram);
}
map['admin'] = Variable<bool>(admin);
if (!nullToAbsent || userSettings != null) {
map['user_settings'] = Variable<String>(userSettings);
@ -329,6 +356,9 @@ class User extends DataClass implements Insertable<User> {
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<User> {
externalUserId: serializer.fromJson<String>(json['externalUserId']),
name: serializer.fromJson<String?>(json['name']),
email: serializer.fromJson<String?>(json['email']),
telegram: serializer.fromJson<String?>(json['telegram']),
admin: serializer.fromJson<bool>(json['admin']),
userSettings: serializer.fromJson<String?>(json['userSettings']),
purchases: serializer.fromJson<List<String>>(json['purchases']),
@ -366,6 +397,7 @@ class User extends DataClass implements Insertable<User> {
'externalUserId': serializer.toJson<String>(externalUserId),
'name': serializer.toJson<String?>(name),
'email': serializer.toJson<String?>(email),
'telegram': serializer.toJson<String?>(telegram),
'admin': serializer.toJson<bool>(admin),
'userSettings': serializer.toJson<String?>(userSettings),
'purchases': serializer.toJson<List<String>>(purchases),
@ -380,6 +412,7 @@ class User extends DataClass implements Insertable<User> {
String? externalUserId,
Value<String?> name = const Value.absent(),
Value<String?> email = const Value.absent(),
Value<String?> telegram = const Value.absent(),
bool? admin,
Value<String?> userSettings = const Value.absent(),
List<String>? purchases,
@ -391,6 +424,7 @@ class User extends DataClass implements Insertable<User> {
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<User> {
: 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<User> {
..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<User> {
externalUserId,
name,
email,
telegram,
admin,
userSettings,
purchases,
@ -455,6 +492,7 @@ class User extends DataClass implements Insertable<User> {
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<User> {
final Value<String> externalUserId;
final Value<String?> name;
final Value<String?> email;
final Value<String?> telegram;
final Value<bool> admin;
final Value<String?> userSettings;
final Value<List<String>> purchases;
@ -480,6 +519,7 @@ class UsersCompanion extends UpdateCompanion<User> {
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<User> {
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<User> {
Expression<String>? externalUserId,
Expression<String>? name,
Expression<String>? email,
Expression<String>? telegram,
Expression<bool>? admin,
Expression<String>? userSettings,
Expression<String>? purchases,
@ -519,6 +561,7 @@ class UsersCompanion extends UpdateCompanion<User> {
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<User> {
Value<String>? externalUserId,
Value<String?>? name,
Value<String?>? email,
Value<String?>? telegram,
Value<bool>? admin,
Value<String?>? userSettings,
Value<List<String>>? purchases,
@ -547,6 +591,7 @@ class UsersCompanion extends UpdateCompanion<User> {
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<User> {
if (email.present) {
map['email'] = Variable<String>(email.value);
}
if (telegram.present) {
map['telegram'] = Variable<String>(telegram.value);
}
if (admin.present) {
map['admin'] = Variable<bool>(admin.value);
}
@ -611,6 +659,7 @@ class UsersCompanion extends UpdateCompanion<User> {
..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<String?> name,
Value<String?> email,
Value<String?> telegram,
Value<bool> admin,
Value<String?> userSettings,
Value<List<String>> purchases,
@ -19726,6 +19776,7 @@ typedef $$UsersTableUpdateCompanionBuilder =
Value<String> externalUserId,
Value<String?> name,
Value<String?> email,
Value<String?> telegram,
Value<bool> admin,
Value<String?> userSettings,
Value<List<String>> purchases,
@ -20029,6 +20080,11 @@ class $$UsersTableFilterComposer extends Composer<_$AppDatabase, $UsersTable> {
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get telegram => $composableBuilder(
column: $table.telegram,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<bool> get admin => $composableBuilder(
column: $table.admin,
builder: (column) => ColumnFilters(column),
@ -20415,6 +20471,11 @@ class $$UsersTableOrderingComposer
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get telegram => $composableBuilder(
column: $table.telegram,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<bool> get admin => $composableBuilder(
column: $table.admin,
builder: (column) => ColumnOrderings(column),
@ -20469,6 +20530,9 @@ class $$UsersTableAnnotationComposer
GeneratedColumn<String> get email =>
$composableBuilder(column: $table.email, builder: (column) => column);
GeneratedColumn<String> get telegram =>
$composableBuilder(column: $table.telegram, builder: (column) => column);
GeneratedColumn<bool> get admin =>
$composableBuilder(column: $table.admin, builder: (column) => column);
@ -20864,6 +20928,7 @@ class $$UsersTableTableManager
Value<String> externalUserId = const Value.absent(),
Value<String?> name = const Value.absent(),
Value<String?> email = const Value.absent(),
Value<String?> telegram = const Value.absent(),
Value<bool> admin = const Value.absent(),
Value<String?> userSettings = const Value.absent(),
Value<List<String>> 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<String?> name = const Value.absent(),
Value<String?> email = const Value.absent(),
Value<String?> telegram = const Value.absent(),
Value<bool> admin = const Value.absent(),
Value<String?> userSettings = const Value.absent(),
Value<List<String>> purchases = const Value.absent(),
@ -20902,6 +20969,7 @@ class $$UsersTableTableManager
externalUserId: externalUserId,
name: name,
email: email,
telegram: telegram,
admin: admin,
userSettings: userSettings,
purchases: purchases,

View file

@ -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

View file

@ -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<void> addTest(TestDto testDto) async {
Future<String> 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!;
}
}

View file

@ -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),

View file

@ -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);
}

View file

@ -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);

View file

@ -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,

View file

@ -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();
});
});
}

View file

@ -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': <String>[],
};
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);
});
});
}

View file

@ -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);
});
});
}

View file

@ -13,6 +13,7 @@ class UserDto {
String? id;
final String? name;
final String? email;
final String? telegram;
final bool admin;
final List<String> packs;
final List<String> purchases;
@ -26,6 +27,7 @@ class UserDto {
this.id,
this.name,
this.email,
this.telegram,
this.admin = false,
this.packs = const [],
this.subscription = false,

View file

@ -13,6 +13,8 @@ abstract class _$UserDtoCWProxy {
UserDto email(String? email);
UserDto telegram(String? telegram);
UserDto admin(bool admin);
UserDto packs(List<String> packs);
@ -40,6 +42,7 @@ abstract class _$UserDtoCWProxy {
String? id,
String? name,
String? email,
String? telegram,
bool admin,
List<String> 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<String, dynamic> 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<dynamic>?)?.map((e) => e as String).toList() ??
@ -200,6 +212,7 @@ Map<String, dynamic> _$UserDtoToJson(UserDto instance) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'email': instance.email,
'telegram': instance.telegram,
'admin': instance.admin,
'packs': instance.packs,
'purchases': instance.purchases,

View file

@ -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"

View file

@ -16,4 +16,5 @@ dependencies:
dev_dependencies:
build_runner: ^2.4.13
test: ^1.28.0

View file

@ -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,

View file

@ -13,6 +13,8 @@ abstract class _$UserModelCWProxy {
UserModel email(String? email);
UserModel telegram(String? telegram);
UserModel admin(bool admin);
UserModel purchases(List<String> purchases);
@ -30,6 +32,7 @@ abstract class _$UserModelCWProxy {
String? id,
String? name,
String? email,
String? telegram,
bool admin,
List<String> 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<String, dynamic> 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<dynamic>?)
@ -144,6 +156,7 @@ Map<String, dynamic> _$UserModelToJson(UserModel instance) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'email': instance.email,
'telegram': instance.telegram,
'admin': instance.admin,
'userData': instance.userData,
'purchases': instance.purchases,

View file

@ -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];

View file

@ -109,28 +109,6 @@ class _CardVoiceControlsState extends State<CardVoiceControls> {
return FutureBuilder<List<VoiceDto>>(
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 <VoiceDto>[];
if (voices.isEmpty) {
return const SizedBox.shrink();