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
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:
parent
a5d1628828
commit
aa30ef516a
31 changed files with 1238 additions and 128 deletions
17
PROGRESS.md
17
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*
|
||||
|
|
|
|||
11
TODO.md
11
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*
|
||||
|
|
|
|||
|
|
@ -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('')
|
||||
})
|
||||
})
|
||||
124
mnemo_cards_admin/src/components/ui/color-palette-input.tsx
Normal file
124
mnemo_cards_admin/src/components/ui/color-palette-input.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 (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)
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
);
|
||||
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -59,6 +59,65 @@ class TestDao extends DatabaseAccessor<AppDatabase> 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<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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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!;
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
112
mnemo_cards_backend/test/models/user_model_telegram_test.dart
Normal file
112
mnemo_cards_backend/test/models/user_model_telegram_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
67
mnemo_cards_backend/test/packs/card_image_storage_test.dart
Normal file
67
mnemo_cards_backend/test/packs/card_image_storage_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -16,4 +16,5 @@ dependencies:
|
|||
|
||||
dev_dependencies:
|
||||
build_runner: ^2.4.13
|
||||
test: ^1.28.0
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue