backend and admin
Some checks are pending
Backend CI / test (push) Waiting to run
Backend 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

This commit is contained in:
Dmitry 2025-12-15 00:10:06 +03:00
parent 4f29ccd3c8
commit ef5d7318e7
25 changed files with 1071 additions and 231 deletions

View file

@ -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() {
<Route path="/cards" element={<CardsPage />} />
<Route path="/packs" element={<PacksPage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/tests" element={<TestsPage />} />
</Routes>
</Layout>
) : (

View file

@ -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<PaginatedResponse<TestDto>> => {
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<TestDto> => {
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
)
}
},
}

View file

@ -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 (
<div className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex items-center space-x-3">
<Music className="h-5 w-5 text-muted-foreground" />
<div>
<div className={`flex items-center justify-between p-3 border rounded-lg transition-colors ${
isPlaying ? 'bg-primary/5 border-primary/20' : ''
}`}>
<div className="flex items-center space-x-3 flex-1 min-w-0">
<Music className={`h-5 w-5 flex-shrink-0 ${
isPlaying ? 'text-primary' : 'text-muted-foreground'
}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2">
<span className="text-sm font-medium">Voice</span>
<Badge variant="outline">{voice.language}</Badge>
{isPlaying && (
<Badge variant="secondary" className="animate-pulse">
Playing
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
Added {new Date(voice.createdAt).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 flex-shrink-0">
<Button
type="button"
variant="ghost"
variant={isPlaying ? "default" : "ghost"}
size="sm"
onClick={handlePlayPause}
disabled={disabled}
className="flex items-center space-x-1"
>
{isPlaying ? 'Pause' : 'Play'}
{isPlaying ? (
<>
<Pause className="h-4 w-4" />
<span className="hidden sm:inline">Pause</span>
</>
) : (
<>
<Play className="h-4 w-4" />
<span className="hidden sm:inline">Play</span>
</>
)}
</Button>
<Button
type="button"
@ -223,6 +274,7 @@ function VoiceItem({ voice, onRemove, disabled }: VoiceItemProps) {
size="sm"
onClick={onRemove}
disabled={disabled}
className="text-destructive hover:text-destructive"
>
<X className="h-4 w-4" />
</Button>

View file

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

View file

@ -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 ? (
<div className="relative">
<div className="flex items-center justify-between p-4 border rounded-lg bg-muted">
<div className="flex items-center space-x-3">
<div className={`flex items-center justify-between p-4 border rounded-lg transition-colors ${
isPlaying ? 'bg-primary/5 border-primary/20' : 'bg-muted'
}`}>
<div className="flex items-center space-x-3 flex-1 min-w-0">
<Button
type="button"
variant="outline"
variant={isPlaying ? "default" : "outline"}
size="sm"
onClick={handlePlayPause}
disabled={disabled}
className="flex items-center space-x-1 flex-shrink-0"
>
{isPlaying ? (
<Pause className="h-4 w-4" />
<>
<Pause className="h-4 w-4" />
<span className="hidden sm:inline">Pause</span>
</>
) : (
<Play className="h-4 w-4" />
<>
<Play className="h-4 w-4" />
<span className="hidden sm:inline">Play</span>
</>
)}
</Button>
<Music className="h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium">Audio file loaded</span>
<Music className={`h-5 w-5 flex-shrink-0 ${
isPlaying ? 'text-primary' : 'text-muted-foreground'
}`} />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">Audio file loaded</span>
{isPlaying && (
<span className="ml-2 text-xs text-primary animate-pulse">Playing...</span>
)}
</div>
</div>
<Button
type="button"
@ -166,6 +216,7 @@ export function AudioUpload({
size="sm"
onClick={handleRemove}
disabled={disabled}
className="flex-shrink-0"
>
<X className="h-4 w-4" />
</Button>

View file

@ -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<TestDto | null>(null)
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [testToDelete, setTestToDelete] = useState<TestDto | null>(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<PaginatedResponse<TestDto>>({
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 (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Tests Management</h1>
<p className="text-muted-foreground">Error loading tests</p>
</div>
<Card>
<CardContent className="pt-6">
<div className="space-y-2">
<p className="text-red-500 font-medium">Failed to load tests</p>
<p className="text-sm text-muted-foreground">{errorMessage}</p>
<Button
variant="outline"
size="sm"
onClick={() => queryClient.invalidateQueries({ queryKey: ['tests'] })}
className="mt-2"
>
Retry
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Tests Management</h1>
<p className="text-muted-foreground">
View, create, edit and delete game tests
</p>
</div>
<Button onClick={openCreateDialog}>
<Plus className="mr-2 h-4 w-4" />
Add Test
</Button>
</div>
{/* Search */}
<Card>
<CardContent className="pt-6">
<div className="flex items-center space-x-2">
<Search className="h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search tests..."
value={search}
onChange={(e) => handleSearch(e.target.value)}
className="max-w-sm"
/>
</div>
</CardContent>
</Card>
{/* Tests Table */}
<Card>
<CardHeader>
<CardTitle>All Tests ({data?.total || 0})</CardTitle>
<CardDescription>
Manage game tests in the system
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8">Loading tests...</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Name</TableHead>
<TableHead>Questions</TableHead>
<TableHead>Version</TableHead>
<TableHead>Time</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.items.map((test) => (
<TableRow key={test.id || Math.random()}>
<TableCell className="font-mono text-sm">{test.id || 'N/A'}</TableCell>
<TableCell className="font-medium">{test.name}</TableCell>
<TableCell>{test.questions?.length || 0}</TableCell>
<TableCell>{test.version || 'N/A'}</TableCell>
<TableCell>{test.time || 'N/A'}</TableCell>
<TableCell>
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => openEditDialog(test)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(test)}
disabled={!test.id}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{/* Pagination */}
{data && data.totalPages > 1 && (
<div className="flex items-center justify-between mt-4">
<div className="text-sm text-muted-foreground">
Showing {((page - 1) * limit) + 1} to {Math.min(page * limit, data.total)} of {data.total} tests
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage(page - 1)}
disabled={page <= 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<span className="text-sm">
Page {page} of {data.totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage(page + 1)}
disabled={page >= data.totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
{/* Create/Edit Dialog */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{selectedTest ? 'Edit Test' : 'Create New Test'}
</DialogTitle>
<DialogDescription>
{selectedTest ? 'Update the test information' : 'Add a new test to the system'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="space-y-2">
<Label htmlFor="name">Name *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="Test name"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="color">Color</Label>
<Input
id="color"
value={formData.color}
onChange={(e) => setFormData(prev => ({ ...prev, color: e.target.value }))}
placeholder="#FF0000"
/>
</div>
<div className="space-y-2">
<Label htmlFor="version">Version</Label>
<Input
id="version"
value={formData.version}
onChange={(e) => setFormData(prev => ({ ...prev, version: e.target.value }))}
placeholder="1.0.0"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="time">Time</Label>
<Input
id="time"
value={formData.time}
onChange={(e) => setFormData(prev => ({ ...prev, time: e.target.value }))}
placeholder="e.g. 5 min"
/>
</div>
<div className="space-y-2">
<Label htmlFor="timeSubtitle">Time Subtitle</Label>
<Input
id="timeSubtitle"
value={formData.timeSubtitle}
onChange={(e) => setFormData(prev => ({ ...prev, timeSubtitle: e.target.value }))}
placeholder="e.g. per question"
/>
</div>
</div>
<div className="space-y-2">
<ImageUpload
label="Cover Image"
value={formData.cover}
onChange={(value) => setFormData(prev => ({ ...prev, cover: value }))}
disabled={createMutation.isPending || updateMutation.isPending}
/>
</div>
<div className="space-y-2">
<Label>Questions</Label>
<div className="border rounded-lg p-4 bg-muted/50">
<p className="text-sm text-muted-foreground">
{formData.questions.length} question(s) in this test
</p>
{formData.questions.length === 0 && (
<p className="text-xs text-muted-foreground mt-2">
Questions are managed separately. After creating the test, you can add questions through the backend API.
</p>
)}
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={closeDialog}>
Cancel
</Button>
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
{createMutation.isPending || updateMutation.isPending ? 'Saving...' : (selectedTest ? 'Update' : 'Create')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Test</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the test "{testToDelete?.name}"? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-red-600 hover:bg-red-700"
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View file

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

View file

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

View file

@ -33,12 +33,12 @@ class AdminCardsApiV2 {
/// Get all cards with pagination and search
@Route.get('/admin/cards')
Future<Response> 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 = <Map<String, dynamic>>[];
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(

View file

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

View file

@ -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 = <WordStatisticsDto>[];
// Для каждой статистики получить карточку и извлечь 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)

View file

@ -14,7 +14,7 @@ class AuditDao extends DatabaseAccessor<AppDatabase> 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<AppDatabase> with _$AuditDaoMixin {
/// - ipAddress: IP адрес пользователя
/// - userAgent: User-Agent браузера
Future<void> log({
required String tableName,
required String table,
required String recordId,
required String action,
String? userId,
@ -34,7 +34,7 @@ class AuditDao extends DatabaseAccessor<AppDatabase> 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<AppDatabase> with _$AuditDaoMixin {
///
/// Возвращает список записей audit log, отсортированных по дате создания (новые первыми)
Future<List<AuditLog>> 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) {

View file

@ -75,16 +75,21 @@ class DiscountDao extends DatabaseAccessor<AppDatabase> with _$DiscountDaoMixin
}
/// Удалить кампанию (soft delete)
Future<void> softDeleteCampaign(String campaignId) {
return (update(db.discountCampaigns)
Future<void> 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<void> softDeleteCampaign(String campaignId) {
return deleteCampaign(campaignId);
}
// ==================== Discounts ====================
/// Получить скидку по ID (только активные)
@ -107,7 +112,7 @@ class DiscountDao extends DatabaseAccessor<AppDatabase> 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())),
));
}

View file

@ -19,33 +19,14 @@ import 'package:mnemo_cards_backend/database/database.dart';
/// TableInfo<Payments, Payment> get table => payments;
/// }
/// ```
///
/// Примечание: Методы softDelete() и restore() не реализованы в миксине,
/// так как требуют создания Companion объектов, которые специфичны для каждой таблицы.
/// Реализуйте эти методы в каждом DAO вручную.
mixin SoftDeleteMixin<T extends Table, D> on DatabaseAccessor<AppDatabase> {
/// Таблица, с которой работает DAO
TableInfo<T, D> get table;
/// Мягкое удаление записи по ID
///
/// Устанавливает isDeleted = true и deletedAt = текущее время.
/// Возвращает true если запись была обновлена.
Future<bool> 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<D>,
);
return updated > 0;
}
/// Получить только активные (не удаленные) записи
///
/// Используйте этот метод вместо select(table) когда нужно
@ -69,25 +50,4 @@ mixin SoftDeleteMixin<T extends Table, D> on DatabaseAccessor<AppDatabase> {
}))
.getSingleOrNull();
}
/// Восстановить удаленную запись
///
/// Устанавливает isDeleted = false и deletedAt = null.
/// Возвращает true если запись была обновлена.
Future<bool> 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<D>,
);
return updated > 0;
}
}

View file

@ -93,6 +93,20 @@ class PackDao extends DatabaseAccessor<AppDatabase> with _$PackDaoMixin {
return (select(gameCards)..where((c) => c.id.equals(id))).getSingleOrNull();
}
/// Получить паки для карточки
/// Связь через CardPackCards (packId удален из GameCards)
Future<List<CardPack>> 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<List<GameCard>> getPackCards(String packId) async {

View file

@ -92,16 +92,6 @@ class PaymentDao extends DatabaseAccessor<AppDatabase>
));
}
/// Подсчитать платежи пользователя
Future<int> 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<Payment?> getPaymentByExternalToken(String token) {
return (selectActive()..where((p) => p.externalToken.equals(token)))

View file

@ -122,7 +122,7 @@ class PromoCodeDao extends DatabaseAccessor<AppDatabase> 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<AppDatabase> 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())),
));
}

View file

@ -96,7 +96,7 @@ class TestDao extends DatabaseAccessor<AppDatabase> 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())),
));
}

View file

@ -202,12 +202,17 @@ class UserDao extends DatabaseAccessor<AppDatabase> with _$UserDaoMixin {
return inserted.id;
}
/// Удалить токен (hard delete)
Future<void> deleteToken(String token) async {
await (delete(tokens)..where((t) => t.token.equals(token))).go();
}
/// Удалить токен (soft delete)
Future<int> 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<AppDatabase> 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<int> 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<AppDatabase> with _$UserDaoMixin {
));
}
/// Удалить истекшие refresh токены (hard delete)
Future<void> deleteExpiredRefreshTokens() async {
final now = PgDateTime(DateTime.now());
await (delete(refreshTokens)
..where((rt) => rt.expiresAt.isSmallerThanValue(now))
).go();
}
/// Удалить истекшие refresh токены (soft delete)
Future<int> 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<AppDatabase> 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())),
));
}

View file

@ -39,10 +39,10 @@ class WordStatisticsDao extends DatabaseAccessor<AppDatabase>
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<AppDatabase>
/// Обновить статистику
///
/// Автоматически пересчитывает mastery на основе новых значений
Future<void> update({
Future<void> updateStatistics({
required String id,
required int correctAnswers,
required int incorrectAnswers,
@ -67,8 +67,8 @@ class WordStatisticsDao extends DatabaseAccessor<AppDatabase>
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())),
),
);
}

View file

@ -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<String> tableName = GeneratedColumn<String>(
'table_name',
late final GeneratedColumn<String> table = GeneratedColumn<String>(
'table',
aliasedName,
false,
type: DriftSqlType.string,
@ -18363,7 +18361,7 @@ class $AuditLogsTable extends AuditLogs
@override
List<GeneratedColumn> 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<AuditLog> {
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<AuditLog> {
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<AuditLog> {
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<String>(id);
map['table_name'] = Variable<String>(tableName);
map['table'] = Variable<String>(table);
map['record_id'] = Variable<String>(recordId);
map['action'] = Variable<String>(action);
if (!nullToAbsent || userId != null) {
@ -18561,7 +18559,7 @@ class AuditLog extends DataClass implements Insertable<AuditLog> {
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<AuditLog> {
serializer ??= driftRuntimeOptions.defaultSerializer;
return AuditLog(
id: serializer.fromJson<String>(json['id']),
tableName: serializer.fromJson<String>(json['tableName']),
table: serializer.fromJson<String>(json['table']),
recordId: serializer.fromJson<String>(json['recordId']),
action: serializer.fromJson<String>(json['action']),
userId: serializer.fromJson<String?>(json['userId']),
@ -18606,7 +18604,7 @@ class AuditLog extends DataClass implements Insertable<AuditLog> {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<String>(id),
'tableName': serializer.toJson<String>(tableName),
'table': serializer.toJson<String>(table),
'recordId': serializer.toJson<String>(recordId),
'action': serializer.toJson<String>(action),
'userId': serializer.toJson<String?>(userId),
@ -18620,7 +18618,7 @@ class AuditLog extends DataClass implements Insertable<AuditLog> {
AuditLog copyWith({
String? id,
String? tableName,
String? table,
String? recordId,
String? action,
Value<String?> userId = const Value.absent(),
@ -18631,7 +18629,7 @@ class AuditLog extends DataClass implements Insertable<AuditLog> {
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> {
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<AuditLog> {
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<AuditLog> {
@override
int get hashCode => Object.hash(
id,
tableName,
table,
recordId,
action,
userId,
@ -18691,7 +18689,7 @@ class AuditLog extends DataClass implements Insertable<AuditLog> {
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<AuditLog> {
class AuditLogsCompanion extends UpdateCompanion<AuditLog> {
final Value<String> id;
final Value<String> tableName;
final Value<String> table;
final Value<String> recordId;
final Value<String> action;
final Value<String?> userId;
@ -18716,7 +18714,7 @@ class AuditLogsCompanion extends UpdateCompanion<AuditLog> {
final Value<int> 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<AuditLog> {
});
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<AuditLog> {
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<AuditLog> custom({
Expression<String>? id,
Expression<String>? tableName,
Expression<String>? table,
Expression<String>? recordId,
Expression<String>? action,
Expression<String>? userId,
@ -18757,7 +18755,7 @@ class AuditLogsCompanion extends UpdateCompanion<AuditLog> {
}) {
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<AuditLog> {
AuditLogsCompanion copyWith({
Value<String>? id,
Value<String>? tableName,
Value<String>? table,
Value<String>? recordId,
Value<String>? action,
Value<String?>? userId,
@ -18785,7 +18783,7 @@ class AuditLogsCompanion extends UpdateCompanion<AuditLog> {
}) {
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<AuditLog> {
if (id.present) {
map['id'] = Variable<String>(id.value);
}
if (tableName.present) {
map['table_name'] = Variable<String>(tableName.value);
if (table.present) {
map['table'] = Variable<String>(table.value);
}
if (recordId.present) {
map['record_id'] = Variable<String>(recordId.value);
@ -18844,7 +18842,7 @@ class AuditLogsCompanion extends UpdateCompanion<AuditLog> {
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<String> id,
required String tableName,
required String table,
required String recordId,
required String action,
Value<String?> userId,
@ -34211,7 +34209,7 @@ typedef $$AuditLogsTableCreateCompanionBuilder =
typedef $$AuditLogsTableUpdateCompanionBuilder =
AuditLogsCompanion Function({
Value<String> id,
Value<String> tableName,
Value<String> table,
Value<String> recordId,
Value<String> action,
Value<String?> userId,
@ -34237,8 +34235,8 @@ class $$AuditLogsTableFilterComposer
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get tableName => $composableBuilder(
column: $table.tableName,
ColumnFilters<String> get table => $composableBuilder(
column: $table.table,
builder: (column) => ColumnFilters(column),
);
@ -34297,8 +34295,8 @@ class $$AuditLogsTableOrderingComposer
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get tableName => $composableBuilder(
column: $table.tableName,
ColumnOrderings<String> get table => $composableBuilder(
column: $table.table,
builder: (column) => ColumnOrderings(column),
);
@ -34355,8 +34353,8 @@ class $$AuditLogsTableAnnotationComposer
GeneratedColumn<String> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
GeneratedColumn<String> get tableName =>
$composableBuilder(column: $table.tableName, builder: (column) => column);
GeneratedColumn<String> get table =>
$composableBuilder(column: $table.table, builder: (column) => column);
GeneratedColumn<String> get recordId =>
$composableBuilder(column: $table.recordId, builder: (column) => column);
@ -34412,7 +34410,7 @@ class $$AuditLogsTableTableManager
updateCompanionCallback:
({
Value<String> id = const Value.absent(),
Value<String> tableName = const Value.absent(),
Value<String> table = const Value.absent(),
Value<String> recordId = const Value.absent(),
Value<String> action = const Value.absent(),
Value<String?> userId = const Value.absent(),
@ -34424,7 +34422,7 @@ class $$AuditLogsTableTableManager
Value<int> 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<String> id = const Value.absent(),
required String tableName,
required String table,
required String recordId,
required String action,
Value<String?> userId = const Value.absent(),
@ -34450,7 +34448,7 @@ class $$AuditLogsTableTableManager
Value<int> rowid = const Value.absent(),
}) => AuditLogsCompanion.insert(
id: id,
tableName: tableName,
table: table,
recordId: recordId,
action: action,
userId: userId,

View file

@ -18,7 +18,7 @@ class AuditLogs extends Table {
// Какая таблица и запись изменена
// например: 'Users', 'Payments'
TextColumn get tableName => text()();
TextColumn get table => text()();
// ID записи, которая изменилась
TextColumn get recordId => text()();

View file

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

View file

@ -94,8 +94,8 @@ class StatisticsCalculator {
// Рассчитать прогресс для каждого пака
final progressList = <PackProgressDto>[];
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<Map<String, dynamic>> 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 = <DateTime, int>{};
@ -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);

View file

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