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
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:
parent
4f29ccd3c8
commit
ef5d7318e7
25 changed files with 1071 additions and 231 deletions
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
131
mnemo_cards_admin/src/api/tests.ts
Normal file
131
mnemo_cards_admin/src/api/tests.ts
Normal 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
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
491
mnemo_cards_admin/src/pages/TestsPage.tsx
Normal file
491
mnemo_cards_admin/src/pages/TestsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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())),
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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())),
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())),
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())),
|
||||
));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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())),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ class AuditLogs extends Table {
|
|||
|
||||
// Какая таблица и запись изменена
|
||||
// например: 'Users', 'Payments'
|
||||
TextColumn get tableName => text()();
|
||||
TextColumn get table => text()();
|
||||
// ID записи, которая изменилась
|
||||
TextColumn get recordId => text()();
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue