From ef5d7318e796d0e2c3cb5ef8153b6e4d538bf2e2 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Mon, 15 Dec 2025 00:10:06 +0300 Subject: [PATCH] backend and admin --- mnemo_cards_admin/src/App.tsx | 2 + mnemo_cards_admin/src/api/tests.ts | 131 +++++ .../src/components/CardVoicesManager.tsx | 94 +++- .../src/components/layout/Layout.tsx | 2 + .../src/components/ui/audio-upload.tsx | 99 +++- mnemo_cards_admin/src/pages/TestsPage.tsx | 491 ++++++++++++++++++ mnemo_cards_admin/src/types/models.ts | 19 + mnemo_cards_backend/README.md | 8 +- .../lib/api/v2/admin_cards_api_v2.dart | 163 +++++- .../lib/api/v2/telegram_bot_api_v2.dart | 4 +- .../lib/api/v2/users_api_v2.dart | 36 +- .../lib/database/daos/audit_dao.dart | 10 +- .../lib/database/daos/discount_dao.dart | 13 +- .../daos/mixins/soft_delete_mixin.dart | 48 +- .../lib/database/daos/pack_dao.dart | 14 + .../lib/database/daos/payment_dao.dart | 10 - .../lib/database/daos/promo_code_dao.dart | 4 +- .../lib/database/daos/test_dao.dart | 2 +- .../lib/database/daos/user_dao.dart | 27 +- .../database/daos/word_statistics_dao.dart | 14 +- .../lib/database/database.g.dart | 92 ++-- .../lib/database/tables/audit.dart | 2 +- .../lib/packs/card_pack_drift_extension.dart | 5 +- .../lib/statistics/statistics_calculator.dart | 10 +- .../statistics/word_statistics_manager.dart | 2 +- 25 files changed, 1071 insertions(+), 231 deletions(-) create mode 100644 mnemo_cards_admin/src/api/tests.ts create mode 100644 mnemo_cards_admin/src/pages/TestsPage.tsx diff --git a/mnemo_cards_admin/src/App.tsx b/mnemo_cards_admin/src/App.tsx index 3f0329b..2c0d0e0 100644 --- a/mnemo_cards_admin/src/App.tsx +++ b/mnemo_cards_admin/src/App.tsx @@ -5,6 +5,7 @@ import DashboardPage from '@/pages/DashboardPage' import CardsPage from '@/pages/CardsPage' import PacksPage from '@/pages/PacksPage' import UsersPage from '@/pages/UsersPage' +import TestsPage from '@/pages/TestsPage' import Layout from '@/components/layout/Layout' function App() { @@ -27,6 +28,7 @@ function App() { } /> } /> } /> + } /> ) : ( diff --git a/mnemo_cards_admin/src/api/tests.ts b/mnemo_cards_admin/src/api/tests.ts new file mode 100644 index 0000000..cf5f99e --- /dev/null +++ b/mnemo_cards_admin/src/api/tests.ts @@ -0,0 +1,131 @@ +import { adminApiClient } from './client' +import type { TestDto, PaginatedResponse } from '@/types/models' +import type { AxiosError } from 'axios' + +export interface TestsApiError { + message: string + statusCode?: number + field?: string + originalError?: unknown + name: 'TestsApiError' +} + +export function createTestsApiError( + message: string, + statusCode?: number, + field?: string, + originalError?: unknown +): TestsApiError { + return { + message, + statusCode, + field, + originalError, + name: 'TestsApiError', + } +} + +export function isTestsApiError(error: unknown): error is TestsApiError { + return ( + typeof error === 'object' && + error !== null && + 'name' in error && + error.name === 'TestsApiError' + ) +} + +export const testsApi = { + // Get all tests with pagination and search + getTests: async (params?: { + page?: number + limit?: number + search?: string + }): Promise> => { + try { + const response = await adminApiClient.get('/api/v2/admin/tests', { + params: { + page: params?.page || 1, + limit: params?.limit || 20, + search: params?.search, + }, + }) + return response.data + } catch (error) { + const axiosError = error as AxiosError<{ error?: string; message?: string; field?: string }> + throw createTestsApiError( + axiosError.response?.data?.message || + axiosError.response?.data?.error || + 'Failed to load tests', + axiosError.response?.status, + axiosError.response?.data?.field, + error + ) + } + }, + + // Get a specific test by ID + getTest: async (testId: string): Promise => { + try { + const response = await adminApiClient.get(`/api/v2/admin/tests/${testId}`) + return response.data + } catch (error) { + const axiosError = error as AxiosError<{ error?: string; message?: string }> + throw createTestsApiError( + axiosError.response?.data?.message || + axiosError.response?.data?.error || + `Failed to load test ${testId}`, + axiosError.response?.status, + undefined, + error + ) + } + }, + + // Create or update a test + upsertTest: async (test: TestDto): Promise<{ success: boolean; test: TestDto }> => { + try { + const response = await adminApiClient.post('/api/v2/admin/tests', test) + return response.data + } catch (error) { + const axiosError = error as AxiosError<{ error?: string; message?: string; field?: string; details?: string }> + const isUpdate = test.id && test.id.length > 0 + const operation = isUpdate ? 'update test' : 'create test' + const message = axiosError.response?.data?.message || + axiosError.response?.data?.error || + `Failed to ${operation}` + + let fullMessage = message + if (axiosError.response?.data?.details) { + fullMessage += `. ${axiosError.response.data.details}` + } + if (axiosError.response?.data?.field) { + fullMessage += ` (Field: ${axiosError.response.data.field})` + } + + throw createTestsApiError( + fullMessage, + axiosError.response?.status, + axiosError.response?.data?.field, + error + ) + } + }, + + // Delete a test by ID + deleteTest: async (testId: string): Promise<{ success: boolean; message: string }> => { + try { + const response = await adminApiClient.delete(`/api/v2/admin/tests/${testId}`) + return response.data + } catch (error) { + const axiosError = error as AxiosError<{ error?: string; message?: string }> + throw createTestsApiError( + axiosError.response?.data?.message || + axiosError.response?.data?.error || + `Failed to delete test ${testId}`, + axiosError.response?.status, + undefined, + error + ) + } + }, +} diff --git a/mnemo_cards_admin/src/components/CardVoicesManager.tsx b/mnemo_cards_admin/src/components/CardVoicesManager.tsx index 750b3a5..8451f7d 100644 --- a/mnemo_cards_admin/src/components/CardVoicesManager.tsx +++ b/mnemo_cards_admin/src/components/CardVoicesManager.tsx @@ -1,4 +1,4 @@ -import { useState, useRef } from 'react' +import { useState, useRef, useEffect } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' import { voicesApi, type VoiceDto } from '@/api/voices' @@ -6,7 +6,7 @@ import { AudioUpload } from '@/components/ui/audio-upload' import { Button } from '@/components/ui/button' import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' -import { X, Plus, Music } from 'lucide-react' +import { X, Plus, Music, Play, Pause } from 'lucide-react' import type { AxiosError } from 'axios' interface CardVoicesManagerProps { @@ -169,53 +169,104 @@ function VoiceItem({ voice, onRemove, disabled }: VoiceItemProps) { const handlePlayPause = () => { if (!audioRef.current) { - const audio = new Audio(`data:audio/mpeg;base64,${voice.voiceUrl}`) - audioRef.current = audio - audio.onended = () => { + try { + const audio = new Audio(`data:audio/mpeg;base64,${voice.voiceUrl}`) + audioRef.current = audio + + audio.onended = () => { + setIsPlaying(false) + audioRef.current = null + } + + audio.onerror = (e) => { + console.error('Audio playback error:', e) + toast.error('Failed to play audio. The file may be corrupted or in an unsupported format.') + setIsPlaying(false) + audioRef.current = null + } + + audio.onloadstart = () => { + setIsPlaying(true) + } + + audio.play().catch((error) => { + console.error('Audio play error:', error) + toast.error('Failed to play audio. Please check your browser audio settings.') + setIsPlaying(false) + audioRef.current = null + }) + } catch (error) { + console.error('Error creating audio element:', error) + toast.error('Failed to initialize audio player') setIsPlaying(false) - audioRef.current = null } - audio.onerror = () => { - alert('Failed to play audio') - setIsPlaying(false) - audioRef.current = null - } - audio.play() - setIsPlaying(true) } else { if (isPlaying) { audioRef.current.pause() setIsPlaying(false) } else { - audioRef.current.play() + audioRef.current.play().catch((error) => { + console.error('Audio play error:', error) + toast.error('Failed to resume audio playback') + }) setIsPlaying(true) } } } + // Cleanup on unmount + useEffect(() => { + return () => { + if (audioRef.current) { + audioRef.current.pause() + audioRef.current = null + } + } + }, []) + return ( -
-
- -
+
+
+ +
Voice {voice.language} + {isPlaying && ( + + Playing + + )}

Added {new Date(voice.createdAt).toLocaleDateString()}

-
+
diff --git a/mnemo_cards_admin/src/components/layout/Layout.tsx b/mnemo_cards_admin/src/components/layout/Layout.tsx index 08a71e1..73db32c 100644 --- a/mnemo_cards_admin/src/components/layout/Layout.tsx +++ b/mnemo_cards_admin/src/components/layout/Layout.tsx @@ -7,6 +7,7 @@ import { FileText, Package, Users, + ClipboardList, LogOut, Menu, X @@ -21,6 +22,7 @@ const navigation = [ { name: 'Dashboard', href: '/', icon: LayoutDashboard }, { name: 'Cards', href: '/cards', icon: FileText }, { name: 'Packs', href: '/packs', icon: Package }, + { name: 'Tests', href: '/tests', icon: ClipboardList }, { name: 'Users', href: '/users', icon: Users }, ] diff --git a/mnemo_cards_admin/src/components/ui/audio-upload.tsx b/mnemo_cards_admin/src/components/ui/audio-upload.tsx index ce88d69..d7135cc 100644 --- a/mnemo_cards_admin/src/components/ui/audio-upload.tsx +++ b/mnemo_cards_admin/src/components/ui/audio-upload.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react' +import { useRef, useState, useEffect } from 'react' import { Button } from './button' import { Label } from './label' import { Input } from './input' @@ -110,29 +110,63 @@ export function AudioUpload({ } } + // Cleanup audio on unmount or value change + useEffect(() => { + return () => { + if (audioRef.current) { + audioRef.current.pause() + audioRef.current = null + } + setIsPlaying(false) + } + }, [value]) + const handlePlayPause = () => { if (!value) return if (!audioRef.current) { - const audio = new Audio(`data:audio/mpeg;base64,${value}`) - audioRef.current = audio - audio.onended = () => { + try { + const audio = new Audio(`data:audio/mpeg;base64,${value}`) + audioRef.current = audio + + audio.onended = () => { + setIsPlaying(false) + audioRef.current = null + } + + audio.onerror = (e) => { + console.error('Audio playback error:', e) + alert('Failed to play audio. The file may be corrupted or in an unsupported format.') + setIsPlaying(false) + audioRef.current = null + } + + audio.onloadstart = () => { + setIsPlaying(true) + } + + audio.play().catch((error) => { + console.error('Audio play error:', error) + alert('Failed to play audio. Please check your browser audio settings.') + setIsPlaying(false) + audioRef.current = null + }) + } catch (error) { + console.error('Error creating audio element:', error) + alert('Failed to initialize audio player') setIsPlaying(false) - audioRef.current = null } - audio.onerror = () => { - alert('Failed to play audio') - setIsPlaying(false) - audioRef.current = null - } - } - - if (isPlaying) { - audioRef.current?.pause() - setIsPlaying(false) } else { - audioRef.current?.play() - setIsPlaying(true) + if (isPlaying) { + audioRef.current.pause() + setIsPlaying(false) + } else { + audioRef.current.play().catch((error) => { + console.error('Audio play error:', error) + alert('Failed to resume audio playback') + }) + setIsPlaying(true) + } } } @@ -142,23 +176,39 @@ export function AudioUpload({ {value ? (
-
-
+
+
- - Audio file loaded + +
+ Audio file loaded + {isPlaying && ( + Playing... + )} +
diff --git a/mnemo_cards_admin/src/pages/TestsPage.tsx b/mnemo_cards_admin/src/pages/TestsPage.tsx new file mode 100644 index 0000000..60a86f5 --- /dev/null +++ b/mnemo_cards_admin/src/pages/TestsPage.tsx @@ -0,0 +1,491 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' +import { testsApi, isTestsApiError } from '@/api/tests' +import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils' +import type { TestDto, PaginatedResponse } from '@/types/models' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { ImageUpload } from '@/components/ui/image-upload' +import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react' + +export default function TestsPage() { + const queryClient = useQueryClient() + const [page, setPage] = useState(1) + const [search, setSearch] = useState('') + const [selectedTest, setSelectedTest] = useState(null) + const [isDialogOpen, setIsDialogOpen] = useState(false) + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) + const [testToDelete, setTestToDelete] = useState(null) + + // Form state + const [formData, setFormData] = useState({ + name: '', + color: '', + cover: undefined as string | undefined, + version: '', + time: '', + timeSubtitle: '', + questions: [] as TestDto['questions'], + }) + + const limit = 20 + + // Fetch tests + const { data, isLoading, error } = useQuery>({ + queryKey: ['tests', page, search], + queryFn: () => testsApi.getTests({ page, limit, search }), + retry: (failureCount, error) => { + // Don't retry on client errors (4xx) + if (isTestsApiError(error) && error.statusCode && error.statusCode >= 400 && error.statusCode < 500) { + return false + } + return failureCount < 2 + }, + }) + + // Mutations + const createMutation = useMutation({ + mutationFn: (test: TestDto) => testsApi.upsertTest(test), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['tests'] }) + toast.success('Test created successfully') + closeDialog() + }, + onError: (error: unknown) => { + const errorMessage = isTestsApiError(error) + ? error.message + : getDetailedErrorMessage(error, 'create', 'test') + toast.error(errorMessage) + console.error('Error creating test:', error) + }, + }) + + const updateMutation = useMutation({ + mutationFn: (test: TestDto) => testsApi.upsertTest(test), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['tests'] }) + toast.success('Test updated successfully') + closeDialog() + }, + onError: (error: unknown) => { + const errorMessage = isTestsApiError(error) + ? error.message + : getDetailedErrorMessage(error, 'update', 'test') + toast.error(errorMessage) + console.error('Error updating test:', error) + }, + }) + + const deleteMutation = useMutation({ + mutationFn: (testId: string) => testsApi.deleteTest(testId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['tests'] }) + toast.success('Test deleted successfully') + setIsDeleteDialogOpen(false) + setTestToDelete(null) + }, + onError: (error: unknown) => { + const errorMessage = isTestsApiError(error) + ? error.message + : getDetailedErrorMessage(error, 'delete', 'test') + toast.error(errorMessage) + console.error('Error deleting test:', error) + }, + }) + + const openCreateDialog = () => { + setSelectedTest(null) + setFormData({ + name: '', + color: '', + cover: undefined, + version: '', + time: '', + timeSubtitle: '', + questions: [], + }) + setIsDialogOpen(true) + } + + const openEditDialog = async (test: TestDto) => { + try { + // Load full test data if we only have preview + const fullTest = test.id ? await testsApi.getTest(test.id) : test + setSelectedTest(fullTest) + setFormData({ + name: fullTest.name || '', + color: fullTest.color || '', + cover: fullTest.cover, + version: fullTest.version || '', + time: fullTest.time || '', + timeSubtitle: fullTest.timeSubtitle || '', + questions: fullTest.questions || [], + }) + setIsDialogOpen(true) + } catch (error) { + const errorMessage = isTestsApiError(error) + ? error.message + : getDetailedErrorMessage(error, 'load', `test "${test.id}"`) + toast.error(errorMessage) + console.error('Error loading test details:', error) + } + } + + const closeDialog = () => { + setIsDialogOpen(false) + setSelectedTest(null) + } + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + + if (!formData.name.trim()) { + toast.error('Name is required') + return + } + + const testData: TestDto = { + id: selectedTest?.id, + name: formData.name.trim(), + color: formData.color.trim() || undefined, + cover: formData.cover || undefined, + version: formData.version.trim() || undefined, + time: formData.time.trim() || undefined, + timeSubtitle: formData.timeSubtitle.trim() || undefined, + questions: formData.questions, + } + + if (selectedTest) { + updateMutation.mutate(testData) + } else { + createMutation.mutate(testData) + } + } + + const handleDelete = (test: TestDto) => { + if (!test.id) { + toast.error('Test ID is required for deletion') + return + } + setTestToDelete(test) + setIsDeleteDialogOpen(true) + } + + const confirmDelete = () => { + if (testToDelete?.id) { + deleteMutation.mutate(testToDelete.id) + } + } + + const handleSearch = (value: string) => { + setSearch(value) + setPage(1) // Reset to first page when searching + } + + if (error) { + const errorMessage = isTestsApiError(error) + ? error.message + : formatApiError(error) + return ( +
+
+

Tests Management

+

Error loading tests

+
+ + +
+

Failed to load tests

+

{errorMessage}

+ +
+
+
+
+ ) + } + + return ( +
+
+
+

Tests Management

+

+ View, create, edit and delete game tests +

+
+ +
+ + {/* Search */} + + +
+ + handleSearch(e.target.value)} + className="max-w-sm" + /> +
+
+
+ + {/* Tests Table */} + + + All Tests ({data?.total || 0}) + + Manage game tests in the system + + + + {isLoading ? ( +
Loading tests...
+ ) : ( + <> + + + + ID + Name + Questions + Version + Time + Actions + + + + {data?.items.map((test) => ( + + {test.id || 'N/A'} + {test.name} + {test.questions?.length || 0} + {test.version || 'N/A'} + {test.time || 'N/A'} + +
+ + +
+
+
+ ))} +
+
+ + {/* Pagination */} + {data && data.totalPages > 1 && ( +
+
+ Showing {((page - 1) * limit) + 1} to {Math.min(page * limit, data.total)} of {data.total} tests +
+
+ + + Page {page} of {data.totalPages} + + +
+
+ )} + + )} +
+
+ + {/* Create/Edit Dialog */} + + + + + {selectedTest ? 'Edit Test' : 'Create New Test'} + + + {selectedTest ? 'Update the test information' : 'Add a new test to the system'} + + +
+
+
+ + setFormData(prev => ({ ...prev, name: e.target.value }))} + placeholder="Test name" + required + /> +
+ +
+
+ + setFormData(prev => ({ ...prev, color: e.target.value }))} + placeholder="#FF0000" + /> +
+
+ + setFormData(prev => ({ ...prev, version: e.target.value }))} + placeholder="1.0.0" + /> +
+
+ +
+
+ + setFormData(prev => ({ ...prev, time: e.target.value }))} + placeholder="e.g. 5 min" + /> +
+
+ + setFormData(prev => ({ ...prev, timeSubtitle: e.target.value }))} + placeholder="e.g. per question" + /> +
+
+ +
+ setFormData(prev => ({ ...prev, cover: value }))} + disabled={createMutation.isPending || updateMutation.isPending} + /> +
+ +
+ +
+

+ {formData.questions.length} question(s) in this test +

+ {formData.questions.length === 0 && ( +

+ Questions are managed separately. After creating the test, you can add questions through the backend API. +

+ )} +
+
+
+ + + + +
+
+
+ + {/* Delete Confirmation Dialog */} + + + + Delete Test + + Are you sure you want to delete the test "{testToDelete?.name}"? This action cannot be undone. + + + + Cancel + + {deleteMutation.isPending ? 'Deleting...' : 'Delete'} + + + + +
+ ) +} diff --git a/mnemo_cards_admin/src/types/models.ts b/mnemo_cards_admin/src/types/models.ts index 27de49a..4a2fdfa 100644 --- a/mnemo_cards_admin/src/types/models.ts +++ b/mnemo_cards_admin/src/types/models.ts @@ -157,3 +157,22 @@ export interface CodeStatusResponse { error?: string message?: string } + +// Test types +export interface TestQuestion { + id?: string + questionType: string + body: unknown // JSON structure varies by question type +} + +export interface TestDto { + id?: string + name: string + color?: string + cover?: string + version?: string + time?: string + timeSubtitle?: string + questions: TestQuestion[] + statistics?: unknown +} diff --git a/mnemo_cards_backend/README.md b/mnemo_cards_backend/README.md index f8c9663..0348848 100644 --- a/mnemo_cards_backend/README.md +++ b/mnemo_cards_backend/README.md @@ -217,11 +217,16 @@ mnemo_cards_backend/ │ │ │ ├── packs.dart │ │ │ ├── auth.dart │ │ │ ├── payments.dart +│ │ │ ├── word_statistics.dart # Статистика ответов на карточки +│ │ │ ├── audit.dart # Audit trail (инфраструктура) │ │ │ └── ... │ │ └── daos/ # Data Access Objects │ │ ├── user_dao.dart │ │ ├── pack_dao.dart -│ │ └── ... +│ │ ├── word_statistics_dao.dart # Работа со статистикой слов +│ │ ├── audit_dao.dart # Audit logging +│ │ └── mixins/ +│ │ └── soft_delete_mixin.dart # Soft delete функциональность │ │ │ ├── user/ # User management │ │ └── user_manager.dart @@ -245,6 +250,7 @@ mnemo_cards_backend/ │ ├── statistics/ # User statistics │ │ ├── session_tracker.dart │ │ ├── statistics_calculator.dart +│ │ ├── word_statistics_manager.dart # Управление статистикой ответов │ │ └── session_tracking_middleware.dart │ │ │ ├── cron/ # Background jobs diff --git a/mnemo_cards_backend/lib/api/v2/admin_cards_api_v2.dart b/mnemo_cards_backend/lib/api/v2/admin_cards_api_v2.dart index cf290c3..a1e59dd 100644 --- a/mnemo_cards_backend/lib/api/v2/admin_cards_api_v2.dart +++ b/mnemo_cards_backend/lib/api/v2/admin_cards_api_v2.dart @@ -33,12 +33,12 @@ class AdminCardsApiV2 { /// Get all cards with pagination and search @Route.get('/admin/cards') Future getAllCards(Request request) async { + final auth = await _ensureAdmin(request); + if (auth.statusCode != 200) { + return auth; + } + try { - final auth = await _ensureAdmin(request); - if (auth.statusCode != 200) { - return auth; - } - final queryParams = request.url.queryParameters; // Parse pagination parameters @@ -91,24 +91,29 @@ class AdminCardsApiV2 { final offset = (page - 1) * limit; final paginatedCards = filteredCards.skip(offset).take(limit).toList(); + // Получить паки для всех карточек (для packId) + final cardsWithPacks = >[]; + for (final card in paginatedCards) { + final packs = await _db.packDao.getPacksForCard(card.id); + final packId = packs.isNotEmpty ? packs.first.id : null; + final cardId = int.tryParse(card.id) ?? 0; + cardsWithPacks.add({ + 'id': cardId, + 'packId': packId, + 'original': card.original, + 'translation': card.translation, + 'mnemo': card.mnemo, + 'image': card.image, + 'back': card.back, + 'transcription': card.transcription, + 'transcriptionMnemo': card.transcriptionMnemo, + 'imageBack': card.imageBack, + }); + } + return Response.ok( json.encode({ - 'items': paginatedCards.map((card) { - // Try to parse ID as int, fallback to 0 if it's not a number - final cardId = int.tryParse(card.id) ?? 0; - return { - 'id': cardId, - 'packId': card.packId, - 'original': card.original, - 'translation': card.translation, - 'mnemo': card.mnemo, - 'image': card.image, - 'back': card.back, - 'transcription': card.transcription, - 'transcriptionMnemo': card.transcriptionMnemo, - 'imageBack': card.imageBack, - }; - }).toList(), + 'items': cardsWithPacks, 'total': total, 'page': page, 'limit': limit, @@ -158,10 +163,14 @@ class AdminCardsApiV2 { ); } + // Получить паки для карточки + final packs = await _db.packDao.getPacksForCard(card.id); + final packId = packs.isNotEmpty ? packs.first.id : null; + return Response.ok( json.encode({ 'id': card.id, - 'packId': card.packId, + 'packId': packId, 'original': card.original, 'translation': card.translation, 'mnemo': card.mnemo, @@ -255,12 +264,16 @@ class AdminCardsApiV2 { ); await _db.packDao.updateCard(updated); final cardIdInt = int.tryParse(updated.id) ?? 0; + // Получить паки для карточки + final packs = await _db.packDao.getPacksForCard(updated.id); + final packId = packs.isNotEmpty ? packs.first.id : null; + return Response.ok( json.encode({ 'success': true, 'card': { 'id': cardIdInt, - 'packId': updated.packId, + 'packId': packId, 'original': updated.original, 'translation': updated.translation, 'mnemo': updated.mnemo, @@ -275,9 +288,8 @@ class AdminCardsApiV2 { ); } - // Create new card + // Create new card (packId больше нет в GameCards) final companion = GameCardsCompanion.insert( - packId: data['packId'], original: data['original'] as String, translation: data['translation'] as String, image: data['image'] as String? ?? '', @@ -289,6 +301,13 @@ class AdminCardsApiV2 { ); final cardId = await _db.packDao.createCard(companion); + + // Если передан packId, создать связь через CardPackCards + if (data['packId'] != null) { + final packId = data['packId'] as String; + await _db.packDao.addCardToPack(cardId, packId); + } + final created = await _db.packDao.getCardById(cardId); if (created == null) { return Response.internalServerError( @@ -302,13 +321,17 @@ class AdminCardsApiV2 { ); } + // Получить паки для карточки + final packs = await _db.packDao.getPacksForCard(cardId); + final packId = packs.isNotEmpty ? packs.first.id : null; + final cardIdInt = int.tryParse(created.id) ?? 0; return Response.ok( json.encode({ 'success': true, 'card': { 'id': cardIdInt, - 'packId': created.packId, + 'packId': packId, 'original': created.original, 'translation': created.translation, 'mnemo': created.mnemo, @@ -575,6 +598,65 @@ class AdminCardsApiV2 { ); } + // Validate base64 format (basic check) + try { + // Remove data URL prefix if present (data:audio/...;base64,) + final base64String = voiceUrl.contains(',') + ? voiceUrl.split(',').last + : voiceUrl; + + // Basic base64 validation - check if it's valid base64 + if (base64String.isEmpty) { + return Response.badRequest( + body: json.encode({ + 'error': 'Validation error', + 'message': 'Invalid base64 audio data', + 'field': 'voiceUrl', + 'details': 'The provided voice URL does not contain valid base64 encoded audio data.', + }), + headers: {'Content-Type': 'application/json'}, + ); + } + + // Check base64 characters (basic validation) + final base64Regex = RegExp(r'^[A-Za-z0-9+/]*={0,2}$'); + if (!base64Regex.hasMatch(base64String)) { + return Response.badRequest( + body: json.encode({ + 'error': 'Validation error', + 'message': 'Invalid base64 format', + 'field': 'voiceUrl', + 'details': 'The provided voice URL does not appear to be valid base64 encoded data.', + }), + headers: {'Content-Type': 'application/json'}, + ); + } + + // Check size (base64 is ~33% larger than original, so 10MB audio = ~13.3MB base64) + // Limit to ~15MB base64 string (roughly 11MB audio) + if (base64String.length > 15 * 1024 * 1024) { + return Response.badRequest( + body: json.encode({ + 'error': 'Validation error', + 'message': 'Audio file too large', + 'field': 'voiceUrl', + 'details': 'The audio file is too large. Maximum size is approximately 10MB for the original audio file.', + }), + headers: {'Content-Type': 'application/json'}, + ); + } + } catch (e) { + return Response.badRequest( + body: json.encode({ + 'error': 'Validation error', + 'message': 'Invalid voice URL format', + 'field': 'voiceUrl', + 'details': 'Failed to validate the voice URL. Please ensure it is a valid base64 encoded audio file.', + }), + headers: {'Content-Type': 'application/json'}, + ); + } + // Verify card exists final card = await _db.packDao.getCardById(cardId); if (card == null) { @@ -659,6 +741,19 @@ class AdminCardsApiV2 { ); } + // Verify card exists + final card = await _db.packDao.getCardById(cardId); + if (card == null) { + return Response.notFound( + body: json.encode({ + 'error': 'Card not found', + 'message': 'The card you are trying to remove a voice from does not exist', + 'details': 'Card with ID "$cardId" was not found. Cannot remove voice from a non-existent card.', + }), + headers: {'Content-Type': 'application/json'}, + ); + } + // Verify voice exists final voice = await _db.packDao.getVoiceById(voiceId); if (voice == null) { @@ -672,10 +767,24 @@ class AdminCardsApiV2 { ); } + // Verify voice belongs to this card (check CardVoices relation) + final cardVoices = await _db.packDao.getCardVoices(cardId); + final voiceBelongsToCard = cardVoices.any((v) => v.id == voiceId); + if (!voiceBelongsToCard) { + return Response.badRequest( + body: json.encode({ + 'error': 'Voice not associated with card', + 'message': 'The voice is not associated with this card', + 'details': 'Voice with ID "$voiceId" is not linked to card "$cardId". Cannot remove a voice that is not associated with this card.', + }), + headers: {'Content-Type': 'application/json'}, + ); + } + // Remove voice from card first (removes the relation) await _db.packDao.removeVoiceFromCard(cardId, voiceId); - // Delete voice model + // Delete voice model (cascade will handle CardVoices relations) await _db.packDao.deleteVoice(voiceId); return Response.ok( diff --git a/mnemo_cards_backend/lib/api/v2/telegram_bot_api_v2.dart b/mnemo_cards_backend/lib/api/v2/telegram_bot_api_v2.dart index 9284880..d37e872 100644 --- a/mnemo_cards_backend/lib/api/v2/telegram_bot_api_v2.dart +++ b/mnemo_cards_backend/lib/api/v2/telegram_bot_api_v2.dart @@ -80,7 +80,9 @@ class TelegramBotApiV2 { 'transcriptionMnemo': card.transcriptionMnemo, 'imageBack': card.imageBack, 'back': card.back, - 'packId': card.packId, + 'packId': (await _db.packDao.getPacksForCard(card.id)).isNotEmpty + ? (await _db.packDao.getPacksForCard(card.id)).first.id + : null, }); } catch (e, s) { print('Error getting random card: $e\n$s'); diff --git a/mnemo_cards_backend/lib/api/v2/users_api_v2.dart b/mnemo_cards_backend/lib/api/v2/users_api_v2.dart index ef7253c..1aee2a1 100644 --- a/mnemo_cards_backend/lib/api/v2/users_api_v2.dart +++ b/mnemo_cards_backend/lib/api/v2/users_api_v2.dart @@ -77,19 +77,23 @@ class UsersApiV2 { // Получить статистику по словам из WordStatistics final wordStats = await _db.wordStatisticsDao.getUserStatistics(user.id!); - final wordsDto = AllWordsStatisticsDto( - words: wordStats.map((stat) { - // Найти карточку для получения слова - // Для простоты используем cardId как word (в будущем можно улучшить) - return WordStatisticsDto( - word: stat.cardId, // Временное решение - нужно получить original из GameCard + final wordsList = []; + + // Для каждой статистики получить карточку и извлечь original + for (final stat in wordStats) { + final card = await _db.packDao.getCardById(stat.cardId); + if (card != null) { + wordsList.add(WordStatisticsDto( + word: card.original, correct: stat.correctAnswers.toDouble(), incorrect: stat.incorrectAnswers.toDouble(), skipped: 0, questionTypes: {}, - ); - }).toList(), - ); + )); + } + } + + final wordsDto = AllWordsStatisticsDto(words: wordsList); final dto = await user.toDtoWithCalculatedData( packProgress: packProgress, @@ -466,18 +470,8 @@ class UsersApiV2 { toDate = DateTime.tryParse(to); } - final userData = user.userData; - if (userData == null) { - return _json({ - 'period': period, - 'totalDays': 0, - 'activeDays': 0, - 'totalMinutes': 0, - 'averageDailyMinutes': 0.0, - 'currentStreak': 0, - 'dailyActivity': {}, - 'studyDates': [], - }); + if (user.id == null) { + return _json({'error': 'user_id_not_found'}, statusCode: 400); } // Рассчитать timeline statistics (теперь принимает userId вместо UserDataModel) diff --git a/mnemo_cards_backend/lib/database/daos/audit_dao.dart b/mnemo_cards_backend/lib/database/daos/audit_dao.dart index b7d4324..21816d3 100644 --- a/mnemo_cards_backend/lib/database/daos/audit_dao.dart +++ b/mnemo_cards_backend/lib/database/daos/audit_dao.dart @@ -14,7 +14,7 @@ class AuditDao extends DatabaseAccessor with _$AuditDaoMixin { /// ⚠️ ВАЖНО: Пока не используется в коде. Инфраструктура создана для будущего использования. /// /// Параметры: - /// - tableName: имя таблицы (например, 'Users', 'Payments') + /// - table: имя таблицы (например, 'Users', 'Payments') /// - recordId: ID записи, которая изменилась /// - action: тип операции ('INSERT', 'UPDATE', 'DELETE', 'SOFT_DELETE', 'RESTORE') /// - userId: ID пользователя, который сделал изменение (null для системных операций) @@ -23,7 +23,7 @@ class AuditDao extends DatabaseAccessor with _$AuditDaoMixin { /// - ipAddress: IP адрес пользователя /// - userAgent: User-Agent браузера Future log({ - required String tableName, + required String table, required String recordId, required String action, String? userId, @@ -34,7 +34,7 @@ class AuditDao extends DatabaseAccessor with _$AuditDaoMixin { }) async { await into(auditLogs).insert( AuditLogsCompanion.insert( - tableName: tableName, + table: table, recordId: recordId, action: action, userId: Value(userId), @@ -50,12 +50,12 @@ class AuditDao extends DatabaseAccessor with _$AuditDaoMixin { /// /// Возвращает список записей audit log, отсортированных по дате создания (новые первыми) Future> getLogsByRecord({ - required String tableName, + required String table, required String recordId, int? limit, }) { final query = select(auditLogs) - ..where((a) => a.tableName.equals(tableName) & a.recordId.equals(recordId)) + ..where((a) => a.table.equals(table) & a.recordId.equals(recordId)) ..orderBy([(a) => OrderingTerm.desc(a.createdAt)]); if (limit != null) { diff --git a/mnemo_cards_backend/lib/database/daos/discount_dao.dart b/mnemo_cards_backend/lib/database/daos/discount_dao.dart index 7dfaa71..619ec79 100644 --- a/mnemo_cards_backend/lib/database/daos/discount_dao.dart +++ b/mnemo_cards_backend/lib/database/daos/discount_dao.dart @@ -75,16 +75,21 @@ class DiscountDao extends DatabaseAccessor with _$DiscountDaoMixin } /// Удалить кампанию (soft delete) - Future softDeleteCampaign(String campaignId) { - return (update(db.discountCampaigns) + Future deleteCampaign(String campaignId) async { + await (update(db.discountCampaigns) ..where((c) => c.id.equals(campaignId)) ).write(DiscountCampaignsCompanion( isDeleted: const Value(true), - deletedAt: Value(DateTime.now()), + deletedAt: Value(PgDateTime(DateTime.now())), updatedAt: Value(PgDateTime(DateTime.now())), )); } + /// Удалить кампанию (soft delete) - алиас для совместимости + Future softDeleteCampaign(String campaignId) { + return deleteCampaign(campaignId); + } + // ==================== Discounts ==================== /// Получить скидку по ID (только активные) @@ -107,7 +112,7 @@ class DiscountDao extends DatabaseAccessor with _$DiscountDaoMixin return (update(db.discounts)..where((d) => d.id.equals(discountId))) .write(DiscountsCompanion( isDeleted: const Value(true), - deletedAt: Value(DateTime.now()), + deletedAt: Value(PgDateTime(DateTime.now())), updatedAt: Value(PgDateTime(DateTime.now())), )); } diff --git a/mnemo_cards_backend/lib/database/daos/mixins/soft_delete_mixin.dart b/mnemo_cards_backend/lib/database/daos/mixins/soft_delete_mixin.dart index e2ca335..f2253ad 100644 --- a/mnemo_cards_backend/lib/database/daos/mixins/soft_delete_mixin.dart +++ b/mnemo_cards_backend/lib/database/daos/mixins/soft_delete_mixin.dart @@ -19,33 +19,14 @@ import 'package:mnemo_cards_backend/database/database.dart'; /// TableInfo get table => payments; /// } /// ``` +/// +/// Примечание: Методы softDelete() и restore() не реализованы в миксине, +/// так как требуют создания Companion объектов, которые специфичны для каждой таблицы. +/// Реализуйте эти методы в каждом DAO вручную. mixin SoftDeleteMixin on DatabaseAccessor { /// Таблица, с которой работает DAO TableInfo get table; - /// Мягкое удаление записи по ID - /// - /// Устанавливает isDeleted = true и deletedAt = текущее время. - /// Возвращает true если запись была обновлена. - Future softDelete(String id) async { - final now = DateTime.now(); - - // Получаем динамический доступ к полям через reflection - final updated = await (update(table) - ..where((t) { - final idColumn = (t as dynamic).id; - return idColumn.equals(id); - })) - .write( - table.companion( - isDeleted: const Value(true), - deletedAt: Value(now), - updatedAt: Value(now), // если есть поле updatedAt - ) as UpdateCompanion, - ); - return updated > 0; - } - /// Получить только активные (не удаленные) записи /// /// Используйте этот метод вместо select(table) когда нужно @@ -69,25 +50,4 @@ mixin SoftDeleteMixin on DatabaseAccessor { })) .getSingleOrNull(); } - - /// Восстановить удаленную запись - /// - /// Устанавливает isDeleted = false и deletedAt = null. - /// Возвращает true если запись была обновлена. - Future restore(String id) async { - final now = DateTime.now(); - final updated = await (update(table) - ..where((t) { - final idColumn = (t as dynamic).id; - return idColumn.equals(id); - })) - .write( - table.companion( - isDeleted: const Value(false), - deletedAt: const Value(null), - updatedAt: Value(now), - ) as UpdateCompanion, - ); - return updated > 0; - } } diff --git a/mnemo_cards_backend/lib/database/daos/pack_dao.dart b/mnemo_cards_backend/lib/database/daos/pack_dao.dart index 52010cd..c012e11 100644 --- a/mnemo_cards_backend/lib/database/daos/pack_dao.dart +++ b/mnemo_cards_backend/lib/database/daos/pack_dao.dart @@ -93,6 +93,20 @@ class PackDao extends DatabaseAccessor with _$PackDaoMixin { return (select(gameCards)..where((c) => c.id.equals(id))).getSingleOrNull(); } + /// Получить паки для карточки + /// Связь через CardPackCards (packId удален из GameCards) + Future> getPacksForCard(String cardId) async { + final query = select(cardPacks).join([ + innerJoin( + cardPackCards, + cardPackCards.packId.equalsExp(cardPacks.id), + ), + ])..where(cardPackCards.cardId.equals(cardId) & cardPacks.isDeleted.equals(false)); + + final rows = await query.get(); + return rows.map((row) => row.readTable(cardPacks)).toList(); + } + /// Получить все карточки пака /// Связь теперь только через CardPackCards (packId удален из GameCards) Future> getPackCards(String packId) async { diff --git a/mnemo_cards_backend/lib/database/daos/payment_dao.dart b/mnemo_cards_backend/lib/database/daos/payment_dao.dart index 668f46f..0ad849b 100644 --- a/mnemo_cards_backend/lib/database/daos/payment_dao.dart +++ b/mnemo_cards_backend/lib/database/daos/payment_dao.dart @@ -92,16 +92,6 @@ class PaymentDao extends DatabaseAccessor )); } - /// Подсчитать платежи пользователя - Future countPaymentsByUserId(String userId) async { - final countExpr = payments.id.count(); - final query = selectOnly(payments) - ..addColumns([countExpr]) - ..where(payments.userId.equals(userId)); - - return await query.map((row) => row.read(countExpr)!).getSingle(); - } - /// Получить платеж по externalToken (только активные) Future getPaymentByExternalToken(String token) { return (selectActive()..where((p) => p.externalToken.equals(token))) diff --git a/mnemo_cards_backend/lib/database/daos/promo_code_dao.dart b/mnemo_cards_backend/lib/database/daos/promo_code_dao.dart index 58d018b..2238deb 100644 --- a/mnemo_cards_backend/lib/database/daos/promo_code_dao.dart +++ b/mnemo_cards_backend/lib/database/daos/promo_code_dao.dart @@ -122,7 +122,7 @@ class PromoCodeDao extends DatabaseAccessor with _$PromoCodeDaoMixi return (update(db.promoCodes)..where((pc) => pc.id.equals(promoCodeId))) .write(PromoCodesCompanion( isDeleted: const Value(true), - deletedAt: Value(DateTime.now()), + deletedAt: Value(PgDateTime(DateTime.now())), updatedAt: Value(PgDateTime(DateTime.now())), )); } @@ -132,7 +132,7 @@ class PromoCodeDao extends DatabaseAccessor with _$PromoCodeDaoMixi return (update(db.promoCodesCampaigns)..where((c) => c.id.equals(campaignId))) .write(PromoCodesCampaignsCompanion( isDeleted: const Value(true), - deletedAt: Value(DateTime.now()), + deletedAt: Value(PgDateTime(DateTime.now())), updatedAt: Value(PgDateTime(DateTime.now())), )); } diff --git a/mnemo_cards_backend/lib/database/daos/test_dao.dart b/mnemo_cards_backend/lib/database/daos/test_dao.dart index 7ac73bb..8497a1f 100644 --- a/mnemo_cards_backend/lib/database/daos/test_dao.dart +++ b/mnemo_cards_backend/lib/database/daos/test_dao.dart @@ -96,7 +96,7 @@ class TestDao extends DatabaseAccessor with _$TestDaoMixin { return (update(testQuestions)..where((tq) => tq.id.equals(questionId))) .write(TestQuestionsCompanion( isDeleted: const Value(true), - deletedAt: Value(DateTime.now()), + deletedAt: Value(PgDateTime(DateTime.now())), updatedAt: Value(PgDateTime(DateTime.now())), )); } diff --git a/mnemo_cards_backend/lib/database/daos/user_dao.dart b/mnemo_cards_backend/lib/database/daos/user_dao.dart index 7cab002..3a612e4 100644 --- a/mnemo_cards_backend/lib/database/daos/user_dao.dart +++ b/mnemo_cards_backend/lib/database/daos/user_dao.dart @@ -202,12 +202,17 @@ class UserDao extends DatabaseAccessor with _$UserDaoMixin { return inserted.id; } + /// Удалить токен (hard delete) + Future deleteToken(String token) async { + await (delete(tokens)..where((t) => t.token.equals(token))).go(); + } + /// Удалить токен (soft delete) Future softDeleteToken(String tokenId) { return (update(tokens)..where((t) => t.id.equals(tokenId))) .write(TokensCompanion( isDeleted: const Value(true), - deletedAt: Value(DateTime.now()), + deletedAt: Value(PgDateTime(DateTime.now())), )); } @@ -216,15 +221,15 @@ class UserDao extends DatabaseAccessor with _$UserDaoMixin { return (update(tokens)..where((t) => t.token.equals(tokenValue))) .write(TokensCompanion( isDeleted: const Value(true), - deletedAt: Value(DateTime.now()), + deletedAt: Value(PgDateTime(DateTime.now())), )); } /// Удалить истекшие токены (soft delete) Future softDeleteExpiredTokens() { - final now = DateTime.now(); + final now = PgDateTime(DateTime.now()); return (update(tokens) - ..where((t) => t.expires.isSmallerThanValue(PgDateTime(now)) & t.isDeleted.equals(false)) + ..where((t) => t.expires.isSmallerThanValue(now) & t.isDeleted.equals(false)) ).write(TokensCompanion( isDeleted: const Value(true), deletedAt: Value(now), @@ -272,11 +277,19 @@ class UserDao extends DatabaseAccessor with _$UserDaoMixin { )); } + /// Удалить истекшие refresh токены (hard delete) + Future deleteExpiredRefreshTokens() async { + final now = PgDateTime(DateTime.now()); + await (delete(refreshTokens) + ..where((rt) => rt.expiresAt.isSmallerThanValue(now)) + ).go(); + } + /// Удалить истекшие refresh токены (soft delete) Future softDeleteExpiredRefreshTokens() { - final now = DateTime.now(); + final now = PgDateTime(DateTime.now()); return (update(refreshTokens) - ..where((rt) => rt.expiresAt.isSmallerThanValue(PgDateTime(now)) & rt.isDeleted.equals(false)) + ..where((rt) => rt.expiresAt.isSmallerThanValue(now) & rt.isDeleted.equals(false)) ).write(RefreshTokensCompanion( isDeleted: const Value(true), deletedAt: Value(now), @@ -297,7 +310,7 @@ class UserDao extends DatabaseAccessor with _$UserDaoMixin { return (update(db.telegramAuthCodes)..where((ac) => ac.id.equals(codeId))) .write(TelegramAuthCodesCompanion( isDeleted: const Value(true), - deletedAt: Value(DateTime.now()), + deletedAt: Value(PgDateTime(DateTime.now())), )); } diff --git a/mnemo_cards_backend/lib/database/daos/word_statistics_dao.dart b/mnemo_cards_backend/lib/database/daos/word_statistics_dao.dart index a1528e8..1ce4dbf 100644 --- a/mnemo_cards_backend/lib/database/daos/word_statistics_dao.dart +++ b/mnemo_cards_backend/lib/database/daos/word_statistics_dao.dart @@ -39,10 +39,10 @@ class WordStatisticsDao extends DatabaseAccessor final companion = WordStatisticsCompanion.insert( userId: userId, cardId: cardId, - correctAnswers: correctAnswers, - incorrectAnswers: incorrectAnswers, - mastery: mastery, - lastReviewed: Value(DateTime.now()), + correctAnswers: Value(correctAnswers), + incorrectAnswers: Value(incorrectAnswers), + mastery: Value(mastery), + lastReviewed: Value(PgDateTime(DateTime.now())), ); final id = await into(wordStatistics).insertReturning(companion); @@ -52,7 +52,7 @@ class WordStatisticsDao extends DatabaseAccessor /// Обновить статистику /// /// Автоматически пересчитывает mastery на основе новых значений - Future update({ + Future updateStatistics({ required String id, required int correctAnswers, required int incorrectAnswers, @@ -67,8 +67,8 @@ class WordStatisticsDao extends DatabaseAccessor correctAnswers: Value(correctAnswers), incorrectAnswers: Value(incorrectAnswers), mastery: Value(mastery), - lastReviewed: Value(lastReviewed), - updatedAt: Value(DateTime.now()), + lastReviewed: Value(PgDateTime(lastReviewed)), + updatedAt: Value(PgDateTime(DateTime.now())), ), ); } diff --git a/mnemo_cards_backend/lib/database/database.g.dart b/mnemo_cards_backend/lib/database/database.g.dart index a1ac612..fed8c95 100644 --- a/mnemo_cards_backend/lib/database/database.g.dart +++ b/mnemo_cards_backend/lib/database/database.g.dart @@ -18263,12 +18263,10 @@ class $AuditLogsTable extends AuditLogs requiredDuringInsert: false, defaultValue: const CustomExpression('gen_random_uuid()::text'), ); - static const VerificationMeta _tableNameMeta = const VerificationMeta( - 'tableName', - ); + static const VerificationMeta _tableMeta = const VerificationMeta('table'); @override - late final GeneratedColumn tableName = GeneratedColumn( - 'table_name', + late final GeneratedColumn table = GeneratedColumn( + 'table', aliasedName, false, type: DriftSqlType.string, @@ -18363,7 +18361,7 @@ class $AuditLogsTable extends AuditLogs @override List get $columns => [ id, - tableName, + table, recordId, action, userId, @@ -18388,13 +18386,13 @@ class $AuditLogsTable extends AuditLogs if (data.containsKey('id')) { context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); } - if (data.containsKey('table_name')) { + if (data.containsKey('table')) { context.handle( - _tableNameMeta, - tableName.isAcceptableOrUnknown(data['table_name']!, _tableNameMeta), + _tableMeta, + table.isAcceptableOrUnknown(data['table']!, _tableMeta), ); } else if (isInserting) { - context.missing(_tableNameMeta); + context.missing(_tableMeta); } if (data.containsKey('record_id')) { context.handle( @@ -18461,9 +18459,9 @@ class $AuditLogsTable extends AuditLogs DriftSqlType.string, data['${effectivePrefix}id'], )!, - tableName: attachedDatabase.typeMapping.read( + table: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}table_name'], + data['${effectivePrefix}table'], )!, recordId: attachedDatabase.typeMapping.read( DriftSqlType.string, @@ -18508,7 +18506,7 @@ class $AuditLogsTable extends AuditLogs class AuditLog extends DataClass implements Insertable { final String id; - final String tableName; + final String table; final String recordId; final String action; final String? userId; @@ -18519,7 +18517,7 @@ class AuditLog extends DataClass implements Insertable { final PgDateTime createdAt; const AuditLog({ required this.id, - required this.tableName, + required this.table, required this.recordId, required this.action, this.userId, @@ -18533,7 +18531,7 @@ class AuditLog extends DataClass implements Insertable { Map toColumns(bool nullToAbsent) { final map = {}; map['id'] = Variable(id); - map['table_name'] = Variable(tableName); + map['table'] = Variable(table); map['record_id'] = Variable(recordId); map['action'] = Variable(action); if (!nullToAbsent || userId != null) { @@ -18561,7 +18559,7 @@ class AuditLog extends DataClass implements Insertable { AuditLogsCompanion toCompanion(bool nullToAbsent) { return AuditLogsCompanion( id: Value(id), - tableName: Value(tableName), + table: Value(table), recordId: Value(recordId), action: Value(action), userId: userId == null && nullToAbsent @@ -18590,7 +18588,7 @@ class AuditLog extends DataClass implements Insertable { serializer ??= driftRuntimeOptions.defaultSerializer; return AuditLog( id: serializer.fromJson(json['id']), - tableName: serializer.fromJson(json['tableName']), + table: serializer.fromJson(json['table']), recordId: serializer.fromJson(json['recordId']), action: serializer.fromJson(json['action']), userId: serializer.fromJson(json['userId']), @@ -18606,7 +18604,7 @@ class AuditLog extends DataClass implements Insertable { serializer ??= driftRuntimeOptions.defaultSerializer; return { 'id': serializer.toJson(id), - 'tableName': serializer.toJson(tableName), + 'table': serializer.toJson(table), 'recordId': serializer.toJson(recordId), 'action': serializer.toJson(action), 'userId': serializer.toJson(userId), @@ -18620,7 +18618,7 @@ class AuditLog extends DataClass implements Insertable { AuditLog copyWith({ String? id, - String? tableName, + String? table, String? recordId, String? action, Value userId = const Value.absent(), @@ -18631,7 +18629,7 @@ class AuditLog extends DataClass implements Insertable { PgDateTime? createdAt, }) => AuditLog( id: id ?? this.id, - tableName: tableName ?? this.tableName, + table: table ?? this.table, recordId: recordId ?? this.recordId, action: action ?? this.action, userId: userId.present ? userId.value : this.userId, @@ -18644,7 +18642,7 @@ class AuditLog extends DataClass implements Insertable { AuditLog copyWithCompanion(AuditLogsCompanion data) { return AuditLog( id: data.id.present ? data.id.value : this.id, - tableName: data.tableName.present ? data.tableName.value : this.tableName, + table: data.table.present ? data.table.value : this.table, recordId: data.recordId.present ? data.recordId.value : this.recordId, action: data.action.present ? data.action.value : this.action, userId: data.userId.present ? data.userId.value : this.userId, @@ -18660,7 +18658,7 @@ class AuditLog extends DataClass implements Insertable { String toString() { return (StringBuffer('AuditLog(') ..write('id: $id, ') - ..write('tableName: $tableName, ') + ..write('table: $table, ') ..write('recordId: $recordId, ') ..write('action: $action, ') ..write('userId: $userId, ') @@ -18676,7 +18674,7 @@ class AuditLog extends DataClass implements Insertable { @override int get hashCode => Object.hash( id, - tableName, + table, recordId, action, userId, @@ -18691,7 +18689,7 @@ class AuditLog extends DataClass implements Insertable { identical(this, other) || (other is AuditLog && other.id == this.id && - other.tableName == this.tableName && + other.table == this.table && other.recordId == this.recordId && other.action == this.action && other.userId == this.userId && @@ -18704,7 +18702,7 @@ class AuditLog extends DataClass implements Insertable { class AuditLogsCompanion extends UpdateCompanion { final Value id; - final Value tableName; + final Value table; final Value recordId; final Value action; final Value userId; @@ -18716,7 +18714,7 @@ class AuditLogsCompanion extends UpdateCompanion { final Value rowid; const AuditLogsCompanion({ this.id = const Value.absent(), - this.tableName = const Value.absent(), + this.table = const Value.absent(), this.recordId = const Value.absent(), this.action = const Value.absent(), this.userId = const Value.absent(), @@ -18729,7 +18727,7 @@ class AuditLogsCompanion extends UpdateCompanion { }); AuditLogsCompanion.insert({ this.id = const Value.absent(), - required String tableName, + required String table, required String recordId, required String action, this.userId = const Value.absent(), @@ -18739,12 +18737,12 @@ class AuditLogsCompanion extends UpdateCompanion { this.userAgent = const Value.absent(), this.createdAt = const Value.absent(), this.rowid = const Value.absent(), - }) : tableName = Value(tableName), + }) : table = Value(table), recordId = Value(recordId), action = Value(action); static Insertable custom({ Expression? id, - Expression? tableName, + Expression? table, Expression? recordId, Expression? action, Expression? userId, @@ -18757,7 +18755,7 @@ class AuditLogsCompanion extends UpdateCompanion { }) { return RawValuesInsertable({ if (id != null) 'id': id, - if (tableName != null) 'table_name': tableName, + if (table != null) 'table': table, if (recordId != null) 'record_id': recordId, if (action != null) 'action': action, if (userId != null) 'user_id': userId, @@ -18772,7 +18770,7 @@ class AuditLogsCompanion extends UpdateCompanion { AuditLogsCompanion copyWith({ Value? id, - Value? tableName, + Value? table, Value? recordId, Value? action, Value? userId, @@ -18785,7 +18783,7 @@ class AuditLogsCompanion extends UpdateCompanion { }) { return AuditLogsCompanion( id: id ?? this.id, - tableName: tableName ?? this.tableName, + table: table ?? this.table, recordId: recordId ?? this.recordId, action: action ?? this.action, userId: userId ?? this.userId, @@ -18804,8 +18802,8 @@ class AuditLogsCompanion extends UpdateCompanion { if (id.present) { map['id'] = Variable(id.value); } - if (tableName.present) { - map['table_name'] = Variable(tableName.value); + if (table.present) { + map['table'] = Variable(table.value); } if (recordId.present) { map['record_id'] = Variable(recordId.value); @@ -18844,7 +18842,7 @@ class AuditLogsCompanion extends UpdateCompanion { String toString() { return (StringBuffer('AuditLogsCompanion(') ..write('id: $id, ') - ..write('tableName: $tableName, ') + ..write('table: $table, ') ..write('recordId: $recordId, ') ..write('action: $action, ') ..write('userId: $userId, ') @@ -34197,7 +34195,7 @@ typedef $$ShareRequestsTableProcessedTableManager = typedef $$AuditLogsTableCreateCompanionBuilder = AuditLogsCompanion Function({ Value id, - required String tableName, + required String table, required String recordId, required String action, Value userId, @@ -34211,7 +34209,7 @@ typedef $$AuditLogsTableCreateCompanionBuilder = typedef $$AuditLogsTableUpdateCompanionBuilder = AuditLogsCompanion Function({ Value id, - Value tableName, + Value table, Value recordId, Value action, Value userId, @@ -34237,8 +34235,8 @@ class $$AuditLogsTableFilterComposer builder: (column) => ColumnFilters(column), ); - ColumnFilters get tableName => $composableBuilder( - column: $table.tableName, + ColumnFilters get table => $composableBuilder( + column: $table.table, builder: (column) => ColumnFilters(column), ); @@ -34297,8 +34295,8 @@ class $$AuditLogsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get tableName => $composableBuilder( - column: $table.tableName, + ColumnOrderings get table => $composableBuilder( + column: $table.table, builder: (column) => ColumnOrderings(column), ); @@ -34355,8 +34353,8 @@ class $$AuditLogsTableAnnotationComposer GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); - GeneratedColumn get tableName => - $composableBuilder(column: $table.tableName, builder: (column) => column); + GeneratedColumn get table => + $composableBuilder(column: $table.table, builder: (column) => column); GeneratedColumn get recordId => $composableBuilder(column: $table.recordId, builder: (column) => column); @@ -34412,7 +34410,7 @@ class $$AuditLogsTableTableManager updateCompanionCallback: ({ Value id = const Value.absent(), - Value tableName = const Value.absent(), + Value table = const Value.absent(), Value recordId = const Value.absent(), Value action = const Value.absent(), Value userId = const Value.absent(), @@ -34424,7 +34422,7 @@ class $$AuditLogsTableTableManager Value rowid = const Value.absent(), }) => AuditLogsCompanion( id: id, - tableName: tableName, + table: table, recordId: recordId, action: action, userId: userId, @@ -34438,7 +34436,7 @@ class $$AuditLogsTableTableManager createCompanionCallback: ({ Value id = const Value.absent(), - required String tableName, + required String table, required String recordId, required String action, Value userId = const Value.absent(), @@ -34450,7 +34448,7 @@ class $$AuditLogsTableTableManager Value rowid = const Value.absent(), }) => AuditLogsCompanion.insert( id: id, - tableName: tableName, + table: table, recordId: recordId, action: action, userId: userId, diff --git a/mnemo_cards_backend/lib/database/tables/audit.dart b/mnemo_cards_backend/lib/database/tables/audit.dart index cb12ca1..36382a7 100644 --- a/mnemo_cards_backend/lib/database/tables/audit.dart +++ b/mnemo_cards_backend/lib/database/tables/audit.dart @@ -18,7 +18,7 @@ class AuditLogs extends Table { // Какая таблица и запись изменена // например: 'Users', 'Payments' - TextColumn get tableName => text()(); + TextColumn get table => text()(); // ID записи, которая изменилась TextColumn get recordId => text()(); diff --git a/mnemo_cards_backend/lib/packs/card_pack_drift_extension.dart b/mnemo_cards_backend/lib/packs/card_pack_drift_extension.dart index 6b16eb8..c2da84e 100644 --- a/mnemo_cards_backend/lib/packs/card_pack_drift_extension.dart +++ b/mnemo_cards_backend/lib/packs/card_pack_drift_extension.dart @@ -80,10 +80,11 @@ extension CardPackFromDto on CardPackDto { } /// Extension для создания GameCard из DTO +/// +/// Примечание: packId удален из GameCards, связь теперь только через CardPackCards extension GameCardFromDto on GameCardDto { - GameCardsCompanion toCompanion(String packId) { + GameCardsCompanion toCompanion() { return GameCardsCompanion.insert( - packId: packId, original: original ?? '', translation: translation ?? '', mnemo: drift.Value(mnemo), diff --git a/mnemo_cards_backend/lib/statistics/statistics_calculator.dart b/mnemo_cards_backend/lib/statistics/statistics_calculator.dart index b236ae3..f1a39e5 100644 --- a/mnemo_cards_backend/lib/statistics/statistics_calculator.dart +++ b/mnemo_cards_backend/lib/statistics/statistics_calculator.dart @@ -94,8 +94,8 @@ class StatisticsCalculator { // Рассчитать прогресс для каждого пака final progressList = []; - for (final userPack in userPacks) { - final progress = await calculatePackProgress(userId, userPack.packId); + for (final pack in userPacks) { + final progress = await calculatePackProgress(userId, pack.id); progressList.add(progress); } @@ -296,7 +296,7 @@ class StatisticsCalculator { /// Get timeline statistics for a specific period /// - /// Примечание: теперь принимает рассчитанные данные вместо UserDataModel + /// Примечание: теперь принимает userId вместо UserDataModel /// для работы с новой структурой (без удаленных полей) Future> getTimelineStatistics( String userId, { @@ -330,7 +330,7 @@ class StatisticsCalculator { } // Получить studyDates - final studyDates = await calculateStudyDates(userId); + final allStudyDates = await calculateStudyDates(userId); // Filter data for the period final periodDailyTime = {}; @@ -343,7 +343,7 @@ class StatisticsCalculator { } } - for (final date in studyDates) { + for (final date in allStudyDates) { if (date.isAfter(startDate.subtract(const Duration(days: 1))) && date.isBefore(endDate.add(const Duration(days: 1)))) { periodStudyDates.add(date); diff --git a/mnemo_cards_backend/lib/statistics/word_statistics_manager.dart b/mnemo_cards_backend/lib/statistics/word_statistics_manager.dart index e93e2b2..95f1f15 100644 --- a/mnemo_cards_backend/lib/statistics/word_statistics_manager.dart +++ b/mnemo_cards_backend/lib/statistics/word_statistics_manager.dart @@ -42,7 +42,7 @@ class WordStatisticsManager { final newCorrect = existing.correctAnswers + (isCorrect ? 1 : 0); final newIncorrect = existing.incorrectAnswers + (isCorrect ? 0 : 1); - await _db.wordStatisticsDao.update( + await _db.wordStatisticsDao.updateStatistics( id: existing.id, correctAnswers: newCorrect, incorrectAnswers: newIncorrect,