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

This commit is contained in:
Dmitry 2026-01-09 20:21:18 +03:00
parent dfcb7a1030
commit 336bafc600
70 changed files with 2659 additions and 1129 deletions

View file

@ -6,6 +6,7 @@ import CardsPage from '@/pages/CardsPage'
import PacksPage from '@/pages/PacksPage' import PacksPage from '@/pages/PacksPage'
import UsersPage from '@/pages/UsersPage' import UsersPage from '@/pages/UsersPage'
import TestsPage from '@/pages/TestsPage' import TestsPage from '@/pages/TestsPage'
import TasksPage from '@/pages/TasksPage'
import Layout from '@/components/layout/Layout' import Layout from '@/components/layout/Layout'
import { TokenRefreshProvider } from '@/components/TokenRefreshProvider' import { TokenRefreshProvider } from '@/components/TokenRefreshProvider'
@ -31,6 +32,7 @@ function App() {
<Route path="/packs" element={<PacksPage />} /> <Route path="/packs" element={<PacksPage />} />
<Route path="/users" element={<UsersPage />} /> <Route path="/users" element={<UsersPage />} />
<Route path="/tests" element={<TestsPage />} /> <Route path="/tests" element={<TestsPage />} />
<Route path="/tasks" element={<TasksPage />} />
</Routes> </Routes>
</Layout> </Layout>
) : ( ) : (

View file

@ -1,4 +1,5 @@
import { adminApiClient } from './client' import { adminApiClient } from './client'
import { tasksApi } from './tasks'
export interface DashboardStats { export interface DashboardStats {
users: number users: number
@ -8,6 +9,13 @@ export interface DashboardStats {
payments: number payments: number
} }
export interface TasksStats {
total: number
active: number
completed: number
expired: number
}
export interface RecentUser { export interface RecentUser {
id: number id: number
name?: string name?: string
@ -52,4 +60,34 @@ export const analyticsApi = {
const response = await adminApiClient.get('/api/v2/admin/analytics/revenue/chart') const response = await adminApiClient.get('/api/v2/admin/analytics/revenue/chart')
return response.data return response.data
}, },
// Get tasks statistics
getTasksStats: async (): Promise<TasksStats> => {
try {
// Get all tasks counts by status
const [allTasks, availableTasks, inProgressTasks, completedTasks, expiredTasks] = await Promise.all([
tasksApi.getTasks({ limit: 1, page: 1 }),
tasksApi.getTasks({ limit: 1, page: 1, status: 'available' }),
tasksApi.getTasks({ limit: 1, page: 1, status: 'in_progress' }),
tasksApi.getTasks({ limit: 1, page: 1, status: 'completed' }),
tasksApi.getTasks({ limit: 1, page: 1, status: 'expired' }),
])
return {
total: allTasks.total,
active: (availableTasks.total || 0) + (inProgressTasks.total || 0),
completed: completedTasks.total || 0,
expired: expiredTasks.total || 0,
}
} catch (error) {
// If there's an error, return zeros
console.error('Error fetching tasks stats:', error)
return {
total: 0,
active: 0,
completed: 0,
expired: 0,
}
}
},
} }

View file

@ -0,0 +1,202 @@
import { adminApiClient } from './client'
import type { TaskDto, CreateTaskDto, UpdateTaskDto, TaskFilters, PaginatedResponse } from '@/types/models'
import type { AxiosError } from 'axios'
export interface TasksApiError {
message: string
statusCode?: number
field?: string
originalError?: unknown
name: 'TasksApiError'
}
export function createTasksApiError(
message: string,
statusCode?: number,
field?: string,
originalError?: unknown
): TasksApiError {
return {
message,
statusCode,
field,
originalError,
name: 'TasksApiError',
}
}
export function isTasksApiError(error: unknown): error is TasksApiError {
return (
typeof error === 'object' &&
error !== null &&
'name' in error &&
error.name === 'TasksApiError'
)
}
export const tasksApi = {
// Get all tasks with pagination and filters
getTasks: async (params?: {
page?: number
limit?: number
} & TaskFilters): Promise<PaginatedResponse<TaskDto>> => {
try {
const response = await adminApiClient.get('/api/v2/admin/tasks', {
params: {
page: params?.page || 1,
limit: params?.limit || 20,
type: params?.type,
difficulty: params?.difficulty,
status: params?.status,
userId: params?.userId,
search: params?.search,
},
})
return response.data
} catch (error) {
const axiosError = error as AxiosError<{ error?: string; message?: string; field?: string }>
// Check for validation errors (limit too high, etc.)
if (axiosError.response?.status === 400) {
const errorData = axiosError.response.data
if (errorData?.message?.includes('Limit')) {
throw createTasksApiError(
`Invalid limit: ${errorData.message}. Maximum allowed limit is 100.`,
axiosError.response.status,
'limit',
error
)
}
}
throw createTasksApiError(
axiosError.response?.data?.message ||
axiosError.response?.data?.error ||
'Failed to load tasks',
axiosError.response?.status,
axiosError.response?.data?.field,
error
)
}
},
// Get task details by ID
getTask: async (taskId: string): Promise<TaskDto> => {
try {
const response = await adminApiClient.get(`/api/v2/admin/tasks/${taskId}`)
return response.data
} catch (error) {
const axiosError = error as AxiosError<{ error?: string; message?: string }>
if (axiosError.response?.status === 404) {
throw createTasksApiError(
`Task not found: The task with ID "${taskId}" does not exist or has been deleted.`,
axiosError.response.status,
undefined,
error
)
}
throw createTasksApiError(
axiosError.response?.data?.message ||
axiosError.response?.data?.error ||
`Failed to load task ${taskId}`,
axiosError.response?.status,
undefined,
error
)
}
},
// Create a new task
createTask: async (data: CreateTaskDto): Promise<{ success: boolean; task: TaskDto }> => {
try {
const response = await adminApiClient.post('/api/v2/admin/tasks', data)
return response.data
} catch (error) {
const axiosError = error as AxiosError<{ error?: string; message?: string; field?: string; details?: string }>
const baseMessage = axiosError.response?.data?.message ||
axiosError.response?.data?.error ||
'Failed to create task'
let fullMessage = baseMessage
if (axiosError.response?.data?.details) {
fullMessage += `. ${axiosError.response.data.details}`
}
if (axiosError.response?.data?.field) {
fullMessage += ` (Field: ${axiosError.response.data.field})`
}
throw createTasksApiError(
fullMessage,
axiosError.response?.status,
axiosError.response?.data?.field,
error
)
}
},
// Update an existing task
updateTask: async (taskId: string, data: UpdateTaskDto): Promise<{ success: boolean; task: TaskDto }> => {
try {
const response = await adminApiClient.put(`/api/v2/admin/tasks/${taskId}`, data)
return response.data
} catch (error) {
const axiosError = error as AxiosError<{ error?: string; message?: string; field?: string; details?: string }>
const baseMessage = axiosError.response?.data?.message ||
axiosError.response?.data?.error ||
'Failed to update task'
let fullMessage = baseMessage
if (axiosError.response?.data?.details) {
fullMessage += `. ${axiosError.response.data.details}`
}
// Special handling for common errors
if (axiosError.response?.status === 404) {
fullMessage = `Task not found: The task you're trying to update (ID: ${taskId}) does not exist.`
}
if (axiosError.response?.data?.field) {
fullMessage += ` (Field: ${axiosError.response.data.field})`
}
throw createTasksApiError(
fullMessage,
axiosError.response?.status,
axiosError.response?.data?.field,
error
)
}
},
// Delete a task
deleteTask: async (taskId: string): Promise<{ success: boolean; message: string }> => {
try {
const response = await adminApiClient.delete(`/api/v2/admin/tasks/${taskId}`)
return response.data
} catch (error) {
const axiosError = error as AxiosError<{ error?: string; message?: string }>
if (axiosError.response?.status === 404) {
throw createTasksApiError(
`Task not found: The task with ID "${taskId}" does not exist and cannot be deleted.`,
axiosError.response.status,
undefined,
error
)
}
throw createTasksApiError(
axiosError.response?.data?.message ||
axiosError.response?.data?.error ||
`Failed to delete task ${taskId}`,
axiosError.response?.status,
undefined,
error
)
}
},
}

View file

@ -8,6 +8,7 @@ import {
Package, Package,
Users, Users,
ClipboardList, ClipboardList,
CheckSquare,
LogOut, LogOut,
Menu, Menu,
X X
@ -24,6 +25,7 @@ const navigation = [
{ name: 'Packs', href: '/packs', icon: Package }, { name: 'Packs', href: '/packs', icon: Package },
{ name: 'Tests', href: '/tests', icon: ClipboardList }, { name: 'Tests', href: '/tests', icon: ClipboardList },
{ name: 'Users', href: '/users', icon: Users }, { name: 'Users', href: '/users', icon: Users },
{ name: 'Tasks', href: '/tasks', icon: CheckSquare },
] ]
export default function Layout({ children }: LayoutProps) { export default function Layout({ children }: LayoutProps) {

View file

@ -0,0 +1,90 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { TaskStatusBadge } from './TaskStatusBadge'
import { TaskTypeBadge } from './TaskTypeBadge'
import { TaskDifficultyBadge } from './TaskDifficultyBadge'
import { TaskRewardsList } from './TaskRewardsList'
import { cn } from '@/lib/utils'
import type { TaskDto } from '@/types/models'
interface TaskCardProps {
task: TaskDto
onClick?: () => void
className?: string
}
export function TaskCard({ task, onClick, className }: TaskCardProps) {
const formatDate = (dateString: string | undefined): string => {
if (!dateString) return 'Не указано'
try {
return new Date(dateString).toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
} catch {
return dateString
}
}
return (
<Card
className={cn(
'cursor-pointer transition-shadow hover:shadow-md',
className
)}
onClick={onClick}
>
<CardHeader>
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-lg line-clamp-2">{task.title}</CardTitle>
<TaskStatusBadge status={task.status} />
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Описание */}
<p className="text-sm text-muted-foreground line-clamp-3">
{task.description}
</p>
{/* Бейджи типа и сложности */}
<div className="flex flex-wrap gap-2">
<TaskTypeBadge type={task.type} />
<TaskDifficultyBadge difficulty={task.difficulty} />
</div>
{/* Награды */}
<div>
<TaskRewardsList rewards={task.rewards} />
</div>
{/* Даты */}
<div className="text-xs text-muted-foreground space-y-1">
<div>Истекает: {formatDate(task.expiresAt)}</div>
{task.completedAt && (
<div>Завершена: {formatDate(task.completedAt)}</div>
)}
</div>
{/* Теги (если есть) */}
{task.tags && task.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{task.tags.slice(0, 3).map((tag, index) => (
<span
key={index}
className="text-xs px-2 py-0.5 bg-muted rounded-full"
>
{tag}
</span>
))}
{task.tags.length > 3 && (
<span className="text-xs px-2 py-0.5 text-muted-foreground">
+{task.tags.length - 3}
</span>
)}
</div>
)}
</CardContent>
</Card>
)
}

View file

@ -0,0 +1,160 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { TaskStatusBadge } from './TaskStatusBadge'
import { TaskTypeBadge } from './TaskTypeBadge'
import { TaskDifficultyBadge } from './TaskDifficultyBadge'
import { TaskRewardsList } from './TaskRewardsList'
import { Badge } from '@/components/ui/badge'
import type { TaskDto } from '@/types/models'
interface TaskDetailsDialogProps {
task: TaskDto
open: boolean
onClose: () => void
}
export function TaskDetailsDialog({
task,
open,
onClose,
}: TaskDetailsDialogProps) {
const formatDate = (dateString: string | undefined): string => {
if (!dateString) return 'Не указано'
try {
return new Date(dateString).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
} catch {
return dateString
}
}
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl">{task.title}</DialogTitle>
<DialogDescription>{task.description}</DialogDescription>
</DialogHeader>
<div className="space-y-6 mt-4">
{/* Статус, тип и сложность */}
<div className="flex flex-wrap gap-2">
<TaskStatusBadge status={task.status} />
<TaskTypeBadge type={task.type} />
<TaskDifficultyBadge difficulty={task.difficulty} />
</div>
{/* Награды */}
<div>
<h3 className="text-sm font-semibold mb-2">Награды</h3>
<TaskRewardsList rewards={task.rewards} />
</div>
{/* Даты */}
<div className="grid grid-cols-2 gap-4">
<div>
<h3 className="text-sm font-semibold mb-1">Создана</h3>
<p className="text-sm text-muted-foreground">
{formatDate(task.createdAt)}
</p>
</div>
<div>
<h3 className="text-sm font-semibold mb-1">Истекает</h3>
<p className="text-sm text-muted-foreground">
{formatDate(task.expiresAt)}
</p>
</div>
{task.completedAt && (
<div>
<h3 className="text-sm font-semibold mb-1">Завершена</h3>
<p className="text-sm text-muted-foreground">
{formatDate(task.completedAt)}
</p>
</div>
)}
</div>
{/* Инструкции */}
{task.instructions && (
<div>
<h3 className="text-sm font-semibold mb-2">Инструкции</h3>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{task.instructions}
</p>
</div>
)}
{/* Теги */}
{task.tags && task.tags.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-2">Теги</h3>
<div className="flex flex-wrap gap-2">
{task.tags.map((tag, index) => (
<Badge key={index} variant="outline">
{tag}
</Badge>
))}
</div>
</div>
)}
{/* Изображение */}
{task.imageUrl && (
<div>
<h3 className="text-sm font-semibold mb-2">Изображение</h3>
<img
src={task.imageUrl}
alt={task.title}
className="rounded-lg max-w-full h-auto"
/>
</div>
)}
{/* Доказательство выполнения */}
{task.proofUrl && (
<div>
<h3 className="text-sm font-semibold mb-2">
Доказательство выполнения
</h3>
<a
href={task.proofUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-500 hover:underline"
>
Открыть ссылку
</a>
</div>
)}
{/* ID пользователя */}
{task.userId && (
<div>
<h3 className="text-sm font-semibold mb-1">ID пользователя</h3>
<p className="text-sm text-muted-foreground font-mono">
{task.userId}
</p>
</div>
)}
{/* ID задачи */}
<div>
<h3 className="text-sm font-semibold mb-1">ID задачи</h3>
<p className="text-sm text-muted-foreground font-mono">{task.id}</p>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,37 @@
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import type { TaskDto } from '@/types/models'
interface TaskDifficultyBadgeProps {
difficulty: TaskDto['difficulty']
className?: string
}
export function TaskDifficultyBadge({ difficulty, className }: TaskDifficultyBadgeProps) {
const difficultyConfig = {
easy: {
label: 'Легкая',
className: 'bg-green-400 hover:bg-green-500 text-white border-transparent',
},
medium: {
label: 'Средняя',
className: 'bg-yellow-500 hover:bg-yellow-600 text-white border-transparent',
},
hard: {
label: 'Сложная',
className: 'bg-red-500 hover:bg-red-600 text-white border-transparent',
},
}
const config = difficultyConfig[difficulty]
return (
<Badge
variant="default"
className={cn(config.className, className)}
>
{config.label}
</Badge>
)
}

View file

@ -0,0 +1,619 @@
import { useState, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { ImageUpload } from '@/components/ui/image-upload'
import { Badge } from '@/components/ui/badge'
import { Plus, Trash2, X } from 'lucide-react'
import type { TaskDto, CreateTaskDto, UpdateTaskDto, TaskRewardDto } from '@/types/models'
interface TaskFormProps {
task?: TaskDto
onSave: (data: CreateTaskDto | UpdateTaskDto) => Promise<void>
onCancel: () => void
}
interface FormErrors {
title?: string
description?: string
type?: string
difficulty?: string
expiresAt?: string
rewards?: string
rewardErrors?: Record<number, { amount?: string; achievementId?: string }>
}
interface RewardFormData {
type: 'xp' | 'coins' | 'achievement'
amount: number
achievementId: string
}
export function TaskForm({ task, onSave, onCancel }: TaskFormProps) {
const [formData, setFormData] = useState({
title: task?.title || '',
description: task?.description || '',
type: (task?.type || 'app_internal') as 'app_internal' | 'external' | 'social',
difficulty: (task?.difficulty || 'easy') as 'easy' | 'medium' | 'hard',
expiresAt: task?.expiresAt
? new Date(task.expiresAt).toISOString().slice(0, 16)
: '',
instructions: task?.instructions || '',
tags: task?.tags || [],
imageUrl: task?.imageUrl || '',
userId: task?.userId || '',
})
const [rewards, setRewards] = useState<RewardFormData[]>(() => {
if (task?.rewards && task.rewards.length > 0) {
return task.rewards.map((r) => ({
type: r.type,
amount: r.amount || 0,
achievementId: r.achievementId || '',
}))
}
return [{ type: 'xp', amount: 0, achievementId: '' }]
})
const [tagInput, setTagInput] = useState('')
const [errors, setErrors] = useState<FormErrors>({})
const [isSubmitting, setIsSubmitting] = useState(false)
// Валидация формы
const validateForm = useCallback((): boolean => {
const newErrors: FormErrors = {}
// Валидация названия
if (!formData.title.trim()) {
newErrors.title = 'Название обязательно для заполнения'
} else if (formData.title.trim().length < 3) {
newErrors.title = 'Название должно содержать минимум 3 символа'
}
// Валидация описания
if (!formData.description.trim()) {
newErrors.description = 'Описание обязательно для заполнения'
} else if (formData.description.trim().length < 10) {
newErrors.description = 'Описание должно содержать минимум 10 символов'
}
// Валидация типа
if (!formData.type) {
newErrors.type = 'Тип задачи обязателен для заполнения'
}
// Валидация сложности
if (!formData.difficulty) {
newErrors.difficulty = 'Сложность обязательна для заполнения'
}
// Валидация срока действия
if (!formData.expiresAt) {
newErrors.expiresAt = 'Срок действия обязателен для заполнения'
} else {
const expiresDate = new Date(formData.expiresAt)
const now = new Date()
if (expiresDate <= now) {
newErrors.expiresAt = 'Срок действия должна быть в будущем'
}
}
// Валидация наград
if (rewards.length === 0) {
newErrors.rewards = 'Необходимо указать хотя бы одну награду'
} else {
const rewardErrors: Record<number, { amount?: string; achievementId?: string }> = {}
rewards.forEach((reward, index) => {
if (reward.type === 'achievement') {
if (!reward.achievementId.trim()) {
rewardErrors[index] = {
achievementId: 'Для достижения необходимо указать ID',
}
}
} else {
if (reward.amount <= 0) {
rewardErrors[index] = {
amount: 'Количество должно быть больше 0',
}
}
}
})
if (Object.keys(rewardErrors).length > 0) {
newErrors.rewardErrors = rewardErrors
}
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}, [formData, rewards])
// Обработка изменений полей
const handleFieldChange = (field: keyof typeof formData, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }))
// Очистка ошибки при изменении поля
if (errors[field as keyof FormErrors]) {
setErrors((prev) => ({ ...prev, [field]: undefined }))
}
}
// Добавление награды
const handleAddReward = () => {
setRewards((prev) => [
...prev,
{ type: 'xp', amount: 0, achievementId: '' },
])
setErrors((prev) => ({
...prev,
rewardErrors: undefined,
rewards: undefined,
}))
}
// Удаление награды
const handleRemoveReward = (index: number) => {
setRewards((prev) => {
const filtered = prev.filter((_, i) => i !== index)
// Если осталась только одна награда, очищаем ошибки
if (filtered.length === 0 || filtered.length === 1) {
setErrors((prevErrors) => ({
...prevErrors,
rewardErrors: undefined,
rewards: undefined,
}))
} else {
// Переиндексируем ошибки после удаления
setErrors((prevErrors) => {
if (!prevErrors.rewardErrors) return prevErrors
const newRewardErrors: Record<number, { amount?: string; achievementId?: string }> = {}
Object.entries(prevErrors.rewardErrors).forEach(([key, value]) => {
const oldIndex = parseInt(key)
if (oldIndex < index) {
// Индекс не изменился
newRewardErrors[oldIndex] = value
} else if (oldIndex > index) {
// Индекс уменьшился на 1
newRewardErrors[oldIndex - 1] = value
}
// Индекс == index - пропускаем (удаленная награда)
})
return {
...prevErrors,
rewardErrors: Object.keys(newRewardErrors).length > 0 ? newRewardErrors : undefined,
}
})
}
return filtered
})
}
// Изменение награды
const handleRewardChange = (
index: number,
field: keyof RewardFormData,
value: string | number
) => {
setRewards((prev) => {
const updated = [...prev]
updated[index] = { ...updated[index], [field]: value }
return updated
})
// Очистка ошибок при изменении награды
setErrors((prev) => {
const newErrors = { ...prev }
if (newErrors.rewardErrors) {
const updatedRewardErrors = { ...newErrors.rewardErrors }
delete updatedRewardErrors[index]
if (Object.keys(updatedRewardErrors).length === 0) {
delete newErrors.rewardErrors
} else {
newErrors.rewardErrors = updatedRewardErrors
}
}
return newErrors
})
}
// Добавление тега
const handleAddTag = () => {
const newTags = tagInput
.split(',')
.map((t) => t.trim())
.filter((t) => t.length > 0 && !formData.tags.includes(t))
if (newTags.length > 0) {
setFormData((prev) => ({
...prev,
tags: [...prev.tags, ...newTags],
}))
setTagInput('')
}
}
// Удаление тега
const handleRemoveTag = (tagToRemove: string) => {
setFormData((prev) => ({
...prev,
tags: prev.tags.filter((t) => t !== tagToRemove),
}))
}
// Обработка Enter для добавления тега
const handleTagInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddTag()
}
}
// Обработка отправки формы
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!validateForm()) {
return
}
setIsSubmitting(true)
try {
// Преобразование наград в формат API
const apiRewards: TaskRewardDto[] = rewards.map((reward) => {
if (reward.type === 'achievement') {
return {
type: reward.type,
amount: 0,
achievementId: reward.achievementId.trim() || undefined,
}
} else {
return {
type: reward.type,
amount: reward.amount,
}
}
})
// Преобразование данных формы в формат API
const taskData: CreateTaskDto | UpdateTaskDto = {
title: formData.title.trim(),
description: formData.description.trim(),
type: formData.type,
difficulty: formData.difficulty,
rewards: apiRewards,
expiresAt: new Date(formData.expiresAt).toISOString(),
instructions: formData.instructions.trim() || undefined,
tags: formData.tags.length > 0 ? formData.tags : undefined,
imageUrl: formData.imageUrl || undefined,
userId: formData.userId.trim() || undefined,
}
await onSave(taskData)
} catch (error) {
console.error('Error saving task:', error)
// Ошибка обрабатывается в родительском компоненте
} finally {
setIsSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Название */}
<div className="space-y-2">
<Label htmlFor="title">
Название <span className="text-red-500">*</span>
</Label>
<Input
id="title"
value={formData.title}
onChange={(e) => handleFieldChange('title', e.target.value)}
placeholder="Введите название задачи"
required
className={errors.title ? 'border-red-500' : ''}
/>
{errors.title && (
<p className="text-sm text-red-500">{errors.title}</p>
)}
</div>
{/* Описание */}
<div className="space-y-2">
<Label htmlFor="description">
Описание <span className="text-red-500">*</span>
</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => handleFieldChange('description', e.target.value)}
placeholder="Введите описание задачи"
rows={4}
required
className={errors.description ? 'border-red-500' : ''}
/>
{errors.description && (
<p className="text-sm text-red-500">{errors.description}</p>
)}
</div>
{/* Тип и сложность */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="type">
Тип <span className="text-red-500">*</span>
</Label>
<Select
value={formData.type}
onValueChange={(value) =>
handleFieldChange('type', value as typeof formData.type)
}
>
<SelectTrigger
id="type"
className={errors.type ? 'border-red-500' : ''}
>
<SelectValue placeholder="Выберите тип" />
</SelectTrigger>
<SelectContent>
<SelectItem value="app_internal">Внутреннее приложение</SelectItem>
<SelectItem value="external">Внешнее</SelectItem>
<SelectItem value="social">Социальное</SelectItem>
</SelectContent>
</Select>
{errors.type && (
<p className="text-sm text-red-500">{errors.type}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="difficulty">
Сложность <span className="text-red-500">*</span>
</Label>
<Select
value={formData.difficulty}
onValueChange={(value) =>
handleFieldChange('difficulty', value as typeof formData.difficulty)
}
>
<SelectTrigger
id="difficulty"
className={errors.difficulty ? 'border-red-500' : ''}
>
<SelectValue placeholder="Выберите сложность" />
</SelectTrigger>
<SelectContent>
<SelectItem value="easy">Легкая</SelectItem>
<SelectItem value="medium">Средняя</SelectItem>
<SelectItem value="hard">Сложная</SelectItem>
</SelectContent>
</Select>
{errors.difficulty && (
<p className="text-sm text-red-500">{errors.difficulty}</p>
)}
</div>
</div>
{/* Награды */}
<div className="space-y-2">
<Label>
Награды <span className="text-red-500">*</span>
</Label>
{errors.rewards && (
<p className="text-sm text-red-500">{errors.rewards}</p>
)}
<div className="space-y-3">
{rewards.map((reward, index) => (
<div
key={index}
className="flex gap-2 items-start p-3 border rounded-lg"
>
<div className="flex-1 grid grid-cols-3 gap-2">
<Select
value={reward.type}
onValueChange={(value) =>
handleRewardChange(
index,
'type',
value as RewardFormData['type']
)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="xp">XP</SelectItem>
<SelectItem value="coins">Монеты</SelectItem>
<SelectItem value="achievement">Достижение</SelectItem>
</SelectContent>
</Select>
{reward.type === 'achievement' ? (
<div className="col-span-2 space-y-1">
<Input
placeholder="ID достижения"
value={reward.achievementId}
onChange={(e) =>
handleRewardChange(index, 'achievementId', e.target.value)
}
className={
errors.rewardErrors?.[index]?.achievementId
? 'border-red-500'
: ''
}
/>
{errors.rewardErrors?.[index]?.achievementId && (
<p className="text-xs text-red-500">
{errors.rewardErrors[index].achievementId}
</p>
)}
</div>
) : (
<div className="col-span-2 space-y-1">
<Input
type="number"
min="1"
placeholder="Количество"
value={reward.amount || ''}
onChange={(e) =>
handleRewardChange(
index,
'amount',
parseInt(e.target.value) || 0
)
}
className={
errors.rewardErrors?.[index]?.amount
? 'border-red-500'
: ''
}
/>
{errors.rewardErrors?.[index]?.amount && (
<p className="text-xs text-red-500">
{errors.rewardErrors[index].amount}
</p>
)}
</div>
)}
</div>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => handleRemoveReward(index)}
disabled={rewards.length === 1}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddReward}
className="w-full"
>
<Plus className="h-4 w-4 mr-2" />
Добавить награду
</Button>
</div>
{/* Срок действия */}
<div className="space-y-2">
<Label htmlFor="expiresAt">
Срок действия <span className="text-red-500">*</span>
</Label>
<Input
id="expiresAt"
type="datetime-local"
value={formData.expiresAt}
onChange={(e) => handleFieldChange('expiresAt', e.target.value)}
required
className={errors.expiresAt ? 'border-red-500' : ''}
/>
{errors.expiresAt && (
<p className="text-sm text-red-500">{errors.expiresAt}</p>
)}
</div>
{/* Инструкции */}
<div className="space-y-2">
<Label htmlFor="instructions">Инструкции</Label>
<Textarea
id="instructions"
value={formData.instructions}
onChange={(e) => handleFieldChange('instructions', e.target.value)}
placeholder="Дополнительные инструкции для выполнения задачи"
rows={3}
/>
</div>
{/* Теги */}
<div className="space-y-2">
<Label htmlFor="tags">Теги</Label>
<div className="flex gap-2">
<Input
id="tags"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleTagInputKeyDown}
placeholder="Введите теги через запятую и нажмите Enter"
/>
<Button
type="button"
variant="outline"
onClick={handleAddTag}
disabled={!tagInput.trim()}
>
Добавить
</Button>
</div>
{formData.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{formData.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="gap-1">
{tag}
<button
type="button"
onClick={() => handleRemoveTag(tag)}
className="ml-1 hover:bg-destructive hover:text-destructive-foreground rounded-full p-0.5"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
</div>
{/* Изображение */}
<div className="space-y-2">
<ImageUpload
label="Изображение задачи"
value={formData.imageUrl}
onChange={(value) => handleFieldChange('imageUrl', value || '')}
uploadType="card-image"
/>
</div>
{/* ID пользователя (опциональное) */}
<div className="space-y-2">
<Label htmlFor="userId">ID пользователя (опционально)</Label>
<Input
id="userId"
value={formData.userId}
onChange={(e) => handleFieldChange('userId', e.target.value)}
placeholder="Оставьте пустым для общей задачи"
/>
</div>
{/* Кнопки */}
<div className="flex justify-end gap-2 pt-4 border-t">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isSubmitting}
>
Отмена
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Сохранение...' : task ? 'Сохранить' : 'Создать'}
</Button>
</div>
</form>
)
}

View file

@ -0,0 +1,61 @@
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import type { TaskRewardDto } from '@/types/models'
interface TaskRewardsListProps {
rewards: TaskRewardDto[]
className?: string
}
export function TaskRewardsList({ rewards, className }: TaskRewardsListProps) {
if (!rewards || rewards.length === 0) {
return (
<div className={cn('text-sm text-muted-foreground', className)}>
Нет наград
</div>
)
}
const getRewardLabel = (reward: TaskRewardDto): string => {
switch (reward.type) {
case 'xp':
return `${reward.amount} XP`
case 'coins':
return `${reward.amount} монет`
case 'achievement':
return reward.achievementId
? `Достижение: ${reward.achievementId}`
: 'Достижение'
default:
return `${reward.amount} ${reward.type}`
}
}
const getRewardColor = (type: TaskRewardDto['type']): string => {
switch (type) {
case 'xp':
return 'bg-blue-500 hover:bg-blue-600 text-white border-transparent'
case 'coins':
return 'bg-yellow-500 hover:bg-yellow-600 text-white border-transparent'
case 'achievement':
return 'bg-purple-500 hover:bg-purple-600 text-white border-transparent'
default:
return 'bg-gray-500 hover:bg-gray-600 text-white border-transparent'
}
}
return (
<div className={cn('flex flex-wrap gap-2', className)}>
{rewards.map((reward, index) => (
<Badge
key={index}
variant="default"
className={getRewardColor(reward.type)}
>
{getRewardLabel(reward)}
</Badge>
))}
</div>
)
}

View file

@ -0,0 +1,50 @@
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import type { TaskDto } from '@/types/models'
interface TaskStatusBadgeProps {
status: TaskDto['status']
className?: string
}
export function TaskStatusBadge({ status, className }: TaskStatusBadgeProps) {
const statusConfig = {
available: {
label: 'Доступна',
variant: 'default' as const,
className: 'bg-green-500 hover:bg-green-600 text-white border-transparent',
},
in_progress: {
label: 'В процессе',
variant: 'default' as const,
className: 'bg-blue-500 hover:bg-blue-600 text-white border-transparent',
},
completed: {
label: 'Завершена',
variant: 'default' as const,
className: 'bg-purple-500 hover:bg-purple-600 text-white border-transparent',
},
expired: {
label: 'Истекла',
variant: 'secondary' as const,
className: 'bg-gray-500 hover:bg-gray-600 text-white border-transparent',
},
failed: {
label: 'Провалена',
variant: 'destructive' as const,
className: 'bg-red-500 hover:bg-red-600 text-white border-transparent',
},
}
const config = statusConfig[status]
return (
<Badge
variant={config.variant}
className={cn(config.className, className)}
>
{config.label}
</Badge>
)
}

View file

@ -0,0 +1,37 @@
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import type { TaskDto } from '@/types/models'
interface TaskTypeBadgeProps {
type: TaskDto['type']
className?: string
}
export function TaskTypeBadge({ type, className }: TaskTypeBadgeProps) {
const typeConfig = {
app_internal: {
label: 'Внутренняя',
className: 'bg-indigo-500 hover:bg-indigo-600 text-white border-transparent',
},
external: {
label: 'Внешняя',
className: 'bg-orange-500 hover:bg-orange-600 text-white border-transparent',
},
social: {
label: 'Социальная',
className: 'bg-pink-500 hover:bg-pink-600 text-white border-transparent',
},
}
const config = typeConfig[type]
return (
<Badge
variant="default"
className={cn(config.className, className)}
>
{config.label}
</Badge>
)
}

View file

@ -0,0 +1,8 @@
export { TaskStatusBadge } from './TaskStatusBadge'
export { TaskTypeBadge } from './TaskTypeBadge'
export { TaskDifficultyBadge } from './TaskDifficultyBadge'
export { TaskRewardsList } from './TaskRewardsList'
export { TaskDetailsDialog } from './TaskDetailsDialog'
export { TaskCard } from './TaskCard'
export { TaskForm } from './TaskForm'

View file

@ -1,9 +1,9 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Users, FileText, Package, DollarSign, TrendingUp, Clock } from 'lucide-react' import { Users, FileText, Package, DollarSign, TrendingUp, Clock, CheckSquare } from 'lucide-react'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar } from 'recharts' import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar } from 'recharts'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { analyticsApi, type DashboardData, type ChartDataPoint } from '@/api/analytics' import { analyticsApi, type DashboardData, type ChartDataPoint, type TasksStats } from '@/api/analytics'
import { useAuthStore } from '@/stores/authStore' import { useAuthStore } from '@/stores/authStore'
export default function DashboardPage() { export default function DashboardPage() {
@ -30,6 +30,12 @@ export default function DashboardPage() {
enabled: isReady, enabled: isReady,
}) })
const { data: tasksStats } = useQuery<TasksStats>({
queryKey: ['tasks-stats'],
queryFn: analyticsApi.getTasksStats,
enabled: isReady,
})
if (dashboardLoading) { if (dashboardLoading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -65,7 +71,7 @@ export default function DashboardPage() {
</div> </div>
{/* Stats Cards */} {/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Users</CardTitle> <CardTitle className="text-sm font-medium">Total Users</CardTitle>
@ -117,6 +123,19 @@ export default function DashboardPage() {
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Tasks</CardTitle>
<CheckSquare className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{tasksStats?.active || 0}</div>
<p className="text-xs text-muted-foreground">
of {tasksStats?.total || 0} total tasks
</p>
</CardContent>
</Card>
</div> </div>
{/* Charts */} {/* Charts */}

View file

@ -0,0 +1,519 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { tasksApi } from '@/api/tasks'
import type { TaskDto, TaskFilters, PaginatedResponse } from '@/types/models'
import type { AxiosError } from 'axios'
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,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { TaskStatusBadge } from '@/components/tasks/TaskStatusBadge'
import { TaskTypeBadge } from '@/components/tasks/TaskTypeBadge'
import { TaskDifficultyBadge } from '@/components/tasks/TaskDifficultyBadge'
import { TaskDetailsDialog } from '@/components/tasks/TaskDetailsDialog'
import { TaskForm } from '@/components/tasks/TaskForm'
import { Search, Plus, Edit, Trash2, Eye, ChevronLeft, ChevronRight } from 'lucide-react'
export default function TasksPage() {
const queryClient = useQueryClient()
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const [filters, setFilters] = useState<TaskFilters>({})
const [selectedTask, setSelectedTask] = useState<TaskDto | null>(null)
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [isViewDialogOpen, setIsViewDialogOpen] = useState(false)
const [taskToDelete, setTaskToDelete] = useState<TaskDto | null>(null)
const limit = 20
// Fetch tasks with filters
const { data, isLoading, error } = useQuery<PaginatedResponse<TaskDto>>({
queryKey: ['tasks', page, search, filters],
queryFn: () =>
tasksApi.getTasks({
page,
limit,
search: search || undefined,
...filters,
}),
})
// Mutations
const createMutation = useMutation({
mutationFn: (taskData: Parameters<typeof tasksApi.createTask>[0]) =>
tasksApi.createTask(taskData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] })
toast.success('Задача успешно создана')
setIsCreateDialogOpen(false)
},
onError: (error: unknown) => {
const axiosError = error as AxiosError<{ message?: string }>
toast.error(
axiosError.response?.data?.message || 'Не удалось создать задачу'
)
},
})
const updateMutation = useMutation({
mutationFn: ({
taskId,
data,
}: {
taskId: string
data: Parameters<typeof tasksApi.updateTask>[1]
}) => tasksApi.updateTask(taskId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] })
toast.success('Задача успешно обновлена')
setIsEditDialogOpen(false)
setSelectedTask(null)
},
onError: (error: unknown) => {
const axiosError = error as AxiosError<{ message?: string }>
toast.error(
axiosError.response?.data?.message || 'Не удалось обновить задачу'
)
},
})
const deleteMutation = useMutation({
mutationFn: (taskId: string) => tasksApi.deleteTask(taskId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] })
toast.success('Задача успешно удалена')
setIsDeleteDialogOpen(false)
setTaskToDelete(null)
},
onError: (error: unknown) => {
const axiosError = error as AxiosError<{ message?: string }>
toast.error(
axiosError.response?.data?.message || 'Не удалось удалить задачу'
)
},
})
const handleCreate = async (
taskData: Parameters<typeof tasksApi.createTask>[0]
) => {
await createMutation.mutateAsync(taskData)
}
const handleEdit = (task: TaskDto) => {
setSelectedTask(task)
setIsEditDialogOpen(true)
}
const handleUpdate = async (
taskData: Parameters<typeof tasksApi.updateTask>[1]
) => {
if (!selectedTask?.id) {
toast.error('ID задачи не найден')
return
}
await updateMutation.mutateAsync({ taskId: selectedTask.id, data: taskData })
}
const handleDelete = (task: TaskDto) => {
setTaskToDelete(task)
setIsDeleteDialogOpen(true)
}
const confirmDelete = () => {
if (taskToDelete?.id) {
deleteMutation.mutate(taskToDelete.id)
}
}
const handleView = (task: TaskDto) => {
setSelectedTask(task)
setIsViewDialogOpen(true)
}
const handleFilterChange = <K extends keyof TaskFilters>(
key: K,
value: TaskFilters[K] | undefined
) => {
setFilters((prev) => {
const newFilters = { ...prev }
if (value === undefined || value === '') {
delete newFilters[key]
} else {
newFilters[key] = value
}
// Reset to first page when filters change
setPage(1)
return newFilters
})
}
const handleSearch = (value: string) => {
setSearch(value)
setPage(1) // Reset to first page when search changes
}
if (error) {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Управление задачами</h1>
<p className="text-muted-foreground">Ошибка загрузки задач</p>
</div>
<Card>
<CardContent className="pt-6">
<p className="text-red-500">
Не удалось загрузить задачи. Попробуйте обновить страницу.
</p>
</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">Управление задачами</h1>
<p className="text-muted-foreground">
Просмотр и управление задачами пользователей
</p>
</div>
<Button onClick={() => setIsCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Создать задачу
</Button>
</div>
{/* Search and Filters */}
<Card>
<CardHeader>
<CardTitle>Поиск и фильтры</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Search */}
<div className="flex items-center space-x-2">
<Search className="h-4 w-4 text-muted-foreground" />
<Input
placeholder="Поиск по названию или описанию..."
value={search}
onChange={(e) => handleSearch(e.target.value)}
className="max-w-sm"
/>
</div>
{/* Filters */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Тип</label>
<Select
value={filters.type || ''}
onValueChange={(value) =>
handleFilterChange('type', value ? (value as TaskFilters['type']) : undefined)
}
>
<SelectTrigger>
<SelectValue placeholder="Все типы" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Все типы</SelectItem>
<SelectItem value="app_internal">Внутреннее приложение</SelectItem>
<SelectItem value="external">Внешнее</SelectItem>
<SelectItem value="social">Социальное</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Сложность</label>
<Select
value={filters.difficulty || ''}
onValueChange={(value) =>
handleFilterChange('difficulty', value ? (value as TaskFilters['difficulty']) : undefined)
}
>
<SelectTrigger>
<SelectValue placeholder="Все уровни" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Все уровни</SelectItem>
<SelectItem value="easy">Легкая</SelectItem>
<SelectItem value="medium">Средняя</SelectItem>
<SelectItem value="hard">Сложная</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Статус</label>
<Select
value={filters.status || ''}
onValueChange={(value) =>
handleFilterChange('status', value ? (value as TaskFilters['status']) : undefined)
}
>
<SelectTrigger>
<SelectValue placeholder="Все статусы" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Все статусы</SelectItem>
<SelectItem value="available">Доступна</SelectItem>
<SelectItem value="in_progress">В процессе</SelectItem>
<SelectItem value="completed">Завершена</SelectItem>
<SelectItem value="expired">Истекла</SelectItem>
<SelectItem value="failed">Провалена</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Clear filters button */}
{(filters.type || filters.difficulty || filters.status) && (
<Button
variant="outline"
size="sm"
onClick={() => {
setFilters({})
setPage(1)
}}
>
Сбросить фильтры
</Button>
)}
</CardContent>
</Card>
{/* Tasks Table */}
<Card>
<CardHeader>
<CardTitle>Все задачи ({data?.total || 0})</CardTitle>
<CardDescription>Управление задачами пользователей</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8">Загрузка задач...</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Название</TableHead>
<TableHead>Тип</TableHead>
<TableHead>Сложность</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Срок действия</TableHead>
<TableHead>Действия</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.items && data.items.length > 0 ? (
data.items.map((task) => (
<TableRow key={task.id}>
<TableCell className="font-mono text-sm">
{task.id.slice(0, 8)}...
</TableCell>
<TableCell className="font-medium">{task.title}</TableCell>
<TableCell>
<TaskTypeBadge type={task.type} />
</TableCell>
<TableCell>
<TaskDifficultyBadge difficulty={task.difficulty} />
</TableCell>
<TableCell>
<TaskStatusBadge status={task.status} />
</TableCell>
<TableCell>
{new Date(task.expiresAt).toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})}
</TableCell>
<TableCell>
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleView(task)}
title="Просмотр"
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(task)}
title="Редактировать"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(task)}
title="Удалить"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
Задачи не найдены
</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">
Показано {((page - 1) * limit) + 1} до {Math.min(page * limit, data.total)} из{' '}
{data.total} задач
</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" />
Предыдущая
</Button>
<span className="text-sm">
Страница {page} из {data.totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage(page + 1)}
disabled={page >= data.totalPages}
>
Следующая
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
{/* Create Task Dialog */}
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Создать задачу</DialogTitle>
<DialogDescription>
Заполните форму для создания новой задачи
</DialogDescription>
</DialogHeader>
<TaskForm
onSave={handleCreate}
onCancel={() => setIsCreateDialogOpen(false)}
/>
</DialogContent>
</Dialog>
{/* Edit Task Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Редактировать задачу</DialogTitle>
<DialogDescription>
Обновите информацию о задаче
</DialogDescription>
</DialogHeader>
{selectedTask && (
<TaskForm
task={selectedTask}
onSave={handleUpdate}
onCancel={() => {
setIsEditDialogOpen(false)
setSelectedTask(null)
}}
/>
)}
</DialogContent>
</Dialog>
{/* View Task Dialog */}
{selectedTask && (
<TaskDetailsDialog
task={selectedTask}
open={isViewDialogOpen}
onClose={() => {
setIsViewDialogOpen(false)
setSelectedTask(null)
}}
/>
)}
{/* Delete Confirmation Dialog */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Удалить задачу</AlertDialogTitle>
<AlertDialogDescription>
Вы уверены, что хотите удалить задачу "{taskToDelete?.title || taskToDelete?.id}"?
Это действие нельзя отменить.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-red-600 hover:bg-red-700"
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? 'Удаление...' : 'Удалить'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View file

@ -196,3 +196,55 @@ export interface TestDto {
statistics?: unknown statistics?: unknown
packs?: TestPackInfo[] packs?: TestPackInfo[]
} }
// Task types
export interface TaskRewardDto {
type: 'xp' | 'coins' | 'achievement'
amount: number
achievementId?: string
}
export interface TaskDto {
id: string
title: string
description: string
type: 'app_internal' | 'external' | 'social'
difficulty: 'easy' | 'medium' | 'hard'
status: 'available' | 'in_progress' | 'completed' | 'expired' | 'failed'
rewards: TaskRewardDto[]
createdAt: string
expiresAt: string
completedAt?: string
proofUrl?: string
instructions?: string
tags: string[]
imageUrl?: string
userId?: string
}
export interface CreateTaskDto {
title: string
description: string
type: 'app_internal' | 'external' | 'social'
difficulty: 'easy' | 'medium' | 'hard'
rewards: TaskRewardDto[]
expiresAt: string
instructions?: string
tags?: string[]
imageUrl?: string
userId?: string
}
export interface UpdateTaskDto extends Partial<CreateTaskDto> {
status?: TaskDto['status']
completedAt?: string
proofUrl?: string
}
export interface TaskFilters {
type?: TaskDto['type']
difficulty?: TaskDto['difficulty']
status?: TaskDto['status']
userId?: string
search?: string
}

View file

@ -19,10 +19,26 @@ import '../../discounts/discounts_manager.dart' as _i891;
import '../../packs/free_packs_distributor.dart' as _i1062; import '../../packs/free_packs_distributor.dart' as _i1062;
import '../../packs/pack_dto_converter.dart' as _i433; import '../../packs/pack_dto_converter.dart' as _i433;
import '../../packs/pack_manager.dart' as _i833; import '../../packs/pack_manager.dart' as _i833;
import '../../packs/pack_repository.dart' as _i258;
import '../../packs/product_availability_manager.dart' as _i797; import '../../packs/product_availability_manager.dart' as _i797;
import '../../packs/products_price_resolver.dart' as _i908; import '../../packs/products_price_resolver.dart' as _i908;
import '../../promo_codes/promo_codes_manager.dart' as _i151; import '../../promo_codes/promo_codes_manager.dart' as _i151;
import '../../repository/%D1%81onverters/card_pack_converter.dart' as _i103;
import '../../repository/%D1%81onverters/export.dart' as _i381;
import '../../repository/%D1%81onverters/game_card_converter.dart' as _i650;
import '../../repository/%D1%81onverters/subscription_converter.dart' as _i768;
import '../../repository/%D1%81onverters/user_converter.dart' as _i491;
import '../../repository/achievement_repository.dart' as _i403;
import '../../repository/discount_repository.dart' as _i864;
import '../../repository/export.dart' as _i69;
import '../../repository/pack_repository.dart' as _i851;
import '../../repository/payment_repository.dart' as _i32;
import '../../repository/promo_code_repository.dart' as _i1060;
import '../../repository/statistics_repository.dart' as _i499;
import '../../repository/subscription_repository.dart' as _i1018;
import '../../repository/task_repository.dart' as _i499;
import '../../repository/test_repository.dart' as _i223;
import '../../repository/user_repository.dart' as _i384;
import '../../repository/word_statistics_repository.dart' as _i226;
import '../../statistics/achievement_manager.dart' as _i802; import '../../statistics/achievement_manager.dart' as _i802;
import '../../statistics/session_tracker.dart' as _i71; import '../../statistics/session_tracker.dart' as _i71;
import '../../statistics/statistics_calculator.dart' as _i1029; import '../../statistics/statistics_calculator.dart' as _i1029;
@ -33,7 +49,6 @@ import '../../tasks/task_manager.dart' as _i586;
import '../../tests/test_manager.dart' as _i259; import '../../tests/test_manager.dart' as _i259;
import '../../user/user_manager.dart' as _i280; import '../../user/user_manager.dart' as _i280;
import '../../user/user_manager_drift.dart' as _i560; import '../../user/user_manager_drift.dart' as _i560;
import '../../user/user_repository.dart' as _i950;
import '../ads/ads_manager.dart' as _i846; import '../ads/ads_manager.dart' as _i846;
import '../mnemo_shelf.dart' as _i561; import '../mnemo_shelf.dart' as _i561;
import '../purchase/payment_manager.dart' as _i1009; import '../purchase/payment_manager.dart' as _i1009;
@ -45,6 +60,7 @@ import '../v2/admin_analytics_api_v2.dart' as _i368;
import '../v2/admin_auth_api_v2.dart' as _i483; import '../v2/admin_auth_api_v2.dart' as _i483;
import '../v2/admin_cards_api_v2.dart' as _i922; import '../v2/admin_cards_api_v2.dart' as _i922;
import '../v2/admin_packs_api_v2.dart' as _i1015; import '../v2/admin_packs_api_v2.dart' as _i1015;
import '../v2/admin_tasks_api_v2.dart' as _i1071;
import '../v2/admin_tests_api_v2.dart' as _i116; import '../v2/admin_tests_api_v2.dart' as _i116;
import '../v2/admin_users_api_v2.dart' as _i895; import '../v2/admin_users_api_v2.dart' as _i895;
import '../v2/auth_api_v2.dart' as _i52; import '../v2/auth_api_v2.dart' as _i52;
@ -80,6 +96,14 @@ extension GetItInjectableX on _i174.GetIt {
gh.lazySingleton<_i240.TelegramAuthCodeService>( gh.lazySingleton<_i240.TelegramAuthCodeService>(
() => _i240.TelegramAuthCodeService(), () => _i240.TelegramAuthCodeService(),
); );
gh.lazySingleton<_i103.CardPackConverter>(() => _i103.CardPackConverter());
gh.lazySingleton<_i650.GameCardConverter>(() => _i650.GameCardConverter());
gh.lazySingleton<_i768.SubscriptionConverter>(
() => _i768.SubscriptionConverter(),
);
gh.lazySingleton<_i491.UserWithDataConverter>(
() => _i491.UserWithDataConverter(),
);
gh.lazySingleton<_i747.MinioService>( gh.lazySingleton<_i747.MinioService>(
() => _i747.MinioService(gh<_i533.MinioConfig>()), () => _i747.MinioService(gh<_i533.MinioConfig>()),
); );
@ -95,132 +119,213 @@ extension GetItInjectableX on _i174.GetIt {
gh<_i747.MinioService>(), gh<_i747.MinioService>(),
), ),
); );
gh.lazySingleton<_i259.TestManager>(
() =>
_i259.TestManager(gh<_i1072.AppDatabase>(), gh<_i747.MinioService>()),
);
gh.lazySingleton<_i377.SubscriptionManager>(
() => _i377.SubscriptionManager(gh<_i1072.AppDatabase>()),
);
gh.lazySingleton<_i108.JwtService>( gh.lazySingleton<_i108.JwtService>(
() => _i108.JwtService(gh<_i1072.AppDatabase>()), () => _i108.JwtService(gh<_i1072.AppDatabase>()),
); );
gh.lazySingleton<_i258.PackRepository>( gh.lazySingleton<_i403.AchievementRepository>(
() => _i258.PackRepository(gh<_i1072.AppDatabase>()), () => _i403.AchievementRepository(gh<_i1072.AppDatabase>()),
); );
gh.lazySingleton<_i71.SessionTracker>( gh.lazySingleton<_i864.DiscountRepository>(
() => _i71.SessionTracker(gh<_i1072.AppDatabase>()), () => _i864.DiscountRepository(gh<_i1072.AppDatabase>()),
); );
gh.lazySingleton<_i909.WordStatisticsManager>( gh.lazySingleton<_i32.PaymentRepository>(
() => _i909.WordStatisticsManager(gh<_i1072.AppDatabase>()), () => _i32.PaymentRepository(gh<_i1072.AppDatabase>()),
);
gh.lazySingleton<_i1060.PromoCodeRepository>(
() => _i1060.PromoCodeRepository(gh<_i1072.AppDatabase>()),
);
gh.lazySingleton<_i499.StatisticsRepository>(
() => _i499.StatisticsRepository(gh<_i1072.AppDatabase>()),
);
gh.lazySingleton<_i499.TaskRepository>(
() => _i499.TaskRepository(gh<_i1072.AppDatabase>()),
);
gh.lazySingleton<_i223.TestRepository>(
() => _i223.TestRepository(gh<_i1072.AppDatabase>()),
);
gh.lazySingleton<_i226.WordStatisticsRepository>(
() => _i226.WordStatisticsRepository(gh<_i1072.AppDatabase>()),
); );
gh.lazySingleton<_i586.TaskManager>( gh.lazySingleton<_i586.TaskManager>(
() => _i586.TaskManager(gh<_i1072.AppDatabase>()), () => _i586.TaskManager(
); gh<_i69.TaskRepository>(),
gh.lazySingleton<_i950.UserRepository>(
() => _i950.UserRepository(gh<_i1072.AppDatabase>()),
);
gh.lazySingleton<_i964.SubscriptionsApiV2>(
() => _i964.SubscriptionsApiV2(gh<_i377.SubscriptionManager>()),
);
gh.lazySingleton<_i296.TelegramBotApiV2>(
() => _i296.TelegramBotApiV2(
gh<_i1072.AppDatabase>(), gh<_i1072.AppDatabase>(),
gh<_i950.UserRepository>(),
), ),
); );
gh.lazySingleton<_i891.DiscountsManager>( gh.lazySingleton<_i71.SessionTracker>(
() => _i891.DiscountsManager( () => _i71.SessionTracker(gh<_i69.StatisticsRepository>()),
);
gh.lazySingleton<_i851.PackRepository>(
() => _i851.PackRepository(
gh<_i1072.AppDatabase>(), gh<_i1072.AppDatabase>(),
gh<_i950.UserRepository>(), gh<_i381.CardPackConverter>(),
gh<_i381.GameCardConverter>(),
), ),
); );
gh.lazySingleton<_i802.AchievementManager>( gh.lazySingleton<_i1018.SubscriptionRepository>(
() => _i802.AchievementManager( () => _i1018.SubscriptionRepository(
gh<_i1072.AppDatabase>(), gh<_i1072.AppDatabase>(),
gh<_i950.UserRepository>(), gh<_i768.SubscriptionConverter>(),
),
);
gh.lazySingleton<_i1029.StatisticsCalculator>(
() => _i1029.StatisticsCalculator(
gh<_i1072.AppDatabase>(),
gh<_i950.UserRepository>(),
),
);
gh.factory<_i895.AdminUsersApiV2>(
() => _i895.AdminUsersApiV2(
gh<_i1072.AppDatabase>(),
gh<_i950.UserRepository>(),
), ),
); );
gh.lazySingleton<_i365.MediaApiV2>( gh.lazySingleton<_i365.MediaApiV2>(
() => _i365.MediaApiV2(gh<_i747.MinioService>()), () => _i365.MediaApiV2(gh<_i747.MinioService>()),
); );
gh.factory<_i1071.AdminTasksApiV2>(
() => _i1071.AdminTasksApiV2(
gh<_i1072.AppDatabase>(),
gh<_i69.TaskRepository>(),
),
);
gh.lazySingleton<_i985.TasksApiV2>( gh.lazySingleton<_i985.TasksApiV2>(
() => _i985.TasksApiV2(gh<_i586.TaskManager>()), () => _i985.TasksApiV2(gh<_i586.TaskManager>()),
); );
gh.lazySingleton<_i491.UserConverter>(
() => _i491.UserConverter(gh<_i103.CardPackConverter>()),
);
gh.lazySingleton<_i909.WordStatisticsManager>(
() => _i909.WordStatisticsManager(gh<_i69.WordStatisticsRepository>()),
);
gh.factory<_i1015.AdminPacksApiV2>( gh.factory<_i1015.AdminPacksApiV2>(
() => _i1015.AdminPacksApiV2( () => _i1015.AdminPacksApiV2(
gh<_i1072.AppDatabase>(), gh<_i1072.AppDatabase>(),
gh<_i258.PackRepository>(), gh<_i69.PackRepository>(),
gh<_i69.TestRepository>(),
), ),
); );
gh.lazySingleton<_i368.AdminAnalyticsApiV2>( gh.lazySingleton<_i377.SubscriptionManager>(
() => _i368.AdminAnalyticsApiV2( () => _i377.SubscriptionManager(gh<_i69.SubscriptionRepository>()),
gh<_i950.UserRepository>(), );
gh.lazySingleton<_i259.TestManager>(
() => _i259.TestManager(
gh<_i1072.AppDatabase>(), gh<_i1072.AppDatabase>(),
gh<_i69.PackRepository>(),
gh<_i69.TestRepository>(),
gh<_i747.MinioService>(),
),
);
gh.lazySingleton<_i384.UserRepository>(
() => _i384.UserRepository(
gh<_i1072.AppDatabase>(),
gh<_i381.UserConverter>(),
gh<_i381.CardPackConverter>(),
gh<_i381.SubscriptionConverter>(),
),
);
gh.lazySingleton<_i891.DiscountsManager>(
() => _i891.DiscountsManager(
gh<_i69.DiscountRepository>(),
gh<_i69.UserRepository>(),
), ),
); );
gh.lazySingleton<_i908.ProductsPriceResolver>( gh.lazySingleton<_i908.ProductsPriceResolver>(
() => _i908.ProductsPriceResolver( () => _i908.ProductsPriceResolver(
gh<_i891.DiscountsManager>(), gh<_i891.DiscountsManager>(),
gh<_i950.UserRepository>(), gh<_i69.UserRepository>(),
), ),
); );
gh.lazySingleton<_i802.AchievementManager>(
() => _i802.AchievementManager(
gh<_i69.AchievementRepository>(),
gh<_i69.UserRepository>(),
),
);
gh.lazySingleton<_i368.AdminAnalyticsApiV2>(
() => _i368.AdminAnalyticsApiV2(
gh<_i69.UserRepository>(),
gh<_i1072.AppDatabase>(),
),
);
gh.lazySingleton<_i296.TelegramBotApiV2>(
() => _i296.TelegramBotApiV2(
gh<_i1072.AppDatabase>(),
gh<_i69.UserRepository>(),
),
);
gh.factory<_i895.AdminUsersApiV2>(
() => _i895.AdminUsersApiV2(
gh<_i1072.AppDatabase>(),
gh<_i69.UserRepository>(),
),
);
gh.lazySingleton<_i964.SubscriptionsApiV2>(
() => _i964.SubscriptionsApiV2(gh<_i377.SubscriptionManager>()),
);
gh.lazySingleton<_i858.DiscountsApiV2>( gh.lazySingleton<_i858.DiscountsApiV2>(
() => _i858.DiscountsApiV2(gh<_i891.DiscountsManager>()), () => _i858.DiscountsApiV2(gh<_i891.DiscountsManager>()),
); );
gh.lazySingleton<_i1029.StatisticsCalculator>(
() => _i1029.StatisticsCalculator(
gh<_i1072.AppDatabase>(),
gh<_i69.UserRepository>(),
gh<_i69.PackRepository>(),
gh<_i69.WordStatisticsRepository>(),
gh<_i69.StatisticsRepository>(),
),
);
gh.lazySingleton<_i433.PackDtoConverter>( gh.lazySingleton<_i433.PackDtoConverter>(
() => _i433.PackDtoConverter( () => _i433.PackDtoConverter(
gh<_i908.ProductsPriceResolver>(), gh<_i908.ProductsPriceResolver>(),
gh<_i846.AdsManager>(), gh<_i846.AdsManager>(),
gh<_i69.UserRepository>(),
), ),
); );
gh.lazySingleton<_i833.PackManager>( gh.lazySingleton<_i833.PackManager>(
() => _i833.PackManager( () => _i833.PackManager(
gh<_i1072.AppDatabase>(), gh<_i69.PackRepository>(),
gh<_i258.PackRepository>(),
gh<_i433.PackDtoConverter>(), gh<_i433.PackDtoConverter>(),
gh<_i1072.AppDatabase>(),
), ),
); );
gh.lazySingleton<_i797.ProductAvailabilityManager>( gh.lazySingleton<_i797.ProductAvailabilityManager>(
() => _i797.ProductAvailabilityManager( () => _i797.ProductAvailabilityManager(
gh<_i833.PackManager>(), gh<_i833.PackManager>(),
gh<_i1072.AppDatabase>(), gh<_i69.UserRepository>(),
), ),
); );
gh.lazySingleton<_i1062.FreePacksDistributor>( gh.lazySingleton<_i1062.FreePacksDistributor>(
() => _i1062.FreePacksDistributor( () => _i1062.FreePacksDistributor(
gh<_i797.ProductAvailabilityManager>(), gh<_i797.ProductAvailabilityManager>(),
gh<_i258.PackRepository>(), gh<_i69.PackRepository>(),
), ),
); );
gh.lazySingleton<_i280.UserManager>( gh.lazySingleton<_i1009.PaymentManager>(
() => _i280.UserManager( () => _i1009.PaymentManager(
gh<_i69.PaymentRepository>(),
gh<_i69.SubscriptionRepository>(),
gh<_i69.UserRepository>(),
gh<_i797.ProductAvailabilityManager>(),
gh<_i988.YooMoneyHandler>(),
gh<_i222.RustorePurchaseHandler>(),
gh<_i1072.AppDatabase>(), gh<_i1072.AppDatabase>(),
gh<_i950.UserRepository>(),
gh<_i1062.FreePacksDistributor>(),
gh<_i71.SessionTracker>(),
gh<_i1029.StatisticsCalculator>(),
gh<_i802.AchievementManager>(),
gh<_i909.WordStatisticsManager>(),
), ),
); );
gh.lazySingleton<_i483.AdminAuthApiV2>( gh.lazySingleton<_i800.PacksApiV2>(
() => _i483.AdminAuthApiV2( () => _i800.PacksApiV2(
gh<_i240.TelegramAuthCodeService>(), gh<_i833.PackManager>(),
gh<_i280.UserManager>(), gh<_i259.TestManager>(),
gh<_i108.JwtService>(), gh<_i69.PackRepository>(),
gh<_i797.ProductAvailabilityManager>(),
gh<_i1072.AppDatabase>(),
gh<_i747.MinioService>(),
),
);
gh.lazySingleton<_i69.PurchasesApiV2>(
() => _i69.PurchasesApiV2(
gh<_i1009.PaymentManager>(),
gh<_i833.PackManager>(),
gh<_i69.UserRepository>(),
gh<_i69.SubscriptionRepository>(),
gh<_i69.PaymentRepository>(),
gh<_i797.ProductAvailabilityManager>(),
gh<_i1072.AppDatabase>(),
),
);
gh.lazySingleton<_i151.PromoCodesManager>(
() => _i151.PromoCodesManager(
gh<_i69.PromoCodeRepository>(),
gh<_i1009.PaymentManager>(),
gh<_i1072.AppDatabase>(),
), ),
); );
gh.lazySingleton<_i560.UserManager>( gh.lazySingleton<_i560.UserManager>(
@ -232,35 +337,20 @@ extension GetItInjectableX on _i174.GetIt {
gh<_i802.AchievementManager>(), gh<_i802.AchievementManager>(),
), ),
); );
gh.lazySingleton<_i561.MnemoShelf>( gh.lazySingleton<_i280.UserManager>(
() => _i561.MnemoShelf(gh<_i280.UserManager>()), () => _i280.UserManager(
);
gh.lazySingleton<_i52.AuthApiV2>(
() => _i52.AuthApiV2(
gh<_i280.UserManager>(),
gh<_i972.GoogleApi>(),
gh<_i108.JwtService>(),
gh<_i240.TelegramAuthCodeService>(),
),
);
gh.lazySingleton<_i1009.PaymentManager>(
() => _i1009.PaymentManager(
gh<_i1072.AppDatabase>(), gh<_i1072.AppDatabase>(),
gh<_i950.UserRepository>(), gh<_i69.UserRepository>(),
gh<_i797.ProductAvailabilityManager>(), gh<_i69.PackRepository>(),
gh<_i988.YooMoneyHandler>(), gh<_i1062.FreePacksDistributor>(),
gh<_i222.RustorePurchaseHandler>(), gh<_i71.SessionTracker>(),
gh<_i1029.StatisticsCalculator>(),
gh<_i802.AchievementManager>(),
gh<_i909.WordStatisticsManager>(),
), ),
); );
gh.lazySingleton<_i800.PacksApiV2>( gh.lazySingleton<_i273.PromocodesApiV2>(
() => _i800.PacksApiV2( () => _i273.PromocodesApiV2(gh<_i151.PromoCodesManager>()),
gh<_i833.PackManager>(),
gh<_i259.TestManager>(),
gh<_i258.PackRepository>(),
gh<_i797.ProductAvailabilityManager>(),
gh<_i1072.AppDatabase>(),
gh<_i747.MinioService>(),
),
); );
gh.lazySingleton<_i735.CheckPaymentTask>( gh.lazySingleton<_i735.CheckPaymentTask>(
() => _i735.CheckPaymentTask( () => _i735.CheckPaymentTask(
@ -275,13 +365,11 @@ extension GetItInjectableX on _i174.GetIt {
gh<_i1072.AppDatabase>(), gh<_i1072.AppDatabase>(),
), ),
); );
gh.lazySingleton<_i69.PurchasesApiV2>( gh.lazySingleton<_i483.AdminAuthApiV2>(
() => _i69.PurchasesApiV2( () => _i483.AdminAuthApiV2(
gh<_i1009.PaymentManager>(), gh<_i240.TelegramAuthCodeService>(),
gh<_i833.PackManager>(), gh<_i280.UserManager>(),
gh<_i950.UserRepository>(), gh<_i108.JwtService>(),
gh<_i797.ProductAvailabilityManager>(),
gh<_i1072.AppDatabase>(),
), ),
); );
gh.lazySingleton<_i247.UsersApiV2>( gh.lazySingleton<_i247.UsersApiV2>(
@ -289,18 +377,20 @@ extension GetItInjectableX on _i174.GetIt {
gh<_i280.UserManager>(), gh<_i280.UserManager>(),
gh<_i1009.PaymentManager>(), gh<_i1009.PaymentManager>(),
gh<_i1029.StatisticsCalculator>(), gh<_i1029.StatisticsCalculator>(),
gh<_i950.UserRepository>(), gh<_i69.UserRepository>(),
gh<_i1072.AppDatabase>(), gh<_i1072.AppDatabase>(),
), ),
); );
gh.lazySingleton<_i151.PromoCodesManager>( gh.lazySingleton<_i561.MnemoShelf>(
() => _i151.PromoCodesManager( () => _i561.MnemoShelf(gh<_i280.UserManager>()),
gh<_i1072.AppDatabase>(),
gh<_i1009.PaymentManager>(),
),
); );
gh.lazySingleton<_i273.PromocodesApiV2>( gh.lazySingleton<_i52.AuthApiV2>(
() => _i273.PromocodesApiV2(gh<_i151.PromoCodesManager>()), () => _i52.AuthApiV2(
gh<_i280.UserManager>(),
gh<_i972.GoogleApi>(),
gh<_i108.JwtService>(),
gh<_i240.TelegramAuthCodeService>(),
),
); );
return this; return this;
} }

View file

@ -10,6 +10,7 @@ import 'v2/admin_analytics_api_v2.dart';
import 'v2/admin_auth_api_v2.dart'; import 'v2/admin_auth_api_v2.dart';
import 'v2/admin_cards_api_v2.dart'; import 'v2/admin_cards_api_v2.dart';
import 'v2/admin_packs_api_v2.dart'; import 'v2/admin_packs_api_v2.dart';
import 'v2/admin_tasks_api_v2.dart';
import 'v2/admin_tests_api_v2.dart'; import 'v2/admin_tests_api_v2.dart';
import 'v2/admin_users_api_v2.dart'; import 'v2/admin_users_api_v2.dart';
import 'v2/auth_api_v2.dart'; import 'v2/auth_api_v2.dart';
@ -56,6 +57,7 @@ class MnemoShelf {
v2Router.mount('/', getIt.get<AdminAnalyticsApiV2>().router); v2Router.mount('/', getIt.get<AdminAnalyticsApiV2>().router);
v2Router.mount('/', getIt.get<AdminCardsApiV2>().router); v2Router.mount('/', getIt.get<AdminCardsApiV2>().router);
v2Router.mount('/', getIt.get<AdminPacksApiV2>().router); v2Router.mount('/', getIt.get<AdminPacksApiV2>().router);
v2Router.mount('/', getIt.get<AdminTasksApiV2>().router);
v2Router.mount('/', getIt.get<AdminTestsApiV2>().router); v2Router.mount('/', getIt.get<AdminTestsApiV2>().router);
v2Router.mount('/', getIt.get<AdminUsersApiV2>().router); v2Router.mount('/', getIt.get<AdminUsersApiV2>().router);
v2Router.mount('/', getIt.get<PacksApiV2>().router); v2Router.mount('/', getIt.get<PacksApiV2>().router);

View file

@ -5,7 +5,7 @@ import 'package:googleapis/androidpublisher/v3.dart' as ap;
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'constaints.dart'; import 'constaints.dart';
import 'iap_repository.dart'; import '../../repository/export.dart';
import 'products.dart'; import 'products.dart';
class GooglePlayPurchaseHandler { class GooglePlayPurchaseHandler {

View file

@ -1,227 +0,0 @@
import 'package:googleapis/firestore/v1.dart';
import 'products.dart';
enum IAPSource { googleplay, appstore }
abstract class Purchase {
final IAPSource iapSource;
final String orderId;
final String productId;
final String? userId;
final DateTime purchaseDate;
final ProductType type;
const Purchase({
required this.iapSource,
required this.orderId,
required this.productId,
required this.userId,
required this.purchaseDate,
required this.type,
});
Map<String, Value> toDocument() {
return {
'iapSource': Value(stringValue: iapSource.name),
'orderId': Value(stringValue: orderId),
'productId': Value(stringValue: productId),
'userId': Value(stringValue: userId),
'purchaseDate': Value(
timestampValue: purchaseDate.toUtc().toIso8601String(),
),
'type': Value(stringValue: type.name),
};
}
Map<String, Value> updateDocument();
static Purchase fromDocument(Document e) {
final type = ProductType.values.firstWhere(
(element) => element.name == e.fields!['type']!.stringValue,
);
switch (type) {
case ProductType.subscription:
return SubscriptionPurchase(
iapSource: e.fields!['iapSource']!.stringValue == 'googleplay'
? IAPSource.googleplay
: IAPSource.appstore,
orderId: e.fields!['orderId']!.stringValue!,
productId: e.fields!['productId']!.stringValue!,
userId: e.fields!['userId']!.stringValue,
purchaseDate: DateTime.parse(
e.fields!['purchaseDate']!.timestampValue!,
),
status: SubscriptionStatus.values.firstWhere(
(element) => element.name == e.fields!['status']!.stringValue,
),
expiryDate:
DateTime.tryParse(
e.fields!['expiryDate']?.timestampValue ?? '',
) ??
DateTime.now(),
);
case ProductType.nonSubscription:
return NonSubscriptionPurchase(
iapSource: e.fields!['iapSource']!.stringValue == 'googleplay'
? IAPSource.googleplay
: IAPSource.appstore,
orderId: e.fields!['orderId']!.stringValue!,
productId: e.fields!['productId']!.stringValue!,
userId: e.fields!['userId']!.stringValue,
purchaseDate: DateTime.parse(
e.fields!['purchaseDate']!.timestampValue!,
),
status: NonSubscriptionStatus.values.firstWhere(
(element) => element.name == e.fields!['status']!.stringValue,
),
);
}
}
}
enum NonSubscriptionStatus { pending, completed, cancelled }
enum SubscriptionStatus { pending, active, expired }
class NonSubscriptionPurchase extends Purchase {
final NonSubscriptionStatus status;
NonSubscriptionPurchase({
required super.iapSource,
required super.orderId,
required super.productId,
required super.userId,
required super.purchaseDate,
required this.status,
super.type = ProductType.nonSubscription,
});
@override
Map<String, Value> toDocument() {
final doc = super.toDocument();
doc.addAll({'status': Value(stringValue: status.name)});
return doc;
}
@override
Map<String, Value> updateDocument() {
return {'status': Value(stringValue: status.name)};
}
@override
String toString() {
return 'NonSubscriptionPurchase { '
'iapSource: $iapSource, '
'orderId: $orderId, '
'productId: $productId, '
'userId: $userId, '
'purchaseDate: $purchaseDate, '
'status: $status, '
'type: $type '
'}';
}
}
class SubscriptionPurchase extends Purchase {
final SubscriptionStatus status;
final DateTime expiryDate;
SubscriptionPurchase({
required super.iapSource,
required super.orderId,
required super.productId,
required super.userId,
required super.purchaseDate,
required this.status,
required this.expiryDate,
super.type = ProductType.subscription,
});
@override
Map<String, Value> toDocument() {
final doc = super.toDocument();
doc.addAll({
'expiryDate': Value(timestampValue: expiryDate.toUtc().toIso8601String()),
'status': Value(stringValue: status.name),
});
return doc;
}
@override
Map<String, Value> updateDocument() {
return {'status': Value(stringValue: status.name)};
}
@override
String toString() {
return 'SubscriptionPurchase { '
'iapSource: $iapSource, '
'orderId: $orderId, '
'productId: $productId, '
'userId: $userId, '
'purchaseDate: $purchaseDate, '
'status: $status, '
'expiryDate: $expiryDate, '
'type: $type '
'}';
}
}
class IapRepository {
final FirestoreApi api;
final String projectId;
IapRepository(this.api, this.projectId);
Future<void> createOrUpdatePurchase(Purchase purchaseData) async {
print('Updating $purchaseData');
final purchaseId = _purchaseId(purchaseData);
await api.projects.databases.documents.commit(
CommitRequest(
writes: [
Write(
update: Document(
fields: purchaseData.toDocument(),
name:
'projects/$projectId/databases/(default)/documents/purchases/$purchaseId',
),
),
],
),
'projects/$projectId/databases/(default)',
);
}
Future<void> updatePurchase(Purchase purchaseData) async {
print('Updating $purchaseData');
final purchaseId = _purchaseId(purchaseData);
await api.projects.databases.documents.commit(
CommitRequest(
writes: [
Write(
update: Document(
fields: purchaseData.updateDocument(),
name:
'projects/$projectId/databases/(default)/documents/purchases/$purchaseId',
),
updateMask: DocumentMask(fieldPaths: ['status']),
),
],
),
'projects/$projectId/databases/(default)',
);
}
String _purchaseId(Purchase purchaseData) {
return '${purchaseData.iapSource.name}_${purchaseData.orderId}';
}
Future<List<Purchase>> getPurchases() async {
final list = await api.projects.databases.documents.list(
'projects/$projectId/databases/(default)/documents',
'purchases',
);
return list.documents!.map((e) => Purchase.fromDocument(e)).toList();
}
}

View file

@ -7,11 +7,10 @@ import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:drift/drift.dart' as drift; import 'package:drift/drift.dart' as drift;
import 'payment_drift_extension.dart';
import 'rustore/rustore_purchase_handler.dart'; import 'rustore/rustore_purchase_handler.dart';
import 'yoo_money.dart'; import 'yoo_money.dart';
import '../../packs/product_availability_manager.dart'; import '../../packs/product_availability_manager.dart';
import '../../user/user_repository.dart'; import '../../repository/export.dart';
/// Result of creating YooKassa payment URL /// Result of creating YooKassa payment URL
class YookassaPaymentResult { class YookassaPaymentResult {
@ -26,19 +25,23 @@ class YookassaPaymentResult {
@lazySingleton @lazySingleton
class PaymentManager { class PaymentManager {
final AppDatabase _db; final PaymentRepository _paymentRepository;
final SubscriptionRepository _subscriptionRepository;
final UserRepository _userRepository; final UserRepository _userRepository;
final ProductAvailabilityManager _productAvailabilityManager; final ProductAvailabilityManager _productAvailabilityManager;
late final GooglePlayPurchaseHandler googlePurchaseHandler; late final GooglePlayPurchaseHandler googlePurchaseHandler;
final YooMoneyHandler _yooMoneyHandler; final YooMoneyHandler _yooMoneyHandler;
final RustorePurchaseHandler _rustorePurchaseHandler; final RustorePurchaseHandler _rustorePurchaseHandler;
final AppDatabase _db;
PaymentManager( PaymentManager(
this._db, this._paymentRepository,
this._subscriptionRepository,
this._userRepository, this._userRepository,
this._productAvailabilityManager, this._productAvailabilityManager,
this._yooMoneyHandler, this._yooMoneyHandler,
this._rustorePurchaseHandler, this._rustorePurchaseHandler,
this._db,
); );
/// Создать платеж в базе данных /// Создать платеж в базе данных
@ -47,32 +50,22 @@ class PaymentManager {
print( print(
'🔍 PaymentManager.createPayment: userId=$userId, externalToken=${paymentDto.externalToken}', '🔍 PaymentManager.createPayment: userId=$userId, externalToken=${paymentDto.externalToken}',
); );
final companion = paymentDto.toCompanion(userId); print(
print('🔍 PaymentManager.createPayment: Calling paymentDao.createPayment'); '🔍 PaymentManager.createPayment: Calling paymentRepository.createPayment',
final paymentId = await _db.paymentDao.createPayment(companion); );
print('✅ PaymentManager.createPayment: Payment created, id=$paymentId'); final payment = await _paymentRepository.createPayment(paymentDto, userId);
print('🔍 PaymentManager.createPayment: Getting payment by id'); print('✅ PaymentManager.createPayment: Payment created successfully');
final payment = await _db.paymentDao.getPaymentById(paymentId); return payment;
if (payment == null) {
print(
'❌ PaymentManager.createPayment: Payment not found after creation, id=$paymentId',
);
throw Exception('Failed to create payment');
}
print('✅ PaymentManager.createPayment: Payment retrieved successfully');
return payment.toDto();
} }
/// Обновить платеж в базе данных /// Обновить платеж в базе данных
Future<void> updatePayment(String paymentId, PaymentDto paymentDto) async { Future<void> updatePayment(String paymentId, PaymentDto paymentDto) async {
final companion = paymentDto.toUpdateCompanion(); await _paymentRepository.updatePayment(paymentId, paymentDto);
await _db.paymentDao.updatePaymentCompanion(paymentId, companion);
} }
/// Получить платеж по ID /// Получить платеж по ID
Future<PaymentDto?> getPaymentById(String id) async { Future<PaymentDto?> getPaymentById(String id) async {
final payment = await _db.paymentDao.getPaymentById(id); return await _paymentRepository.getPaymentById(id);
return payment?.toDto();
} }
/// Получить последний платеж для пользователя и пака /// Получить последний платеж для пользователя и пака
@ -81,11 +74,10 @@ class PaymentManager {
required String userId, required String userId,
required String packId, required String packId,
}) async { }) async {
final payment = await _db.paymentDao.getLatestPaymentForUserAndPack( return await _paymentRepository.getLatestPaymentForUserAndPack(
userId: userId, userId: userId,
packId: packId, packId: packId,
); );
return payment?.toDto();
} }
/// Выдать продукт пользователю (для промокодов и других бесплатных активаций) /// Выдать продукт пользователю (для промокодов и других бесплатных активаций)
@ -104,17 +96,17 @@ class PaymentManager {
} else if (product.type == MnemoCardsProductType.subscription && } else if (product.type == MnemoCardsProductType.subscription &&
product.id != null) { product.id != null) {
final planId = product.id!; final planId = product.id!;
final plan = await _db.subscriptionDao.getPlanById(planId); final plan = await _subscriptionRepository.getPlanById(planId);
if (plan != null) { if (plan != null) {
final now = DateTime.now(); final now = DateTime.now();
final endDate = now.add(Duration(days: plan.durationDays)); final endDate = now.add(Duration(days: plan.durationDays));
await _db.subscriptionDao.createUserSubscription( await _subscriptionRepository.createSubscription(
UserSubscriptionsCompanion.insert( UserSubscriptionsCompanion.insert(
userId: userId, userId: userId,
start: PgDateTime(now), start: PgDateTime(now),
finish: PgDateTime(endDate), finish: PgDateTime(endDate),
features: drift.Value(plan.features), features: drift.Value(plan.features.map((f) => f.name).toList()),
), ),
); );
log('Granted subscription $planId to user $userId via promo code'); log('Granted subscription $planId to user $userId via promo code');
@ -135,10 +127,7 @@ class PaymentManager {
} }
// Получить пользователя // Получить пользователя
final userModel = await _userRepository.getUserById( final userModel = await _userRepository.getUserById(payment.userId);
payment.userId,
withData: true,
);
if (userModel == null) { if (userModel == null) {
log('User not found: ${payment.userId}'); log('User not found: ${payment.userId}');
return; return;
@ -188,17 +177,17 @@ class PaymentManager {
// Создать подписки // Создать подписки
for (final subscriptionId in subscriptionIds) { for (final subscriptionId in subscriptionIds) {
final plan = await _db.subscriptionDao.getPlanById(subscriptionId); final plan = await _subscriptionRepository.getPlanById(subscriptionId);
if (plan != null) { if (plan != null) {
final now = DateTime.now(); final now = DateTime.now();
final endDate = now.add(Duration(days: plan.durationDays)); final endDate = now.add(Duration(days: plan.durationDays));
await _db.subscriptionDao.createUserSubscription( await _subscriptionRepository.createSubscription(
UserSubscriptionsCompanion.insert( UserSubscriptionsCompanion.insert(
userId: payment.userId, userId: payment.userId,
start: PgDateTime(now), start: PgDateTime(now),
finish: PgDateTime(endDate), finish: PgDateTime(endDate),
features: drift.Value(plan.features), features: drift.Value(plan.features.map((f) => f.name).toList()),
), ),
); );
log( log(
@ -215,7 +204,7 @@ class PaymentManager {
try { try {
// Try to update by externalToken (if it's used as identifier) // Try to update by externalToken (if it's used as identifier)
// TODO: Once database is regenerated, use payment.id directly // TODO: Once database is regenerated, use payment.id directly
await _db.paymentDao.updatePaymentStatus( await _paymentRepository.updatePaymentStatus(
payment.externalToken!, payment.externalToken!,
PaymentStatus.processed.name, PaymentStatus.processed.name,
); );
@ -294,24 +283,30 @@ class PaymentManager {
.checkPayment(subscriptionToken); .checkPayment(subscriptionToken);
// Найти платеж по продукту // Найти платеж по продукту
final payments = await _db.paymentDao.getPaymentsByProduct(productId); final paymentsDto = await _paymentRepository.getPaymentsByProduct(
final payment = payments.isNotEmpty ? payments.first : null; productId,
);
final paymentDto = paymentsDto.isNotEmpty ? paymentsDto.first : null;
if (payment == null) { if (paymentDto == null) {
log('Payment not found for product: $productId'); log('Payment not found for product: $productId');
return false; return false;
} }
if (rustorePurchaseResponse?.invoiceStatus.name == 'paid') { if (rustorePurchaseResponse?.invoiceStatus.name == 'paid') {
// Find payment id by productId or use subscriptionToken // Find payment id by productId or use subscriptionToken
final token = payment.externalToken ?? subscriptionToken; final token = paymentDto.externalToken ?? subscriptionToken;
if (token.isNotEmpty) { if (token.isNotEmpty) {
await _db.paymentDao.updatePaymentStatus( await _paymentRepository.updatePaymentStatus(
token, token,
PaymentStatus.succeeded.name, PaymentStatus.succeeded.name,
); );
} }
await processPayment(payment); // Обработать платеж - нужно получить Payment из БД для processPayment
final payment = await _db.paymentDao.getPaymentByExternalToken(token);
if (payment != null) {
await processPayment(payment);
}
return true; return true;
} }
@ -325,8 +320,10 @@ class PaymentManager {
/// Проверить платеж YooKassa /// Проверить платеж YooKassa
Future<bool> checkYookassaPayment(String token) async { Future<bool> checkYookassaPayment(String token) async {
try { try {
final payment = await _db.paymentDao.getPaymentByExternalToken(token); final paymentDto = await _paymentRepository.getPaymentByExternalToken(
if (payment == null) { token,
);
if (paymentDto == null) {
print('Payment not found for token: $token'); print('Payment not found for token: $token');
return false; return false;
} }
@ -336,14 +333,18 @@ class PaymentManager {
if (yookassaPayment.status == 'succeeded') { if (yookassaPayment.status == 'succeeded') {
// Use token as payment identifier // Use token as payment identifier
await _db.paymentDao.updatePaymentStatus( await _paymentRepository.updatePaymentStatus(
token, token,
PaymentStatus.succeeded.name, PaymentStatus.succeeded.name,
); );
await processPayment(payment); // Обработать платеж - нужно получить Payment из БД для processPayment
final payment = await _db.paymentDao.getPaymentByExternalToken(token);
if (payment != null) {
await processPayment(payment);
}
return true; return true;
} else if (yookassaPayment.status == 'canceled') { } else if (yookassaPayment.status == 'canceled') {
await _db.paymentDao.updatePaymentStatus( await _paymentRepository.updatePaymentStatus(
token, token,
PaymentStatus.canceled.name, PaymentStatus.canceled.name,
); );
@ -415,8 +416,7 @@ class PaymentManager {
/// Получить платежи пользователя /// Получить платежи пользователя
Future<List<PaymentDto>> getUserPayments(String userIdString) async { Future<List<PaymentDto>> getUserPayments(String userIdString) async {
final payments = await _db.paymentDao.getPaymentsByUserId(userIdString); return await _paymentRepository.getPaymentsByUserId(userIdString);
return payments.map((p) => p.toDto()).toList();
} }
/// Проверить и обработать платеж (для cron задач) /// Проверить и обработать платеж (для cron задач)

View file

@ -259,11 +259,7 @@ class YooMoneyHandler {
/// Build return URL for payment confirmation /// Build return URL for payment confirmation
/// Uses YOOKASSA_RETURN_URL env var if set, otherwise defaults to web app URL /// Uses YOOKASSA_RETURN_URL env var if set, otherwise defaults to web app URL
String _buildReturnUrl( String _buildReturnUrl(String userId, {String? packId, String? paymentId}) {
String userId, {
String? packId,
String? paymentId,
}) {
// Construct query parameters // Construct query parameters
final params = <String, String>{ final params = <String, String>{
'userId': userId, 'userId': userId,

View file

@ -1,15 +1,16 @@
import 'package:drift_postgres/drift_postgres.dart'; import 'package:drift_postgres/drift_postgres.dart';
import 'package:injectable/injectable.dart'; import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/database/database.dart'; import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/repository/export.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
@lazySingleton @lazySingleton
class SubscriptionManager { class SubscriptionManager {
final AppDatabase _db; final SubscriptionRepository _subscriptionRepository;
SubscriptionManager(this._db); SubscriptionManager(this._subscriptionRepository);
Future<SubscriptionDto> getSubscriptionDto(UserModel user) async { Future<SubscriptionDto> getSubscriptionDto(UserModel user) async {
if (user.id == null) { if (user.id == null) {
@ -21,15 +22,15 @@ class SubscriptionManager {
); );
} }
final subscription = await _db.subscriptionDao.getActiveUserSubscription( final subscription = await _subscriptionRepository.getActiveSubscription(
user.id!, user.id!,
); );
return SubscriptionDto( return SubscriptionDto(
page: null, page: null,
isActive: subscription != null, isActive: subscription != null,
start: subscription?.start.dateTime, start: subscription?.start,
finish: subscription?.finish.dateTime, finish: subscription?.finish,
); );
} }
@ -44,7 +45,7 @@ class SubscriptionManager {
final now = DateTime.now(); final now = DateTime.now();
final endDate = now.add(Duration(days: plan.durationDays)); final endDate = now.add(Duration(days: plan.durationDays));
await _db.subscriptionDao.createUserSubscription( await _subscriptionRepository.createSubscription(
UserSubscriptionsCompanion.insert( UserSubscriptionsCompanion.insert(
userId: user.id!, userId: user.id!,
start: PgDateTime(now), start: PgDateTime(now),
@ -55,25 +56,7 @@ class SubscriptionManager {
} }
Future<List<SubscriptionPlanModel>> getAllPlans() async { Future<List<SubscriptionPlanModel>> getAllPlans() async {
final plans = await _db.subscriptionDao.getAllPlans(); return await _subscriptionRepository.getAllPlans();
return plans.map((plan) {
final uiMap = plan.ui as Map<String, dynamic>?;
final ui = uiMap != null ? SubscriptionPlanUI.fromJson(uiMap) : null;
return SubscriptionPlanModel(
id: plan.id,
ui: ui,
price: plan.price,
currency: plan.currency,
durationDays: plan.durationDays,
features: [],
paymentSystem: PaymentSystem.values.firstWhere(
(ps) => ps.name == plan.paymentSystem,
orElse: () => PaymentSystem.unknown,
),
paymentId: plan.paymentId,
);
}).toList();
} }
Future<List<SubscriptionPlanModel>> getAllSubscriptionPlans() async { Future<List<SubscriptionPlanModel>> getAllSubscriptionPlans() async {
@ -81,7 +64,7 @@ class SubscriptionManager {
} }
Future<void> purchaseSubscription(String userId, String planId) async { Future<void> purchaseSubscription(String userId, String planId) async {
final plan = await _db.subscriptionDao.getPlanById(planId); final plan = await _subscriptionRepository.getPlanById(planId);
if (plan == null) { if (plan == null) {
throw StateError('Subscription plan not found'); throw StateError('Subscription plan not found');
} }
@ -89,17 +72,17 @@ class SubscriptionManager {
final now = DateTime.now(); final now = DateTime.now();
final endDate = now.add(Duration(days: plan.durationDays)); final endDate = now.add(Duration(days: plan.durationDays));
await _db.subscriptionDao.createUserSubscription( await _subscriptionRepository.createSubscription(
UserSubscriptionsCompanion.insert( UserSubscriptionsCompanion.insert(
userId: userId, userId: userId,
start: PgDateTime(now), start: PgDateTime(now),
finish: PgDateTime(endDate), finish: PgDateTime(endDate),
features: Value(plan.features as List<dynamic>), features: Value(plan.features.map((f) => f.name).toList()),
), ),
); );
} }
Future<void> cancelSubscription(String userId) async { Future<void> cancelSubscription(String userId) async {
await _db.subscriptionDao.cancelUserSubscription(userId); await _subscriptionRepository.cancelSubscription(userId);
} }
} }

View file

@ -5,7 +5,7 @@ import 'package:mnemo_cards_backend/api/authorize/acl_types.dart';
import 'package:mnemo_cards_backend/api/authorize/access_service.dart'; import 'package:mnemo_cards_backend/api/authorize/access_service.dart';
import 'package:mnemo_cards_backend/api/authorize/helpers.dart'; import 'package:mnemo_cards_backend/api/authorize/helpers.dart';
import 'package:mnemo_cards_backend/database/database.dart'; import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/user/user_repository.dart'; import 'package:mnemo_cards_backend/repository/export.dart';
import 'package:shelf/shelf.dart'; import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart'; import 'package:shelf_router/shelf_router.dart';

View file

@ -9,7 +9,7 @@ import 'package:mnemo_cards_backend/api/authorize/access_service.dart';
import 'package:mnemo_cards_backend/api/authorize/helpers.dart'; import 'package:mnemo_cards_backend/api/authorize/helpers.dart';
import 'package:mnemo_cards_backend/packs/card_pack_drift_extension.dart'; import 'package:mnemo_cards_backend/packs/card_pack_drift_extension.dart';
import 'package:mnemo_cards_backend/packs/card_image_storage.dart'; import 'package:mnemo_cards_backend/packs/card_image_storage.dart';
import 'package:mnemo_cards_backend/packs/pack_repository.dart'; import 'package:mnemo_cards_backend/repository/export.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:shelf/shelf.dart'; import 'package:shelf/shelf.dart';
@ -22,8 +22,9 @@ part 'admin_packs_api_v2.g.dart';
class AdminPacksApiV2 { class AdminPacksApiV2 {
final AppDatabase _db; final AppDatabase _db;
final PackRepository _packRepository; final PackRepository _packRepository;
final TestRepository _testRepository;
AdminPacksApiV2(this._db, this._packRepository); AdminPacksApiV2(this._db, this._packRepository, this._testRepository);
static final _uuidRegex = RegExp( static final _uuidRegex = RegExp(
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
@ -70,8 +71,7 @@ class AdminPacksApiV2 {
mnemo: const drift.Value('test_image'), mnemo: const drift.Value('test_image'),
); );
// Note: createCard is not in PackRepository, keeping direct DAO call for now final cardId = await _packRepository.createCard(companion);
final cardId = await _db.packDao.createCard(companion);
await _tryLinkCardToPack(packId: packId, cardId: cardId); await _tryLinkCardToPack(packId: packId, cardId: cardId);
final stored = await CardImageStorage.persistFromBase64( final stored = await CardImageStorage.persistFromBase64(
@ -85,12 +85,11 @@ class AdminPacksApiV2 {
final created = await _packRepository.getCardById(cardId); final created = await _packRepository.getCardById(cardId);
if (created == null) return null; if (created == null) return null;
// Note: updateCard is not in PackRepository, keeping direct DAO call for now // TODO: Refactor to use PackRepository.updateCard with GameCardModel
// We need to get the Drift GameCard to update it
final driftCard = await _db.packDao.getCardById(cardId); final driftCard = await _db.packDao.getCardById(cardId);
if (driftCard == null) return null; if (driftCard == null) return null;
await _db.packDao.updateCard( await _packRepository.updateCard(
driftCard.copyWith( driftCard.copyWith(
image: stored.fileName, image: stored.fileName,
updatedAt: PgDateTime(DateTime.now()), updatedAt: PgDateTime(DateTime.now()),
@ -143,6 +142,7 @@ class AdminPacksApiV2 {
packId: packId, packId: packId,
); );
if ((test.cover ?? '').trim() != (normalizedCover ?? '').trim()) { if ((test.cover ?? '').trim() != (normalizedCover ?? '').trim()) {
// TODO: Refactor to use TestRepository.updateTest
await _db.testDao.updateTest( await _db.testDao.updateTest(
test.copyWith( test.copyWith(
cover: drift.Value(normalizedCover), cover: drift.Value(normalizedCover),
@ -155,7 +155,7 @@ class AdminPacksApiV2 {
} }
// Ensure all question/button images are normalized and cards linked to pack. // Ensure all question/button images are normalized and cards linked to pack.
final questions = await _db.testDao.getTestQuestions(testId); final questions = await _testRepository.getTestQuestions(testId);
for (final q in questions) { for (final q in questions) {
// Parse options/buttons // Parse options/buttons
List<dynamic> buttons; List<dynamic> buttons;
@ -220,6 +220,7 @@ class AdminPacksApiV2 {
} }
if (mutated) { if (mutated) {
// TODO: Refactor to use TestRepository.updateTestQuestion
await _db.testDao.updateTestQuestion( await _db.testDao.updateTestQuestion(
q.copyWith( q.copyWith(
options: jsonEncode(normalizedButtons), options: jsonEncode(normalizedButtons),
@ -402,7 +403,7 @@ class AdminPacksApiV2 {
final previewCardIds = previewCards.map((c) => c.id).toList(); final previewCardIds = previewCards.map((c) => c.id).toList();
// Get tests for this pack // Get tests for this pack
final packTests = await _db.testDao.getTestsByPackId(packId); final packTests = await _testRepository.getTestsByPackId(packId);
final testIds = packTests.map((t) => t.id).toList(); final testIds = packTests.map((t) => t.id).toList();
// Create EditCardPackDto // Create EditCardPackDto
@ -596,7 +597,7 @@ class AdminPacksApiV2 {
try { try {
for (final testId in editDto.addTestIds!) { for (final testId in editDto.addTestIds!) {
// Verify test exists before adding // Verify test exists before adding
final test = await _db.testDao.getTestById(testId); final test = await _testRepository.getTestById(testId);
if (test == null) { if (test == null) {
return _json({ return _json({
'error': 'Test not found', 'error': 'Test not found',
@ -607,7 +608,7 @@ class AdminPacksApiV2 {
'Test with ID "$testId" was not found. Please verify all test IDs before adding them to the pack.', 'Test with ID "$testId" was not found. Please verify all test IDs before adding them to the pack.',
}, statusCode: 404); }, statusCode: 404);
} }
await _db.testDao.linkTestToPack(testId, packId); await _testRepository.linkTestToPack(testId, packId);
// Important: tests may reference cardIds (or even base64) in // Important: tests may reference cardIds (or even base64) in
// question/button images. Ensure those cards are linked to this pack // question/button images. Ensure those cards are linked to this pack
// so `/api/v2/packs/<packId>/cards/<cardId>/image` works. // so `/api/v2/packs/<packId>/cards/<cardId>/image` works.
@ -631,7 +632,7 @@ class AdminPacksApiV2 {
if (editDto.removeTestIds != null && editDto.removeTestIds!.isNotEmpty) { if (editDto.removeTestIds != null && editDto.removeTestIds!.isNotEmpty) {
for (final testId in editDto.removeTestIds!) { for (final testId in editDto.removeTestIds!) {
await _db.testDao.unlinkTestFromPack(testId, packId); await _testRepository.unlinkTestFromPack(testId, packId);
} }
} }
@ -661,7 +662,7 @@ class AdminPacksApiV2 {
final previewCardIds = previewCards.map((c) => c.id).toList(); final previewCardIds = previewCards.map((c) => c.id).toList();
// Get updated tests for this pack // Get updated tests for this pack
final updatedPackTests = await _db.testDao.getTestsByPackId(packId); final updatedPackTests = await _testRepository.getTestsByPackId(packId);
final updatedTestIds = updatedPackTests.map((t) => t.id).toList(); final updatedTestIds = updatedPackTests.map((t) => t.id).toList();
final updatedDto = EditCardPackDto( final updatedDto = EditCardPackDto(

View file

@ -9,7 +9,8 @@ import 'package:mnemo_cards_backend/api/authorize/helpers.dart';
import 'package:mnemo_cards_backend/database/database.dart'; import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/user/user_model.dart' import 'package:mnemo_cards_backend/user/user_model.dart'
show UserModelExtension; show UserModelExtension;
import 'package:mnemo_cards_backend/user/user_repository.dart'; import 'package:mnemo_cards_backend/user/user_data_model.dart';
import 'package:mnemo_cards_backend/repository/export.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:shelf/shelf.dart'; import 'package:shelf/shelf.dart';
@ -25,6 +26,30 @@ class AdminUsersApiV2 {
AdminUsersApiV2(this._db, this._userRepository); AdminUsersApiV2(this._db, this._userRepository);
/// Вспомогательный метод для загрузки userData и subscription
Future<(UserDataModel?, UserSubscriptionModel?)> _loadUserDataAndSubscription(
String? userId,
) async {
if (userId == null) return (null, null);
final userData = await _userRepository.getUserDataById(userId);
final subscription = await _userRepository.getUserSubscription(userId);
final userDataModel = userData != null
? UserDataModel(
id: userData.id,
lastTestSessionToken: userData.lastTestSessionToken,
lastTimeOnline: userData.lastTimeOnline?.toDateTime(),
tags: userData.tags,
totalStudyTimeMinutes: userData.totalStudyTimeMinutes,
currentStreak: userData.currentStreak,
longestStreak: userData.longestStreak,
)
: null;
return (userDataModel, subscription);
}
Response _json( Response _json(
Object? data, { Object? data, {
int statusCode = 200, int statusCode = 200,
@ -96,7 +121,13 @@ class AdminUsersApiV2 {
// Convert to DTOs // Convert to DTOs
final userDtos = <Map<String, dynamic>>[]; final userDtos = <Map<String, dynamic>>[];
for (final userModel in userModels) { for (final userModel in userModels) {
final dto = await userModel.toDto(); final (userDataModel, subscriptionModel) =
await _loadUserDataAndSubscription(userModel.id);
final dto = await userModel.toDto(
userData: userDataModel,
subscriptionModel: subscriptionModel,
);
userDtos.add(dto.toJson()); userDtos.add(dto.toJson());
} }
@ -173,7 +204,13 @@ class AdminUsersApiV2 {
}, statusCode: 404); }, statusCode: 404);
} }
final dto = await userModel.toDto(); final (userData, subscription) = await _loadUserDataAndSubscription(
userModel.id,
);
final dto = await userModel.toDto(
userData: userData,
subscriptionModel: subscription,
);
return _json(dto.toJson()); return _json(dto.toJson());
} catch (e, s) { } catch (e, s) {
@ -272,7 +309,13 @@ class AdminUsersApiV2 {
}, statusCode: 500); }, statusCode: 500);
} }
final dto = await updatedUserModel.toDto(); final (userData, subscription) = await _loadUserDataAndSubscription(
updatedUserModel.id,
);
final dto = await updatedUserModel.toDto(
userData: userData,
subscriptionModel: subscription,
);
return _json({'result': true, 'user': dto.toJson()}); return _json({'result': true, 'user': dto.toJson()});
} else { } else {
@ -330,7 +373,13 @@ class AdminUsersApiV2 {
}, statusCode: 500); }, statusCode: 500);
} }
final dto = await createdUserModel.toDto(); final (userData, subscription) = await _loadUserDataAndSubscription(
createdUserModel.id,
);
final dto = await createdUserModel.toDto(
userData: userData,
subscriptionModel: subscription,
);
return _json({'result': true, 'user': dto.toJson()}); return _json({'result': true, 'user': dto.toJson()});
} }

View file

@ -11,7 +11,7 @@ import 'package:mnemo_cards_backend/database/database.dart'
import 'package:mnemo_cards_backend/packs/card_image_storage.dart'; import 'package:mnemo_cards_backend/packs/card_image_storage.dart';
import 'package:mnemo_cards_backend/packs/card_model_extension.dart'; import 'package:mnemo_cards_backend/packs/card_model_extension.dart';
import 'package:mnemo_cards_backend/packs/pack_manager.dart' show PackManager; import 'package:mnemo_cards_backend/packs/pack_manager.dart' show PackManager;
import 'package:mnemo_cards_backend/packs/pack_repository.dart'; import 'package:mnemo_cards_backend/repository/export.dart';
import 'package:mnemo_cards_backend/packs/product_availability_manager.dart'; import 'package:mnemo_cards_backend/packs/product_availability_manager.dart';
import 'package:mnemo_cards_backend/packs/voice_storage.dart'; import 'package:mnemo_cards_backend/packs/voice_storage.dart';
import 'package:mnemo_cards_backend/storage/minio_config.dart'; import 'package:mnemo_cards_backend/storage/minio_config.dart';

View file

@ -7,7 +7,7 @@ import 'package:mnemo_cards_backend/api/purchase/payment_manager.dart';
import 'package:mnemo_cards_backend/database/database.dart'; import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/packs/pack_manager.dart'; import 'package:mnemo_cards_backend/packs/pack_manager.dart';
import 'package:mnemo_cards_backend/packs/product_availability_manager.dart'; import 'package:mnemo_cards_backend/packs/product_availability_manager.dart';
import 'package:mnemo_cards_backend/user/user_repository.dart'; import 'package:mnemo_cards_backend/repository/export.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:shelf/shelf.dart'; import 'package:shelf/shelf.dart';
@ -23,6 +23,8 @@ class PurchasesApiV2 {
final PaymentManager _paymentManager; final PaymentManager _paymentManager;
final PackManager _packManager; final PackManager _packManager;
final UserRepository _userRepository; final UserRepository _userRepository;
final SubscriptionRepository _subscriptionRepository;
final PaymentRepository _paymentRepository;
final ProductAvailabilityManager _productAvailabilityManager; final ProductAvailabilityManager _productAvailabilityManager;
final AppDatabase _db; final AppDatabase _db;
@ -30,6 +32,8 @@ class PurchasesApiV2 {
this._paymentManager, this._paymentManager,
this._packManager, this._packManager,
this._userRepository, this._userRepository,
this._subscriptionRepository,
this._paymentRepository,
this._productAvailabilityManager, this._productAvailabilityManager,
this._db, this._db,
); );
@ -65,10 +69,7 @@ class PurchasesApiV2 {
Response _forbidden(String message) => Response( Response _forbidden(String message) => Response(
403, 403,
body: jsonEncode({ body: jsonEncode({'error': 'Forbidden', 'message': message}),
'error': 'Forbidden',
'message': message,
}),
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
); );
@ -250,15 +251,15 @@ class PurchasesApiV2 {
]; ];
} else if (productType == MnemoCardsProductType.subscription) { } else if (productType == MnemoCardsProductType.subscription) {
// Get subscription plan // Get subscription plan
final planDrift = await _db.subscriptionDao.getPlanById(productId); final planDrift = await _subscriptionRepository.getPlanById(productId);
if (planDrift == null) { if (planDrift == null) {
return _notFound('Subscription plan not found'); return _notFound('Subscription plan not found');
} }
// Convert to SubscriptionPlanModel // Convert to SubscriptionPlanModel
final uiMap = planDrift.ui; final uiMap = planDrift.ui;
final ui = uiMap is Map<String, dynamic> final ui = uiMap != null
? SubscriptionPlanUI.fromJson(uiMap) ? SubscriptionPlanUI.fromJson(uiMap as Map<String, dynamic>)
: null; : null;
final plan = SubscriptionPlanModel( final plan = SubscriptionPlanModel(
id: planDrift.id, id: planDrift.id,
@ -344,9 +345,9 @@ class PurchasesApiV2 {
if (paymentId == 'latest' && user.id != null) { if (paymentId == 'latest' && user.id != null) {
final latestPayment = await _paymentManager final latestPayment = await _paymentManager
.getLatestPaymentForUserAndPack( .getLatestPaymentForUserAndPack(
userId: user.id!, userId: user.id!,
packId: productId, packId: productId,
); );
if (latestPayment == null || latestPayment.externalToken == null) { if (latestPayment == null || latestPayment.externalToken == null) {
return _notFound('No payment found for this pack'); return _notFound('No payment found for this pack');
} }
@ -354,8 +355,9 @@ class PurchasesApiV2 {
} }
// Check payment status // Check payment status
final isSuccess = final isSuccess = await _paymentManager.checkYookassaPayment(
await _paymentManager.checkYookassaPayment(actualPaymentId); actualPaymentId,
);
// Get product information // Get product information
MnemoCardsProductDto? product; MnemoCardsProductDto? product;
@ -411,7 +413,10 @@ class PurchasesApiV2 {
); );
// Check direct purchase access separately // Check direct purchase access separately
final hasDirectAccess = await _db.userDao.hasPackAccess(user.id!, packId); final hasDirectAccess = await _userRepository.hasPackAccess(
user.id!,
packId,
);
// Check subscription access separately // Check subscription access separately
final activeSubscription = await _db.subscriptionDao final activeSubscription = await _db.subscriptionDao
@ -458,20 +463,27 @@ class PurchasesApiV2 {
); );
// Find payment by external token (YooKassa payment ID) // Find payment by external token (YooKassa payment ID)
final payment = await _db.paymentDao.getPaymentByExternalToken(paymentId); final payment = await _paymentRepository.getPaymentByExternalToken(
paymentId,
);
if (payment == null) { if (payment == null) {
developer.log( developer.log('Payment not found: $paymentId', name: 'PurchasesApiV2');
'Payment not found: $paymentId', return _notFound('Payment not found');
name: 'PurchasesApiV2', }
);
// Get Payment from DB to check userId
final paymentDrift = await _db.paymentDao.getPaymentByExternalToken(
paymentId,
);
if (paymentDrift == null) {
return _notFound('Payment not found'); return _notFound('Payment not found');
} }
// Verify payment belongs to current user // Verify payment belongs to current user
if (payment.userId != user.id) { if (paymentDrift.userId != user.id) {
developer.log( developer.log(
'Payment access denied: paymentId=$paymentId, ownerId=${payment.userId}, requesterId=${user.id}', 'Payment access denied: paymentId=$paymentId, ownerId=${paymentDrift.userId}, requesterId=${user.id}',
name: 'PurchasesApiV2', name: 'PurchasesApiV2',
); );
return _forbidden('Access denied'); return _forbidden('Access denied');
@ -483,11 +495,12 @@ class PurchasesApiV2 {
); );
// Force check and process payment // Force check and process payment
await _paymentManager.checkAndProcessPayment(payment); await _paymentManager.checkAndProcessPayment(paymentDrift);
// Get updated payment status // Get updated payment status
final updatedPayment = final updatedPayment = await _paymentRepository.getPaymentByExternalToken(
await _db.paymentDao.getPaymentByExternalToken(paymentId); paymentId,
);
final finalStatus = updatedPayment?.status ?? payment.status; final finalStatus = updatedPayment?.status ?? payment.status;

View file

@ -20,5 +20,10 @@ Router _$PurchasesApiV2Router(PurchasesApiV2 service) {
r'/purchases/packs/<packId>/status', r'/purchases/packs/<packId>/status',
service.getPackPurchaseStatus, service.getPackPurchaseStatus,
); );
router.add(
'POST',
r'/purchases/check-payment/<paymentId>',
service.checkPayment,
);
return router; return router;
} }

View file

@ -5,7 +5,7 @@ import 'package:drift/drift.dart';
import 'package:drift_postgres/drift_postgres.dart'; import 'package:drift_postgres/drift_postgres.dart';
import 'package:injectable/injectable.dart'; import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/database/database.dart'; import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/user/user_repository.dart'; import 'package:mnemo_cards_backend/repository/export.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:shelf/shelf.dart'; import 'package:shelf/shelf.dart';
import 'package:shelf_open_api/shelf_open_api.dart'; import 'package:shelf_open_api/shelf_open_api.dart';

View file

@ -10,8 +10,9 @@ import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/statistics/statistics_calculator.dart'; import 'package:mnemo_cards_backend/statistics/statistics_calculator.dart';
import 'package:mnemo_cards_backend/user/user_manager.dart'; import 'package:mnemo_cards_backend/user/user_manager.dart';
import 'package:mnemo_cards_backend/user/user_model.dart'; import 'package:mnemo_cards_backend/user/user_model.dart';
import 'package:mnemo_cards_backend/user/user_repository.dart'; import 'package:mnemo_cards_backend/repository/export.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:shelf/shelf.dart'; import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart'; import 'package:shelf_router/shelf_router.dart';
@ -99,11 +100,30 @@ class UsersApiV2 {
final wordsDto = AllWordsStatisticsDto(words: wordsList); final wordsDto = AllWordsStatisticsDto(words: wordsList);
// Load userData and subscription
final userData = await _userRepository.getUserDataById(user.id!);
final subscription = await _userRepository.getUserSubscription(user.id!);
// Convert UserData to UserDataModel if exists
final userDataModel = userData != null
? UserDataModel(
id: userData.id,
lastTestSessionToken: userData.lastTestSessionToken,
lastTimeOnline: userData.lastTimeOnline?.toDateTime(),
tags: userData.tags,
totalStudyTimeMinutes: userData.totalStudyTimeMinutes,
currentStreak: userData.currentStreak,
longestStreak: userData.longestStreak,
)
: null;
final dto = await user.toDtoWithCalculatedData( final dto = await user.toDtoWithCalculatedData(
packProgress: packProgress, packProgress: packProgress,
studyDates: studyDates, studyDates: studyDates,
categoryMinutes: categoryMinutes, categoryMinutes: categoryMinutes,
wordsStatistics: wordsDto, wordsStatistics: wordsDto,
userData: userDataModel,
subscriptionModel: subscription,
); );
return _json(dto.toJson()); return _json(dto.toJson());
} }
@ -155,7 +175,28 @@ class UsersApiV2 {
); );
final refreshedUser = (await _userManager.fetchUser(user.id!))!; final refreshedUser = (await _userManager.fetchUser(user.id!))!;
final dto = await refreshedUser.toDto();
// Load userData and subscription
final userData = await _userRepository.getUserDataById(user.id!);
final subscription = await _userRepository.getUserSubscription(user.id!);
// Convert UserData to UserDataModel if exists
final userDataModel = userData != null
? UserDataModel(
id: userData.id,
lastTestSessionToken: userData.lastTestSessionToken,
lastTimeOnline: userData.lastTimeOnline?.toDateTime(),
tags: userData.tags,
totalStudyTimeMinutes: userData.totalStudyTimeMinutes,
currentStreak: userData.currentStreak,
longestStreak: userData.longestStreak,
)
: null;
final dto = await refreshedUser.toDto(
userData: userDataModel,
subscriptionModel: subscription,
);
return _json(dto.toJson()); return _json(dto.toJson());
} }
@ -252,20 +293,11 @@ class UsersApiV2 {
return _json({'error': 'user_id_not_found'}, statusCode: 400); return _json({'error': 'user_id_not_found'}, statusCode: 400);
} }
final userData = await _userRepository.getUserData(user.id!); final userData = await _userRepository.getUserDataById(user.id!);
if (userData == null) { if (userData == null) {
return _json({'error': 'user_data_not_found'}, statusCode: 404); return _json({'error': 'user_data_not_found'}, statusCode: 404);
} }
// Convert UserData to UserDataDto
final userModel = await _userRepository.getUserById(
user.id!,
withData: true,
);
if (userModel?.userData == null) {
return _json({'error': 'user_data_not_found'}, statusCode: 404);
}
// Рассчитать studyDates на лету из StudySessions // Рассчитать studyDates на лету из StudySessions
final studyDates = await _statisticsCalculator.calculateStudyDates( final studyDates = await _statisticsCalculator.calculateStudyDates(
user.id!, user.id!,
@ -345,27 +377,27 @@ class UsersApiV2 {
final validatedLimit = limit.clamp(1, 100); final validatedLimit = limit.clamp(1, 100);
final validatedOffset = offset < 0 ? 0 : offset; final validatedOffset = offset < 0 ? 0 : offset;
final userData = user.userData; // Get words statistics from database
if (userData == null) { final wordStats = await _db.wordStatisticsDao.getUserStatistics(user.id!);
return _json({ final wordsList = <DetailedWordStatisticsDto>[];
'words': [],
'totalCount': 0, // For each statistic, get the card and extract word data
'page': 0, for (final stat in wordStats) {
'pageSize': validatedLimit, final card = await _db.packDao.getCardById(stat.cardId);
'hasMore': false, if (card != null) {
}); wordsList.add(
DetailedWordStatisticsDto(
word: card.original,
correct: stat.correctAnswers.toDouble(),
incorrect: stat.incorrectAnswers.toDouble(),
skipped: 0,
questionTypes: {},
),
);
}
} }
// Get words with filtering var words = wordsList;
var words = userData.words.map((model) {
return DetailedWordStatisticsDto(
word: model.word,
correct: model.correct.toDouble(),
incorrect: model.incorrect.toDouble(),
skipped: model.skipped.toDouble(),
questionTypes: model.questionTypes.toSet(),
);
}).toList();
// Apply pack filter if specified // Apply pack filter if specified
if (packId != null) { if (packId != null) {
@ -552,13 +584,26 @@ class UsersApiV2 {
} }
try { try {
final userData = user.userData; final userData = await _userRepository.getUserDataById(user.id!);
if (userData == null) { if (userData == null) {
return _json([]); return _json([]);
} }
final achievements = _statisticsCalculator.calculateAchievemegntProgress( // Convert UserData to UserDataModel
userData, final userDataModel = UserDataModel(
id: userData.id,
lastTestSessionToken: userData.lastTestSessionToken,
lastTimeOnline: userData.lastTimeOnline?.toDateTime(),
tags: userData.tags,
totalStudyTimeMinutes: userData.totalStudyTimeMinutes,
currentStreak: userData.currentStreak,
longestStreak: userData.longestStreak,
// words, packProgress, studyDates, categoryMinutes, achievements
// are loaded separately from database when needed
);
final achievements = _statisticsCalculator.calculateAchievementProgress(
userDataModel,
); );
return _json(achievements.map((a) => a.toJson()).toList()); return _json(achievements.map((a) => a.toJson()).toList());
} catch (e) { } catch (e) {

View file

@ -1,6 +1,6 @@
import 'package:mnemo_cards_backend/packs/free_packs_distributor.dart'; import 'package:mnemo_cards_backend/packs/free_packs_distributor.dart';
import 'package:mnemo_cards_backend/packs/product_availability_manager.dart'; import 'package:mnemo_cards_backend/packs/product_availability_manager.dart';
import 'package:mnemo_cards_backend/user/user_repository.dart'; import 'package:mnemo_cards_backend/repository/export.dart';
import 'task.dart' as task; import 'task.dart' as task;

View file

@ -1,7 +1,7 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:drift_postgres/drift_postgres.dart'; import 'package:drift_postgres/drift_postgres.dart';
import 'package:mnemo_cards_backend/user/admin_ids_service.dart'; import 'package:mnemo_cards_backend/user/admin_ids_service.dart';
import 'package:mnemo_cards_backend/user/user_repository.dart'; import 'package:mnemo_cards_backend/repository/export.dart';
import '../database/database.dart'; import '../database/database.dart';
import 'task.dart' as task; import 'task.dart' as task;

View file

@ -213,7 +213,9 @@ class PaymentDao extends DatabaseAccessor<AppDatabase>
required String packId, required String packId,
}) async { }) async {
final query = selectActive() final query = selectActive()
..where((p) => p.userId.equals(userId) & p.products.like('%"id":"$packId"%')) ..where(
(p) => p.userId.equals(userId) & p.products.like('%"id":"$packId"%'),
)
..orderBy([(p) => OrderingTerm.desc(p.createdAt)]) ..orderBy([(p) => OrderingTerm.desc(p.createdAt)])
..limit(1); ..limit(1);

View file

@ -143,6 +143,14 @@ class TaskDao extends DatabaseAccessor<AppDatabase> with _$TaskDaoMixin {
return await query.map((row) => row.read(countExpr)!).getSingle(); return await query.map((row) => row.read(countExpr)!).getSingle();
} }
/// Удалить задачу пользователя
Future<bool> deleteUserTask(String taskId) async {
final deleted = await (delete(
db.userTasks,
)..where((ut) => ut.id.equals(taskId))).go();
return deleted > 0;
}
// ==================== UserTaskProgresses ==================== // ==================== UserTaskProgresses ====================
/// Получить прогресс задачи пользователя /// Получить прогресс задачи пользователя

View file

@ -1,9 +1,14 @@
import 'package:mnemo_cards_backend/discounts/discount_drift_extension.dart';
import 'package:mnemo_cards_backend/extensions.dart'; import 'package:mnemo_cards_backend/extensions.dart';
import 'package:mnemo_cards_backend/repository/export.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
extension DiscountCampaignModelExt on DiscountCampaignModel { extension DiscountCampaignModelExt on DiscountCampaignModel {
Future<DiscountCampaignDto> toDto() async { Future<DiscountCampaignDto> toDto(
DiscountRepository discountRepository,
) async {
final discounts = await discountRepository.getDiscountsByCampaignId(id!);
return DiscountCampaignDto( return DiscountCampaignDto(
id: id, id: id,
start: this.start, start: this.start,

View file

@ -1,14 +1,16 @@
import 'package:drift/drift.dart' as drift; import 'package:drift/drift.dart' as drift;
import 'package:drift_postgres/drift_postgres.dart'; import 'package:drift_postgres/drift_postgres.dart';
import 'package:mnemo_cards_backend/database/daos/discount_dao.dart';
import 'package:mnemo_cards_backend/database/database.dart'; import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/repository/export.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
/// Extension для конвертации DiscountCampaign (Drift) в DTO /// Extension для конвертации DiscountCampaign (Drift) в DTO
extension DiscountCampaignToDto on DiscountCampaign { extension DiscountCampaignToDto on DiscountCampaign {
Future<DiscountCampaignDto> toDto(DiscountDao discountDao) async { Future<DiscountCampaignDto> toDto(
final discounts = await discountDao.getDiscountsByCampaignId(id); DiscountRepository discountRepository,
) async {
final discounts = await discountRepository.getDiscountsByCampaignId(id);
final discountsDto = discounts.map((d) => d.toDto()).toList(); final discountsDto = discounts.map((d) => d.toDto()).toList();
DiscountCampaignStatus statusEnum; DiscountCampaignStatus statusEnum;
@ -44,7 +46,7 @@ extension DiscountCampaignToDto on DiscountCampaign {
/// Extension для конвертации Discount (Drift) в DTO /// Extension для конвертации Discount (Drift) в DTO
extension DiscountToDto on Discount { extension DiscountToDto on Discount {
DiscountDto toDto() { DiscountDto toDto() {
final productsList = (products ?? []) final productsList = products
.map((p) { .map((p) {
if (p is Map<String, dynamic>) { if (p is Map<String, dynamic>) {
return MnemoCardsProductDto.fromJson(p); return MnemoCardsProductDto.fromJson(p);

View file

@ -1,22 +1,19 @@
import 'dart:developer'; import 'dart:developer';
import 'package:injectable/injectable.dart'; import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/database/daos/discount_dao.dart';
import 'package:mnemo_cards_backend/database/database.dart'; import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/discounts/discount_drift_extension.dart'; import 'package:mnemo_cards_backend/discounts/discount_drift_extension.dart';
import 'package:mnemo_cards_backend/user/user_repository.dart'; import 'package:mnemo_cards_backend/repository/export.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart' hide DateTimeExt; import 'package:mnemo_cards_common/mnemo_cards_common.dart' hide DateTimeExt;
import 'package:mnemo_cards_common/src/utils/utils.dart'; import 'package:mnemo_cards_common/src/utils/utils.dart';
@lazySingleton @lazySingleton
class DiscountsManager { class DiscountsManager {
final AppDatabase _db; final DiscountRepository _discountRepository;
final DiscountDao _discountDao;
final UserRepository _userRepository; final UserRepository _userRepository;
DiscountsManager(this._db, this._userRepository) DiscountsManager(this._discountRepository, this._userRepository);
: _discountDao = _db.discountDao;
Future<void> applyDiscount({ Future<void> applyDiscount({
required Iterable<DiscountModel> discounts, required Iterable<DiscountModel> discounts,
@ -33,7 +30,7 @@ class DiscountsManager {
if (discountIds.isEmpty) return; if (discountIds.isEmpty) return;
// Добавляем скидки пользователю через junction таблицу // Добавляем скидки пользователю через junction таблицу
await _discountDao.grantDiscountsToUser(user.id!, discountIds); await _discountRepository.grantDiscountsToUser(user.id!, discountIds);
} }
Future<void> changeCampaignStatus(DiscountCampaign campaign) async { Future<void> changeCampaignStatus(DiscountCampaign campaign) async {
@ -52,7 +49,7 @@ class DiscountsManager {
} }
if (targetStatus != null && targetStatus != campaign.status) { if (targetStatus != null && targetStatus != campaign.status) {
await _discountDao.updateCampaignStatus(campaign.id, targetStatus); await _discountRepository.updateCampaignStatus(campaign.id, targetStatus);
} }
} }
@ -76,13 +73,15 @@ class DiscountsManager {
if (user.id == null) return; if (user.id == null) return;
// Получаем скидки кампании // Получаем скидки кампании
final discounts = await _discountDao.getDiscountsByCampaignId(campaign.id); final discounts = await _discountRepository.getDiscountsByCampaignId(
campaign.id,
);
final discountIds = discounts.map((d) => d.id).toList(); final discountIds = discounts.map((d) => d.id).toList();
if (discountIds.isEmpty) return; if (discountIds.isEmpty) return;
// Удаляем скидки пользователя // Удаляем скидки пользователя
await _discountDao.revokeDiscountsFromUser(user.id!, discountIds); await _discountRepository.revokeDiscountsFromUser(user.id!, discountIds);
} }
Future<double> getProductDiscount( Future<double> getProductDiscount(
@ -92,7 +91,9 @@ class DiscountsManager {
double maxDiscount = 0; double maxDiscount = 0;
// Получаем активные скидки пользователя из junction таблицы // Получаем активные скидки пользователя из junction таблицы
final userDiscounts = await _discountDao.getUserDiscounts(userData.userId); final userDiscounts = await _discountRepository.getUserDiscounts(
userData.userId,
);
for (final discount in userDiscounts) { for (final discount in userDiscounts) {
final discountDto = discount.toDto(); final discountDto = discount.toDto();
if (discountDto.discountPercent > maxDiscount && if (discountDto.discountPercent > maxDiscount &&
@ -108,14 +109,14 @@ class DiscountsManager {
final userDataModel = await _userRepository.getUserData(userData.userId); final userDataModel = await _userRepository.getUserData(userData.userId);
final userTags = userDataModel?.tags ?? []; final userTags = userDataModel?.tags ?? [];
final activeCampaigns = await _discountDao.getActiveCampaignsForUser( final activeCampaigns = await _discountRepository.getActiveCampaignsForUser(
userTags: userTags, userTags: userTags,
productType: model.type.name, productType: model.type.name,
productId: model.id.toString(), productId: model.id.toString(),
); );
for (final campaign in activeCampaigns) { for (final campaign in activeCampaigns) {
final discounts = await _discountDao.getDiscountsByCampaignId( final discounts = await _discountRepository.getDiscountsByCampaignId(
campaign.id, campaign.id,
); );
for (final discount in discounts) { for (final discount in discounts) {
@ -135,7 +136,7 @@ class DiscountsManager {
MnemoCardsProductModel? product, MnemoCardsProductModel? product,
}) async { }) async {
final userTags = userData.tags; final userTags = userData.tags;
return await _discountDao.getActiveCampaignsForUser( return await _discountRepository.getActiveCampaignsForUser(
userTags: userTags, userTags: userTags,
productType: product?.type.name, productType: product?.type.name,
productId: product?.id.toString(), productId: product?.id.toString(),
@ -143,23 +144,23 @@ class DiscountsManager {
} }
Future<List<DiscountCampaignDto>> discountCampaigns() async { Future<List<DiscountCampaignDto>> discountCampaigns() async {
final campaigns = await _discountDao.getAllCampaigns(); final campaigns = await _discountRepository.getAllCampaigns();
final dtos = await Future.wait( final dtos = await Future.wait(
campaigns.map((c) async => await c.toDto(_discountDao)), campaigns.map((c) async => await c.toDto(_discountRepository)),
); );
return dtos; return dtos;
} }
Future<String?> deleteDiscountCampaign(String id) async { Future<String?> deleteDiscountCampaign(String id) async {
try { try {
final campaign = await _discountDao.getCampaignById(id); final campaign = await _discountRepository.getCampaignById(id);
if (campaign == null) { if (campaign == null) {
return 'Campaign $id not found'; return 'Campaign $id not found';
} }
if (campaign.status != 'disabled') { if (campaign.status != 'disabled') {
return 'Disable campaign before deleting'; return 'Disable campaign before deleting';
} }
await _discountDao.deleteCampaign(id); await _discountRepository.deleteCampaign(id);
return null; return null;
} catch (e, s) { } catch (e, s) {
log('Error when deleting discount campaign', error: e, stackTrace: s); log('Error when deleting discount campaign', error: e, stackTrace: s);
@ -169,22 +170,22 @@ class DiscountsManager {
Future<bool> addDiscount(DiscountCampaignDto dto) async { Future<bool> addDiscount(DiscountCampaignDto dto) async {
try { try {
return await _db.transaction(() async { // Создаем кампанию
// Создаем кампанию final campaignCompanion = dto.toCompanion();
final campaignCompanion = dto.toCompanion(); final campaignId = await _discountRepository.createCampaign(
final campaignId = await _discountDao.createCampaign(campaignCompanion); campaignCompanion,
);
// Создаем скидки // Создаем скидки
final discountCompanions = dto.discounts final discountCompanions = dto.discounts
.map((discountDto) => discountDto.toCompanion(campaignId)) .map((discountDto) => discountDto.toCompanion(campaignId))
.toList(); .toList();
for (final discountCompanion in discountCompanions) { for (final discountCompanion in discountCompanions) {
await _discountDao.createDiscount(discountCompanion); await _discountRepository.createDiscount(discountCompanion);
} }
return true; return true;
});
} catch (e, s) { } catch (e, s) {
print('error when adding campaign'); print('error when adding campaign');
log('error when adding campaign', error: e, stackTrace: s); log('error when adding campaign', error: e, stackTrace: s);

View file

@ -11,7 +11,7 @@ import 'package:mnemo_cards_backend/discounts/discounts_manager.dart';
import 'package:mnemo_cards_backend/packs/product_availability_manager.dart'; import 'package:mnemo_cards_backend/packs/product_availability_manager.dart';
import 'package:mnemo_cards_backend/user/user_manager.dart'; import 'package:mnemo_cards_backend/user/user_manager.dart';
import 'package:mnemo_cards_backend/tests/test_manager.dart'; import 'package:mnemo_cards_backend/tests/test_manager.dart';
import 'package:mnemo_cards_backend/user/user_repository.dart'; import 'package:mnemo_cards_backend/repository/export.dart';
import 'api/di/injector.dart'; import 'api/di/injector.dart';
import 'cron/check_admins.dart'; import 'cron/check_admins.dart';

View file

@ -1,7 +1,7 @@
import 'package:injectable/injectable.dart'; import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/packs/product_availability_manager.dart'; import 'package:mnemo_cards_backend/packs/product_availability_manager.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'pack_repository.dart'; import '../repository/export.dart';
@lazySingleton @lazySingleton
class FreePacksDistributor { class FreePacksDistributor {

View file

@ -1,6 +1,7 @@
import 'package:injectable/injectable.dart'; import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/api/ads/ads_manager.dart'; import 'package:mnemo_cards_backend/api/ads/ads_manager.dart';
import 'package:mnemo_cards_backend/extensions.dart'; import 'package:mnemo_cards_backend/extensions.dart';
import 'package:mnemo_cards_backend/repository/export.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_backend/packs/card_model_extension.dart'; import 'package:mnemo_cards_backend/packs/card_model_extension.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
@ -13,8 +14,13 @@ bool canOpenForAd(String? id) => id != null;
class PackDtoConverter { class PackDtoConverter {
final ProductsPriceResolver _productsPriceResolver; final ProductsPriceResolver _productsPriceResolver;
final AdsManager _adsManager; final AdsManager _adsManager;
final UserRepository _userRepository;
const PackDtoConverter(this._productsPriceResolver, this._adsManager); PackDtoConverter(
this._productsPriceResolver,
this._adsManager,
this._userRepository,
);
Future<CardPackPreviewDto> toCardPackPreviewDto( Future<CardPackPreviewDto> toCardPackPreviewDto(
CardPackModel model, CardPackModel model,
@ -23,12 +29,14 @@ class PackDtoConverter {
bool available = bool available =
userModel != null && userModel != null &&
(userModel.packs.contains(model.id?.toString()) || userModel.admin); (userModel.packs.contains(model.id?.toString()) || userModel.admin);
if (userModel != null && !available) { if (userModel != null && !available && userModel.id != null) {
final subscription = await _userRepository.getUserSubscription(
userModel.id!,
);
available = available =
userModel.subscriptionModel?.features.contains( subscription != null &&
SubscriptionFeatureEnum.packs, subscription.isActive &&
) == subscription.features.contains(SubscriptionFeatureEnum.packs);
true;
} }
String? price = model.price; String? price = model.price;

View file

@ -8,15 +8,15 @@ import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'pack_dto_converter.dart'; import 'pack_dto_converter.dart';
import 'card_pack_drift_extension.dart'; import 'card_pack_drift_extension.dart';
import 'pack_repository.dart'; import '../repository/export.dart';
@lazySingleton @lazySingleton
class PackManager { class PackManager {
final AppDatabase _db;
final PackRepository _packRepository; final PackRepository _packRepository;
final PackDtoConverter packDtoConverter; final PackDtoConverter packDtoConverter;
final AppDatabase _db;
PackManager(this._db, this._packRepository, this.packDtoConverter); PackManager(this._packRepository, this.packDtoConverter, this._db);
Future<List<CardPackPreviewDto>> listPacksPreviews( Future<List<CardPackPreviewDto>> listPacksPreviews(
UserModel? userModel, UserModel? userModel,
@ -29,6 +29,7 @@ class PackManager {
); );
// Convert to Drift models for toPreviewDto (temporary, until we refactor DTO conversion) // Convert to Drift models for toPreviewDto (temporary, until we refactor DTO conversion)
// TODO: Refactor to use PackRepository and remove direct DAO access
final packs = await Future.wait( final packs = await Future.wait(
packModels.map((packModel) async { packModels.map((packModel) async {
final pack = await _db.packDao.getPackById(packModel.id!); final pack = await _db.packDao.getPackById(packModel.id!);
@ -63,11 +64,11 @@ class PackManager {
} }
Future<VoiceModel?> getVoice(String id) async { Future<VoiceModel?> getVoice(String id) async {
return await _db.packDao.getVoiceById(id); return await _packRepository.getVoiceById(id);
} }
Future<List<VoiceModel>> getVoices(String cardId) async { Future<List<VoiceModel>> getVoices(String cardId) async {
return await _db.packDao.getCardVoices(cardId); return await _packRepository.getCardVoices(cardId);
} }
Future<CardPackDto> getPackDto(String id, UserModel? userModel) async { Future<CardPackDto> getPackDto(String id, UserModel? userModel) async {
@ -77,19 +78,21 @@ class PackManager {
} }
// Get pack from DAO for DTO conversion (temporary, until we refactor) // Get pack from DAO for DTO conversion (temporary, until we refactor)
// TODO: Refactor to use PackRepository and remove direct DAO access
final pack = await _db.packDao.getPackById(id); final pack = await _db.packDao.getPackById(id);
if (pack == null) { if (pack == null) {
throw StateError('Pack not found'); throw StateError('Pack not found');
} }
final cards = await _db.packDao.getPackCards(id); // TODO: Refactor to use PackRepository and remove direct DAO access
final cardsDrift = await _db.packDao.getPackCards(id);
final voices = <VoiceModel>[]; final voices = <VoiceModel>[];
for (final card in cards) { for (final card in cardsDrift) {
voices.addAll(await getVoices(card.id)); voices.addAll(await getVoices(card.id));
} }
return await pack.toDto(cards, voices, userModel); return await pack.toDto(cardsDrift, voices, userModel);
} }
} }

View file

@ -1,237 +0,0 @@
import 'package:drift/drift.dart';
import 'package:drift_postgres/drift_postgres.dart';
import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'
hide VoiceModel;
import 'card_pack_drift_extension.dart';
/// Extension для конвертации GameCard (Drift) в GameCardModel
extension GameCardToModel on GameCard {
GameCardModel toModel() {
return GameCardModel(
id: id,
image: image,
mnemo: mnemo ?? '',
original: original,
translation: translation,
transcription: transcription,
transcriptionMnemo: transcriptionMnemo,
imageBack: imageBack,
back: back,
);
}
}
/// Repository для работы с паками
/// Работает с доменными моделями (CardPackModel), использует DAO для доступа к БД
@lazySingleton
class PackRepository {
final AppDatabase _db;
PackRepository(this._db);
/// Получить пак по ID
/// [includeCards] - загружать ли связанные карточки
/// [includePreviewCards] - загружать ли превью карточки
Future<CardPackModel?> getPackById(
String id, {
bool includeCards = false,
bool includePreviewCards = false,
}) async {
final pack = await _db.packDao.getPackById(id);
if (pack == null) return null;
final packModel = await pack.toModel();
if (includeCards) {
final cards = await _db.packDao.getPackCards(id);
packModel.cards.addAll(cards.map((c) => c.toModel()));
}
if (includePreviewCards) {
final previewCards = await _db.packDao.getPreviewCards(id);
packModel.previewCards.addAll(previewCards.map((c) => c.toModel()));
}
return packModel;
}
/// Получить все паки
Future<List<CardPackModel>> getAllPacks({
bool enabledOnly = false,
String? orderByField,
bool orderDesc = false,
bool includeCards = false,
bool includePreviewCards = false,
}) async {
final packs = await _db.packDao.getAllPacks(
enabledOnly: enabledOnly,
orderByField: orderByField,
orderDesc: orderDesc,
);
if (includeCards || includePreviewCards) {
final packModels = <CardPackModel>[];
for (final pack in packs) {
final packModel = await pack.toModel();
if (includeCards) {
final cards = await _db.packDao.getPackCards(pack.id);
packModel.cards.addAll(cards.map((c) => c.toModel()));
}
if (includePreviewCards) {
final previewCards = await _db.packDao.getPreviewCards(pack.id);
packModel.previewCards.addAll(previewCards.map((c) => c.toModel()));
}
packModels.add(packModel);
}
return packModels;
}
return await Future.wait(packs.map((p) => p.toModel()));
}
/// Создать пак
Future<String> createPack(CardPackModel packModel) async {
final companion = CardPacksCompanion.insert(
title: packModel.title,
subtitle: packModel.subtitle,
size: packModel.size,
color: Value(packModel.color),
version: Value(packModel.version),
cover: Value(packModel.cover),
description: Value(packModel.description),
googlePlayId: Value(packModel.googlePlayId),
rustoreId: Value(packModel.rustoreId),
appStoreId: Value(packModel.appStoreId),
price: Value(packModel.price),
currency: packModel.currency != null
? Value(packModel.currency!)
: const Value.absent(),
enabled: Value(packModel.enabled),
order: Value(packModel.order),
cardsOrder: Value(packModel.cardsOrder),
);
return await _db.packDao.createPack(companion);
}
/// Обновить пак
Future<void> updatePack(CardPackModel packModel) async {
if (packModel.id == null) {
throw ArgumentError('Pack ID is required');
}
await _db.packDao.updatePackPartial(
CardPacksCompanion(
id: Value(packModel.id!),
title: Value(packModel.title),
subtitle: Value(packModel.subtitle),
size: Value(packModel.size),
color: Value(packModel.color),
version: Value(packModel.version),
cover: Value(packModel.cover),
description: Value(packModel.description),
googlePlayId: Value(packModel.googlePlayId),
rustoreId: Value(packModel.rustoreId),
appStoreId: Value(packModel.appStoreId),
price: Value(packModel.price),
currency: packModel.currency != null
? Value(packModel.currency!)
: const Value.absent(),
enabled: Value(packModel.enabled),
order: Value(packModel.order),
cardsOrder: Value(packModel.cardsOrder),
updatedAt: Value(PgDateTime(DateTime.now())),
),
);
}
/// Обновить пак частично
Future<void> updatePackPartial(CardPacksCompanion updates) async {
await _db.packDao.updatePackPartial(updates);
}
/// Удалить пак (soft delete)
Future<void> softDeletePack(String packId) async {
await _db.packDao.softDeletePack(packId);
}
/// Подсчитать паки
Future<int> countPacks({bool enabledOnly = false}) async {
return await _db.packDao.countPacks(enabledOnly: enabledOnly);
}
/// Получить карточки пака
Future<List<GameCardModel>> getPackCards(String packId) async {
final cards = await _db.packDao.getPackCards(packId);
return cards.map((c) => c.toModel()).toList();
}
/// Получить превью карточки пака
Future<List<GameCardModel>> getPreviewCards(String packId) async {
final cards = await _db.packDao.getPreviewCards(packId);
return cards.map((c) => c.toModel()).toList();
}
/// Получить карточку по ID
Future<GameCardModel?> getCardById(String id) async {
final card = await _db.packDao.getCardById(id);
return card?.toModel();
}
/// Получить паки для карточки
Future<List<CardPackModel>> getPacksForCard(String cardId) async {
final packs = await _db.packDao.getPacksForCard(cardId);
return await Future.wait(packs.map((p) => p.toModel()));
}
/// Добавить карточку в пак
Future<void> addCardToPack({
required String packId,
required String cardId,
int order = 0,
}) async {
await _db.packDao.addCardToPack(
packId: packId,
cardId: cardId,
order: order,
);
}
/// Удалить карточку из пака
Future<void> removeCardFromPack(String packId, String cardId) async {
await _db.packDao.removeCardFromPack(packId, cardId);
}
/// Обновить порядок карточек в паке
Future<void> updatePackCardsOrder(String packId, List<String> cardIds) async {
await _db.packDao.updatePackCardsOrder(packId, cardIds);
}
/// Установить preview карточки для пака
Future<void> setPreviewCards(String packId, List<String> cardIds) async {
await _db.packDao.setPreviewCards(packId, cardIds);
}
// ==================== VoiceModels ====================
/// Получить голосовые модели карточки
Future<List<VoiceModel>> getCardVoices(String cardId) async {
return await _db.packDao.getCardVoices(cardId);
}
/// Получить голосовую модель по ID
Future<VoiceModel?> getVoiceById(String id) async {
return await _db.packDao.getVoiceById(id);
}
/// Обновить путь/URL аудиофайла голосовой модели
Future<void> updateVoiceUrl(String voiceId, String voiceUrl) async {
await _db.packDao.updateVoiceUrl(voiceId, voiceUrl);
}
}

View file

@ -1,7 +1,7 @@
import 'dart:developer'; import 'dart:developer';
import 'package:injectable/injectable.dart'; import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/packs/pack_manager.dart'; import 'package:mnemo_cards_backend/packs/pack_manager.dart';
import 'package:mnemo_cards_backend/repository/export.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
@ -9,10 +9,10 @@ import 'package:mnemo_cards_common/mnemo_cards_common.dart';
@lazySingleton @lazySingleton
class ProductAvailabilityManager { class ProductAvailabilityManager {
final PackManager _packManager; final PackManager _packManager;
final AppDatabase _db; final UserRepository _userRepository;
final Set<String> _publicPackIds; final Set<String> _publicPackIds;
ProductAvailabilityManager(this._packManager, this._db) ProductAvailabilityManager(this._packManager, this._userRepository)
: _publicPackIds = const {'10'}; : _publicPackIds = const {'10'};
/// Проверить доступность пакета для пользователя /// Проверить доступность пакета для пользователя
@ -56,14 +56,18 @@ class ProductAvailabilityManager {
} }
// Проверить прямой доступ через user_packs // Проверить прямой доступ через user_packs
final hasDirectAccess = await _db.userDao.hasPackAccess(user.id!, packId); final hasDirectAccess = await _userRepository.hasPackAccess(
user.id!,
packId,
);
if (hasDirectAccess) { if (hasDirectAccess) {
return true; return true;
} }
// Проверить доступ через подписку // Проверить доступ через подписку
if (user.subscriptionModel != null && user.subscriptionModel!.isActive) { final subscription = await _userRepository.getUserSubscription(user.id!);
final hasSubscriptionAccess = user.subscriptionModel!.features.contains( if (subscription != null && subscription.isActive) {
final hasSubscriptionAccess = subscription.features.contains(
SubscriptionFeatureEnum.packs, SubscriptionFeatureEnum.packs,
); );
if (hasSubscriptionAccess) { if (hasSubscriptionAccess) {
@ -97,11 +101,15 @@ class ProductAvailabilityManager {
case MnemoCardsProductType.subscription: case MnemoCardsProductType.subscription:
// Подписки не проверяются на доступность, они являются продуктами для покупки // Подписки не проверяются на доступность, они являются продуктами для покупки
// Но можно проверить, есть ли у пользователя уже активная подписка // Но можно проверить, есть ли у пользователя уже активная подписка
if (user?.subscriptionModel != null && if (user?.id != null) {
user!.subscriptionModel!.isActive) { final subscription = await _userRepository.getUserSubscription(
// Пользователь уже имеет активную подписку user!.id!,
// В зависимости от бизнес-логики, можно вернуть true или false );
return false; // По умолчанию, если есть активная подписка, покупка новой недоступна if (subscription != null && subscription.isActive) {
// Пользователь уже имеет активную подписку
// В зависимости от бизнес-логики, можно вернуть true или false
return false; // По умолчанию, если есть активная подписка, покупка новой недоступна
}
} }
return true; // Подписка доступна для покупки return true; // Подписка доступна для покупки
case MnemoCardsProductType.discount: case MnemoCardsProductType.discount:
@ -144,7 +152,7 @@ class ProductAvailabilityManager {
throw ArgumentError('Pack is disabled: $packId'); throw ArgumentError('Pack is disabled: $packId');
} }
await _db.userDao.grantPackAccess( await _userRepository.grantPackAccess(
userId: userId, userId: userId,
packId: packId, packId: packId,
grantType: grantType, grantType: grantType,
@ -161,7 +169,7 @@ class ProductAvailabilityManager {
required String userId, required String userId,
required String packId, required String packId,
}) async { }) async {
await _db.userDao.revokePackAccess(userId, packId); await _userRepository.revokePackAccess(userId, packId);
log('Revoked access to pack $packId for user $userId'); log('Revoked access to pack $packId for user $userId');
} }

View file

@ -1,6 +1,6 @@
import 'package:injectable/injectable.dart'; import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/discounts/discounts_manager.dart'; import 'package:mnemo_cards_backend/discounts/discounts_manager.dart';
import 'package:mnemo_cards_backend/user/user_repository.dart'; import 'package:mnemo_cards_backend/repository/export.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
@LazySingleton() @LazySingleton()

View file

@ -2,15 +2,17 @@ import 'package:drift_postgres/drift_postgres.dart';
import 'package:injectable/injectable.dart'; import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/api/purchase/payment_manager.dart'; import 'package:mnemo_cards_backend/api/purchase/payment_manager.dart';
import 'package:mnemo_cards_backend/database/database.dart'; import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/repository/export.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:drift/drift.dart' as drift; import 'package:drift/drift.dart' as drift;
@lazySingleton @lazySingleton
class PromoCodesManager { class PromoCodesManager {
final AppDatabase _db; final PromoCodeRepository _promoCodeRepository;
final PaymentManager _paymentManager; final PaymentManager _paymentManager;
final AppDatabase _db;
PromoCodesManager(this._db, this._paymentManager); PromoCodesManager(this._promoCodeRepository, this._paymentManager, this._db);
PromoCodeCampaignStatus _parseCampaignStatus(String status) { PromoCodeCampaignStatus _parseCampaignStatus(String status) {
switch (status) { switch (status) {
@ -32,13 +34,13 @@ class PromoCodesManager {
Future<List<PromoCodesCampaignDto>> promoCodeCampaigns({ Future<List<PromoCodesCampaignDto>> promoCodeCampaigns({
List<String> withCodes = const [], List<String> withCodes = const [],
}) async { }) async {
final campaigns = await _db.promoCodeDao.getActiveCampaigns(); final campaigns = await _promoCodeRepository.getActiveCampaigns();
final campaignDtos = <PromoCodesCampaignDto>[]; final campaignDtos = <PromoCodesCampaignDto>[];
for (final campaign in campaigns) { for (final campaign in campaigns) {
List<String>? promoCodes; List<String>? promoCodes;
if (withCodes.isNotEmpty) { if (withCodes.isNotEmpty) {
final codes = await _db.promoCodeDao.getPromoCodesByCampaignId( final codes = await _promoCodeRepository.getPromoCodesByCampaignId(
campaign.id, campaign.id,
); );
promoCodes = codes.map((code) => code.code).toList(); promoCodes = codes.map((code) => code.code).toList();
@ -70,10 +72,10 @@ class PromoCodesManager {
} }
Future<PromoCodesCampaignDto?> promoCodeCampaign(String id) async { Future<PromoCodesCampaignDto?> promoCodeCampaign(String id) async {
final campaign = await _db.promoCodeDao.getCampaignById(id); final campaign = await _promoCodeRepository.getCampaignById(id);
if (campaign == null) return null; if (campaign == null) return null;
final codes = await _db.promoCodeDao.getPromoCodesByCampaignId(id); final codes = await _promoCodeRepository.getPromoCodesByCampaignId(id);
final promoCodes = codes.map((code) => code.code).toList(); final promoCodes = codes.map((code) => code.code).toList();
final products = (campaign.products ?? []) final products = (campaign.products ?? [])
@ -112,14 +114,14 @@ class PromoCodesManager {
tags: drift.Value(dto.tags), tags: drift.Value(dto.tags),
); );
await _db.promoCodeDao.createCampaign(companion); await _promoCodeRepository.createCampaign(companion);
} }
Future<void> updatePromoCodeCampaign( Future<void> updatePromoCodeCampaign(
String id, String id,
PromoCodesCampaignDto dto, PromoCodesCampaignDto dto,
) async { ) async {
final existing = await _db.promoCodeDao.getCampaignById(id); final existing = await _promoCodeRepository.getCampaignById(id);
if (existing == null) { if (existing == null) {
throw StateError('Campaign not found: $id'); throw StateError('Campaign not found: $id');
} }
@ -140,15 +142,15 @@ class PromoCodesManager {
updatedAt: PgDateTime(DateTime.now()), updatedAt: PgDateTime(DateTime.now()),
); );
await _db.promoCodeDao.updateCampaign(updated); await _promoCodeRepository.updateCampaign(updated);
} }
Future<String?> deletePromoCodeCampaign(String id) async { Future<String?> deletePromoCodeCampaign(String id) async {
final existing = await _db.promoCodeDao.getCampaignById(id); final existing = await _promoCodeRepository.getCampaignById(id);
if (existing == null) return 'Campaign not found'; if (existing == null) return 'Campaign not found';
// Soft delete - mark as deleted // Soft delete - mark as deleted
await _db.promoCodeDao.updateCampaign( await _promoCodeRepository.updateCampaign(
existing.copyWith(isDeleted: true, updatedAt: PgDateTime(DateTime.now())), existing.copyWith(isDeleted: true, updatedAt: PgDateTime(DateTime.now())),
); );
@ -159,7 +161,7 @@ class PromoCodesManager {
// Get user ID // Get user ID
if (userId.isEmpty) return []; if (userId.isEmpty) return [];
final userCodes = await _db.promoCodeDao.getUserPromoCodes(userId); final userCodes = await _promoCodeRepository.getUserPromoCodes(userId);
return userCodes return userCodes
.map( .map(
(code) => { (code) => {
@ -175,14 +177,14 @@ class PromoCodesManager {
String code, String code,
dynamic user, dynamic user,
) async { ) async {
final promoCode = await _db.promoCodeDao.getPromoCodeByCode( final promoCode = await _promoCodeRepository.getPromoCodeByCode(
code.toUpperCase(), code.toUpperCase(),
); );
if (promoCode == null) { if (promoCode == null) {
return {'valid': false, 'message': 'Promo code not found'}; return {'valid': false, 'message': 'Promo code not found'};
} }
final campaign = await _db.promoCodeDao.getCampaignById( final campaign = await _promoCodeRepository.getCampaignById(
promoCode.campaignId, promoCode.campaignId,
); );
if (campaign == null) { if (campaign == null) {
@ -222,7 +224,7 @@ class PromoCodesManager {
throw StateError(validation['message']); throw StateError(validation['message']);
} }
final promoCode = await _db.promoCodeDao.getPromoCodeByCode(code); final promoCode = await _promoCodeRepository.getPromoCodeByCode(code);
if (promoCode == null) return null; if (promoCode == null) return null;
// Get user ID // Get user ID
@ -231,7 +233,7 @@ class PromoCodesManager {
} }
// Increment activations // Increment activations
await _db.promoCodeDao.incrementActivations(promoCode.id); await _promoCodeRepository.incrementActivations(promoCode.id);
// Apply the promo code benefits to user // Apply the promo code benefits to user
final campaign = validation['campaign'] as Map<String, dynamic>; final campaign = validation['campaign'] as Map<String, dynamic>;
@ -250,7 +252,7 @@ class PromoCodesManager {
} }
Future<bool> launchPromoCodesCampaign(String campaignId) async { Future<bool> launchPromoCodesCampaign(String campaignId) async {
final campaign = await _db.promoCodeDao.getCampaignById(campaignId); final campaign = await _promoCodeRepository.getCampaignById(campaignId);
if (campaign == null) return false; if (campaign == null) return false;
if (campaign.status != 'ready') { if (campaign.status != 'ready') {
@ -267,13 +269,13 @@ class PromoCodesManager {
// Insert codes into database // Insert codes into database
await _db.transaction(() async { await _db.transaction(() async {
for (final code in codes) { for (final code in codes) {
await _db.promoCodeDao.createPromoCode( await _promoCodeRepository.createPromoCode(
PromoCodesCompanion.insert(campaignId: campaignId, code: code), PromoCodesCompanion.insert(campaignId: campaignId, code: code),
); );
} }
// Update campaign status to active // Update campaign status to active
await _db.promoCodeDao.updateCampaign( await _promoCodeRepository.updateCampaign(
campaign.copyWith( campaign.copyWith(
status: 'active', status: 'active',
updatedAt: PgDateTime(DateTime.now()), updatedAt: PgDateTime(DateTime.now()),

View file

@ -2,16 +2,16 @@ import 'package:drift/drift.dart';
import 'package:drift_postgres/drift_postgres.dart'; import 'package:drift_postgres/drift_postgres.dart';
import 'package:injectable/injectable.dart'; import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/database/database.dart'; import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/user/user_repository.dart'; import 'package:mnemo_cards_backend/repository/export.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
@lazySingleton @lazySingleton
class AchievementManager { class AchievementManager {
final AppDatabase _db; final AchievementRepository _achievementRepository;
final UserRepository _userRepository; final UserRepository _userRepository;
AchievementManager(this._db, this._userRepository); AchievementManager(this._achievementRepository, this._userRepository);
/// Get all available achievements with their definitions /// Get all available achievements with their definitions
List<AchievementDto> get allAchievementDefinitions => List<AchievementDto> get allAchievementDefinitions =>
@ -50,7 +50,7 @@ class AchievementManager {
achievement, achievement,
); );
if (progress > 0.0) { if (progress > 0.0) {
await _db.achievementDao.updateAchievementProgress( await _achievementRepository.updateAchievementProgress(
userId, userId,
achievement.id, achievement.id,
progress, progress,
@ -64,10 +64,12 @@ class AchievementManager {
/// Get achievements for a user /// Get achievements for a user
Future<List<AchievementDto>> getUserAchievements(String userId) async { Future<List<AchievementDto>> getUserAchievements(String userId) async {
final userAchievements = await _db.achievementDao.getUserAchievements( final userAchievements = await _achievementRepository.getUserAchievements(
userId,
);
final progressMap = await _achievementRepository.getAchievementProgress(
userId, userId,
); );
final progressMap = await _db.achievementDao.getAchievementProgress(userId);
final achievements = <AchievementDto>[]; final achievements = <AchievementDto>[];
@ -98,7 +100,7 @@ class AchievementManager {
/// Check if user has specific achievement /// Check if user has specific achievement
Future<bool> hasAchievement(String userId, String achievementId) async { Future<bool> hasAchievement(String userId, String achievementId) async {
return await _db.achievementDao.hasAchievement(userId, achievementId); return await _achievementRepository.hasAchievement(userId, achievementId);
} }
/// Unlock achievement for user /// Unlock achievement for user
@ -117,11 +119,11 @@ class AchievementManager {
); );
try { try {
await _db.achievementDao.unlockAchievement(companion); await _achievementRepository.unlockAchievement(companion);
return definition.unlock(); return definition.unlock();
} catch (e) { } catch (e) {
// Achievement might already exist, return existing // Achievement might already exist, return existing
final existing = await _db.achievementDao.getUserAchievement( final existing = await _achievementRepository.getUserAchievement(
userId, userId,
achievementId, achievementId,
); );
@ -131,7 +133,7 @@ class AchievementManager {
/// Get achievement progress for user /// Get achievement progress for user
Future<Map<String, double>> getAchievementProgress(String userId) async { Future<Map<String, double>> getAchievementProgress(String userId) async {
return await _db.achievementDao.getAchievementProgress(userId); return await _achievementRepository.getAchievementProgress(userId);
} }
/// Check if achievement condition is met /// Check if achievement condition is met

View file

@ -1,15 +1,15 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection';
import 'dart:math'; import 'dart:math';
import 'package:drift_postgres/drift_postgres.dart'; import 'package:drift_postgres/drift_postgres.dart';
import 'package:injectable/injectable.dart'; import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/database/database.dart'; import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/repository/export.dart';
import 'package:drift/drift.dart' as drift; import 'package:drift/drift.dart' as drift;
@lazySingleton @lazySingleton
class SessionTracker { class SessionTracker {
final AppDatabase _db; final StatisticsRepository _statisticsRepository;
/// Active sessions cache: userId -> sessionId /// Active sessions cache: userId -> sessionId
final Map<String, String> _activeSessions = {}; final Map<String, String> _activeSessions = {};
@ -20,7 +20,7 @@ class SessionTracker {
/// Session timeout duration (30 minutes by default) /// Session timeout duration (30 minutes by default)
static const Duration _sessionTimeout = Duration(minutes: 30); static const Duration _sessionTimeout = Duration(minutes: 30);
SessionTracker(this._db); SessionTracker(this._statisticsRepository);
/// Get or create an active session for a user /// Get or create an active session for a user
/// ///
@ -48,7 +48,7 @@ class SessionTracker {
final sessionId = _generateSessionId(); final sessionId = _generateSessionId();
final now = PgDateTime(DateTime.now()); final now = PgDateTime(DateTime.now());
await _db.statisticsDao.createSession( await _statisticsRepository.createSession(
StudySessionsCompanion.insert( StudySessionsCompanion.insert(
userId: userId, userId: userId,
sessionId: drift.Value(sessionId), sessionId: drift.Value(sessionId),
@ -91,11 +91,13 @@ class SessionTracker {
} }
// Get session by sessionId to find its database ID // Get session by sessionId to find its database ID
final session = await _db.statisticsDao.getSessionBySessionId(sessionId); final session = await _statisticsRepository.getSessionBySessionId(
sessionId,
);
if (session == null) return; if (session == null) return;
// Update session in database using database ID // Update session in database using database ID
await _db.statisticsDao.endSession( await _statisticsRepository.endSession(
session.id, session.id,
wordsLearned: wordsLearned, wordsLearned: wordsLearned,
testsCompleted: testsCompleted, testsCompleted: testsCompleted,
@ -111,7 +113,9 @@ class SessionTracker {
double? accuracy, double? accuracy,
}) async { }) async {
// Find session by sessionId // Find session by sessionId
final session = await _db.statisticsDao.getSessionBySessionId(sessionId); final session = await _statisticsRepository.getSessionBySessionId(
sessionId,
);
if (session == null) return; if (session == null) return;
// Update session // Update session
@ -122,12 +126,14 @@ class SessionTracker {
updatedAt: PgDateTime(DateTime.now()), updatedAt: PgDateTime(DateTime.now()),
); );
await _db.statisticsDao.updateSession(updatedSession); await _statisticsRepository.updateSession(updatedSession);
} }
/// Get active session for user /// Get active session for user
Future<StudySession?> getActiveSession(String userId) async { Future<StudySession?> getActiveSession(String userId) async {
final activeSessions = await _db.statisticsDao.getActiveSessions(userId); final activeSessions = await _statisticsRepository.getActiveSessions(
userId,
);
return activeSessions.isNotEmpty ? activeSessions.first : null; return activeSessions.isNotEmpty ? activeSessions.first : null;
} }
@ -138,7 +144,7 @@ class SessionTracker {
DateTime? fromDate, DateTime? fromDate,
DateTime? toDate, DateTime? toDate,
}) async { }) async {
return await _db.statisticsDao.getSessionsByUserId( return await _statisticsRepository.getSessionsByUserId(
userId, userId,
limit: limit, limit: limit,
fromDate: fromDate, fromDate: fromDate,
@ -154,7 +160,9 @@ class SessionTracker {
for (final entry in _activeSessions.entries) { for (final entry in _activeSessions.entries) {
final sessionId = entry.value; final sessionId = entry.value;
final session = await _db.statisticsDao.getSessionBySessionId(sessionId); final session = await _statisticsRepository.getSessionBySessionId(
sessionId,
);
if (session != null && if (session != null &&
session.endTime == null && session.endTime == null &&
@ -191,9 +199,11 @@ class SessionTracker {
/// Update session activity timestamp /// Update session activity timestamp
Future<void> _updateSessionActivity(String sessionId) async { Future<void> _updateSessionActivity(String sessionId) async {
final session = await _db.statisticsDao.getSessionBySessionId(sessionId); final session = await _statisticsRepository.getSessionBySessionId(
sessionId,
);
if (session != null) { if (session != null) {
await _db.statisticsDao.updateSession( await _statisticsRepository.updateSession(
session.copyWith(updatedAt: PgDateTime(DateTime.now())), session.copyWith(updatedAt: PgDateTime(DateTime.now())),
); );
} }

View file

@ -1,7 +1,7 @@
import 'dart:math'; import 'dart:math';
import 'package:injectable/injectable.dart'; import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/user/user_repository.dart'; import 'package:mnemo_cards_backend/repository/export.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_backend/database/database.dart'; import 'package:mnemo_cards_backend/database/database.dart';
@ -11,8 +11,17 @@ import 'package:mnemo_cards_backend/database/database.dart';
class StatisticsCalculator { class StatisticsCalculator {
final AppDatabase _db; final AppDatabase _db;
final UserRepository _userRepository; final UserRepository _userRepository;
final PackRepository _packRepository;
final WordStatisticsRepository _wordStatisticsRepository;
final StatisticsRepository _statisticsRepository;
StatisticsCalculator(this._db, this._userRepository); StatisticsCalculator(
this._db,
this._userRepository,
this._packRepository,
this._wordStatisticsRepository,
this._statisticsRepository,
);
/// Calculate pack progress for a specific user and pack /// Calculate pack progress for a specific user and pack
/// ///
@ -23,19 +32,19 @@ class StatisticsCalculator {
String packId, String packId,
) async { ) async {
// Получить информацию о паке // Получить информацию о паке
final pack = await _db.packDao.getPackById(packId); final pack = await _packRepository.getPackById(packId);
if (pack == null) { if (pack == null) {
return PackProgressDto.empty(packId, 0); return PackProgressDto.empty(packId, 0);
} }
// Получить статистику по словам пака из WordStatistics // Получить статистику по словам пака из WordStatistics
final wordStats = await _db.wordStatisticsDao.getPackStatistics( final wordStats = await _wordStatisticsRepository.getPackStatistics(
userId, userId,
packId, packId,
); );
// Получить сессии изучения этого пака // Получить сессии изучения этого пака
final sessions = await _db.statisticsDao.getSessionsByUserId( final sessions = await _statisticsRepository.getSessionsByUserId(
userId, userId,
limit: null, limit: null,
); );
@ -123,7 +132,7 @@ class StatisticsCalculator {
/// ///
/// Данные теперь берутся из StudySessions вместо UserDatas.studyDates /// Данные теперь берутся из StudySessions вместо UserDatas.studyDates
Future<List<DateTime>> calculateStudyDates(String userId) async { Future<List<DateTime>> calculateStudyDates(String userId) async {
final sessions = await _db.statisticsDao.getSessionsByUserId(userId); final sessions = await _statisticsRepository.getSessionsByUserId(userId);
// Получить уникальные даты (без времени) // Получить уникальные даты (без времени)
final uniqueDates = <DateTime>{}; final uniqueDates = <DateTime>{};
@ -147,7 +156,7 @@ class StatisticsCalculator {
/// Данные теперь берутся из StudySessions + CardPacks.category /// Данные теперь берутся из StudySessions + CardPacks.category
/// вместо UserDatas.categoryMinutes /// вместо UserDatas.categoryMinutes
Future<Map<String, int>> calculateCategoryMinutes(String userId) async { Future<Map<String, int>> calculateCategoryMinutes(String userId) async {
final sessions = await _db.statisticsDao.getSessionsByUserId(userId); final sessions = await _statisticsRepository.getSessionsByUserId(userId);
final categoryMinutes = <String, int>{}; final categoryMinutes = <String, int>{};
@ -157,7 +166,7 @@ class StatisticsCalculator {
// Получить категорию пака // Получить категорию пака
// Примечание: в CardPacks нет поля category, используем 'unknown' // Примечание: в CardPacks нет поля category, используем 'unknown'
// В будущем можно добавить поле category в CardPacks или использовать другую логику // В будущем можно добавить поле category в CardPacks или использовать другую логику
final pack = await _db.packDao.getPackById(session.packId!); final pack = await _packRepository.getPackById(session.packId!);
final category = pack != null final category = pack != null
? 'unknown' ? 'unknown'
: 'unknown'; // TODO: добавить category в CardPacks : 'unknown'; // TODO: добавить category в CardPacks

View file

@ -1,5 +1,6 @@
import 'package:injectable/injectable.dart'; import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/database/database.dart'; import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/repository/export.dart';
/// Менеджер для работы со статистикой ответов пользователей на карточки /// Менеджер для работы со статистикой ответов пользователей на карточки
/// ///
@ -8,9 +9,9 @@ import 'package:mnemo_cards_backend/database/database.dart';
/// Обновляет существующую запись при каждом последующем ответе. /// Обновляет существующую запись при каждом последующем ответе.
@lazySingleton @lazySingleton
class WordStatisticsManager { class WordStatisticsManager {
final AppDatabase _db; final WordStatisticsRepository _wordStatisticsRepository;
WordStatisticsManager(this._db); WordStatisticsManager(this._wordStatisticsRepository);
/// Записать ответ пользователя на карточку /// Записать ответ пользователя на карточку
/// ///
@ -26,14 +27,14 @@ class WordStatisticsManager {
required bool isCorrect, required bool isCorrect,
}) async { }) async {
// Получить существующую статистику или создать новую // Получить существующую статистику или создать новую
final existing = await _db.wordStatisticsDao.getByUserAndCard( final existing = await _wordStatisticsRepository.getByUserAndCard(
userId, userId,
cardId, cardId,
); );
if (existing == null) { if (existing == null) {
// Создать новую запись при первом ответе // Создать новую запись при первом ответе
await _db.wordStatisticsDao.create( await _wordStatisticsRepository.create(
userId: userId, userId: userId,
cardId: cardId, cardId: cardId,
correctAnswers: isCorrect ? 1 : 0, correctAnswers: isCorrect ? 1 : 0,
@ -44,7 +45,7 @@ class WordStatisticsManager {
final newCorrect = existing.correctAnswers + (isCorrect ? 1 : 0); final newCorrect = existing.correctAnswers + (isCorrect ? 1 : 0);
final newIncorrect = existing.incorrectAnswers + (isCorrect ? 0 : 1); final newIncorrect = existing.incorrectAnswers + (isCorrect ? 0 : 1);
await _db.wordStatisticsDao.updateStatistics( await _wordStatisticsRepository.updateStatistics(
id: existing.id, id: existing.id,
correctAnswers: newCorrect, correctAnswers: newCorrect,
incorrectAnswers: newIncorrect, incorrectAnswers: newIncorrect,
@ -74,11 +75,11 @@ class WordStatisticsManager {
String userId, String userId,
String packId, String packId,
) async { ) async {
return _db.wordStatisticsDao.getPackStatistics(userId, packId); return _wordStatisticsRepository.getPackStatistics(userId, packId);
} }
/// Получить всю статистику пользователя /// Получить всю статистику пользователя
Future<List<WordStatistic>> getUserStatistics(String userId) async { Future<List<WordStatistic>> getUserStatistics(String userId) async {
return _db.wordStatisticsDao.getUserStatistics(userId); return _wordStatisticsRepository.getUserStatistics(userId);
} }
} }

View file

@ -2,12 +2,14 @@ import 'package:drift_postgres/drift_postgres.dart';
import 'package:injectable/injectable.dart'; import 'package:injectable/injectable.dart';
import 'package:drift/drift.dart' as drift; import 'package:drift/drift.dart' as drift;
import 'package:mnemo_cards_backend/database/database.dart'; import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/repository/export.dart';
@lazySingleton @lazySingleton
class TaskManager { class TaskManager {
final TaskRepository _taskRepository;
final AppDatabase _db; final AppDatabase _db;
TaskManager(this._db); TaskManager(this._taskRepository, this._db);
/// Получить все доступные задачи пользователя /// Получить все доступные задачи пользователя
Future<List<UserTask>> getUserTasks( Future<List<UserTask>> getUserTasks(
@ -19,7 +21,7 @@ class TaskManager {
int? limit, int? limit,
int? offset, int? offset,
}) async { }) async {
return await _db.taskDao.getUserTasks( return await _taskRepository.getUserTasks(
userId, userId,
status: status, status: status,
activeOnly: status == 'available', activeOnly: status == 'available',
@ -28,7 +30,7 @@ class TaskManager {
/// Получить задачу по ID /// Получить задачу по ID
Future<UserTask?> getUserTask(String userId, String taskId) async { Future<UserTask?> getUserTask(String userId, String taskId) async {
final tasks = await _db.taskDao.getUserTasks(userId); final tasks = await _taskRepository.getUserTasks(userId);
return tasks.where((task) => task.id == taskId).firstOrNull; return tasks.where((task) => task.id == taskId).firstOrNull;
} }
@ -42,10 +44,12 @@ class TaskManager {
} }
// Обновить статус задачи // Обновить статус задачи
await _db.taskDao.updateUserTask(task.copyWith(status: 'in_progress')); await _taskRepository.updateUserTask(
task.copyWith(status: 'in_progress'),
);
// Создать запись прогресса // Создать запись прогресса
await _db.taskDao.createTaskProgress( await _taskRepository.createTaskProgress(
UserTaskProgressesCompanion.insert( UserTaskProgressesCompanion.insert(
userId: userId, userId: userId,
taskId: taskId, taskId: taskId,
@ -66,12 +70,12 @@ class TaskManager {
// Обновить статус задачи // Обновить статус задачи
final now = PgDateTime(DateTime.now()); final now = PgDateTime(DateTime.now());
await _db.taskDao.updateUserTask( await _taskRepository.updateUserTask(
task.copyWith(status: 'completed', completedAt: drift.Value(now)), task.copyWith(status: 'completed', completedAt: drift.Value(now)),
); );
// Создать запись результата // Создать запись результата
await _db.taskDao.createTaskResult( await _taskRepository.createTaskResult(
UserTaskResultsCompanion.insert( UserTaskResultsCompanion.insert(
userId: userId, userId: userId,
taskId: taskId, taskId: taskId,
@ -88,11 +92,11 @@ class TaskManager {
Future<List<UserTaskProgressesData>> getUserTaskProgress( Future<List<UserTaskProgressesData>> getUserTaskProgress(
String userId, String userId,
) async { ) async {
final tasks = await _db.taskDao.getUserTasks(userId); final tasks = await _taskRepository.getUserTasks(userId);
final progresses = <UserTaskProgressesData>[]; final progresses = <UserTaskProgressesData>[];
for (final task in tasks) { for (final task in tasks) {
final progress = await _db.taskDao.getTaskProgress(userId, task.id); final progress = await _taskRepository.getTaskProgress(userId, task.id);
if (progress != null) { if (progress != null) {
progresses.add(progress); progresses.add(progress);
} }
@ -103,7 +107,7 @@ class TaskManager {
/// Получить категории задач /// Получить категории задач
Future<List<String>> getTaskCategories() async { Future<List<String>> getTaskCategories() async {
final tasks = await _db.taskDao.getAllUserTasks(); final tasks = await _taskRepository.getAllUserTasks();
final categories = <String>{}; final categories = <String>{};
for (final task in tasks) { for (final task in tasks) {
@ -117,16 +121,16 @@ class TaskManager {
/// Создать новую задачу для пользователя /// Создать новую задачу для пользователя
Future<String> createUserTask(UserTasksCompanion task) async { Future<String> createUserTask(UserTasksCompanion task) async {
return await _db.taskDao.createUserTask(task); return await _taskRepository.createUserTask(task);
} }
/// Обновить задачу пользователя /// Обновить задачу пользователя
Future<bool> updateUserTask(UserTask task) async { Future<bool> updateUserTask(UserTask task) async {
return await _db.taskDao.updateUserTask(task); return await _taskRepository.updateUserTask(task);
} }
/// Подсчитать задачи пользователя /// Подсчитать задачи пользователя
Future<int> countUserTasks(String userId, {String? status}) async { Future<int> countUserTasks(String userId, {String? status}) async {
return await _db.taskDao.countUserTasks(userId, status: status); return await _taskRepository.countUserTasks(userId, status: status);
} }
} }

View file

@ -6,6 +6,7 @@ import 'package:drift_postgres/drift_postgres.dart';
import 'package:mnemo_cards_backend/database/database.dart'; import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/packs/card_image_storage.dart'; import 'package:mnemo_cards_backend/packs/card_image_storage.dart';
import 'package:mnemo_cards_backend/packs/voice_storage.dart'; import 'package:mnemo_cards_backend/packs/voice_storage.dart';
import 'package:mnemo_cards_backend/repository/export.dart';
import 'package:mnemo_cards_backend/storage/minio_config.dart'; import 'package:mnemo_cards_backend/storage/minio_config.dart';
import 'package:mnemo_cards_backend/storage/minio_service.dart'; import 'package:mnemo_cards_backend/storage/minio_service.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
@ -18,9 +19,16 @@ import 'generators/pack_test_generator.dart';
@lazySingleton @lazySingleton
class TestManager { class TestManager {
final AppDatabase _db; final AppDatabase _db;
final PackRepository _packRepository;
final TestRepository _testRepository;
final MinioService _minioService; final MinioService _minioService;
TestManager(this._db, this._minioService); TestManager(
this._db,
this._packRepository,
this._testRepository,
this._minioService,
);
static final _uuidRegex = RegExp( static final _uuidRegex = RegExp(
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
@ -47,9 +55,9 @@ class TestManager {
required String cardId, required String cardId,
}) async { }) async {
try { try {
final card = await _db.packDao.getCardById(cardId); final card = await _packRepository.getCardById(cardId);
if (card == null) return; if (card == null) return;
await _db.packDao.addCardToPack(packId: packId, cardId: cardId); await _packRepository.addCardToPack(packId: packId, cardId: cardId);
} catch (_) { } catch (_) {
// Best-effort only. // Best-effort only.
} }
@ -67,7 +75,7 @@ class TestManager {
mnemo: const drift.Value('test_image'), mnemo: const drift.Value('test_image'),
); );
final cardId = await _db.packDao.createCard(companion); final cardId = await _packRepository.createCard(companion);
if (packId != null) { if (packId != null) {
await _tryLinkCardToPack(packId: packId, cardId: cardId); await _tryLinkCardToPack(packId: packId, cardId: cardId);
} }
@ -80,15 +88,19 @@ class TestManager {
); );
if (stored == null) return null; if (stored == null) return null;
final created = await _db.packDao.getCardById(cardId); final created = await _packRepository.getCardById(cardId);
if (created == null) return null; if (created == null) return null;
await _db.packDao.updateCard( // TODO: Refactor to use PackRepository.updateCard with GameCardModel
created.copyWith( final card = await _db.packDao.getCardById(cardId);
image: stored.fileName, if (card != null) {
updatedAt: PgDateTime(DateTime.now()), await _packRepository.updateCard(
), card.copyWith(
); image: stored.fileName,
updatedAt: PgDateTime(DateTime.now()),
),
);
}
return cardId; return cardId;
} catch (_) { } catch (_) {
@ -276,7 +288,7 @@ class TestManager {
String userId, String userId,
String testId, String testId,
) async { ) async {
final statistics = await _db.testDao.getTestStatistics(userId, testId); final statistics = await _testRepository.getTestStatistics(userId, testId);
if (statistics == null) return null; if (statistics == null) return null;
// Convert TestStatistic to TestStatisticsDto // Convert TestStatistic to TestStatisticsDto
@ -411,6 +423,7 @@ class TestManager {
questionJson['buttons'] = normalizedButtons; questionJson['buttons'] = normalizedButtons;
if (mutated) { if (mutated) {
// TODO: Refactor to use TestRepository.updateTestQuestion
await _db.testDao.updateTestQuestion( await _db.testDao.updateTestQuestion(
q.copyWith( q.copyWith(
options: jsonEncode(normalizedButtons), options: jsonEncode(normalizedButtons),
@ -455,7 +468,7 @@ class TestManager {
final currentButtons = final currentButtons =
(questionJson['buttons'] as List<dynamic>?) ?? []; (questionJson['buttons'] as List<dynamic>?) ?? [];
if (currentButtons.isEmpty && packId != null) { if (currentButtons.isEmpty && packId != null) {
final pool = await _db.packDao.getPackCards(packId); final pool = await _packRepository.getPackCards(packId);
final maxSize = sqrt(pool.length).floor().clamp(1, 4); final maxSize = sqrt(pool.length).floor().clamp(1, 4);
final resolvedSize = matrixSize.clamp(1, maxSize); final resolvedSize = matrixSize.clamp(1, maxSize);
final total = resolvedSize * resolvedSize; final total = resolvedSize * resolvedSize;
@ -554,11 +567,11 @@ class TestManager {
} }
Future<List<TestDto>> availableTests(UserModel userModel) async { Future<List<TestDto>> availableTests(UserModel userModel) async {
final tests = await _db.testDao.getAllTests(); final tests = await _testRepository.getAllTests();
final testDtos = <TestDto>[]; final testDtos = <TestDto>[];
for (final test in tests) { for (final test in tests) {
final questions = await _db.testDao.getTestQuestions(test.id); final questions = await _testRepository.getTestQuestions(test.id);
final statistics = await _testStatisticsDto(userModel.id!, test.id); final statistics = await _testStatisticsDto(userModel.id!, test.id);
final questionsList = questions.map((q) { final questionsList = questions.map((q) {
@ -616,11 +629,11 @@ class TestManager {
final packId = model.id; final packId = model.id;
if (packId == null) return []; if (packId == null) return [];
final tests = await _db.testDao.getTestsByPackId(packId); final tests = await _testRepository.getTestsByPackId(packId);
final testDtos = <TestDto>[]; final testDtos = <TestDto>[];
for (final test in tests) { for (final test in tests) {
final questions = await _db.testDao.getTestQuestions(test.id); final questions = await _testRepository.getTestQuestions(test.id);
final statistics = await _testStatisticsDto(user.id!, test.id); final statistics = await _testStatisticsDto(user.id!, test.id);
final questionsList = await Future.wait( final questionsList = await Future.wait(
@ -750,7 +763,7 @@ class TestManager {
final testDataItems = await Future.wait( final testDataItems = await Future.wait(
cards.map((card) async { cards.map((card) async {
// Get voices for this card // Get voices for this card
final voices = await _db.packDao.getCardVoices(card.id.toString()); final voices = await _packRepository.getCardVoices(card.id.toString());
// Use first voice's voiceUrl if it's a valid UUID, otherwise null // Use first voice's voiceUrl if it's a valid UUID, otherwise null
String? audioUuid; String? audioUuid;
@ -849,7 +862,7 @@ class TestManager {
uiData: drift.Value(json.encode(uiData)), uiData: drift.Value(json.encode(uiData)),
); );
await _db.testDao.createTestQuestion(questionCompanion); await _testRepository.createTestQuestion(questionCompanion);
} }
}); });
return createdTestId!; return createdTestId!;

View file

@ -19,9 +19,7 @@ extension UserDataModelExtension on UserDataModel {
words: words.map((model) => model.toDto()).toList(), words: words.map((model) => model.toDto()).toList(),
), ),
allTestsStatistics: AllTestsStatisticsDto( allTestsStatistics: AllTestsStatisticsDto(
tests: Map.fromEntries( tests: {}, // testsStatistics removed - always empty
testsStatistics.map((model) => MapEntry(model.id!, model.toDto())),
),
lastSessionToken: this.lastTestSessionToken, lastSessionToken: this.lastTestSessionToken,
), ),
// NEW STATISTICS FIELDS // NEW STATISTICS FIELDS

View file

@ -1,4 +1,5 @@
import 'package:drift/drift.dart' as drift; import 'package:drift/drift.dart' as drift;
import 'package:mnemo_cards_backend/database/daos/user_dao.dart';
import 'package:mnemo_cards_backend/database/database.dart'; import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/packs/card_pack_drift_extension.dart'; import 'package:mnemo_cards_backend/packs/card_pack_drift_extension.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
@ -24,6 +25,26 @@ extension UserToUserModel on User {
} }
} }
extension UserWithDataToUserDataModel on UserWithData {
Future<UserDataModel> toUserDataModel() async {
if (userData == null) {
return UserDataModel();
}
return UserDataModel(
id: userData!.id,
lastTestSessionToken: userData!.lastTestSessionToken,
lastTimeOnline: userData!.lastTimeOnline?.toDateTime(),
tags: userData!.tags,
totalStudyTimeMinutes: userData!.totalStudyTimeMinutes,
currentStreak: userData!.currentStreak,
longestStreak: userData!.longestStreak,
// words, packProgress, studyDates, categoryMinutes, achievements
// are loaded separately from database when needed
);
}
}
/// Extension для конвертации UserModel в User (Drift) /// Extension для конвертации UserModel в User (Drift)
extension UserModelToUser on UserModel { extension UserModelToUser on UserModel {
UsersCompanion toUsersCompanion() { UsersCompanion toUsersCompanion() {

View file

@ -15,7 +15,7 @@ import '../statistics/statistics_calculator.dart';
import '../statistics/achievement_manager.dart'; import '../statistics/achievement_manager.dart';
import '../statistics/word_statistics_manager.dart'; import '../statistics/word_statistics_manager.dart';
import 'secure.dart'; import 'secure.dart';
import 'user_repository.dart'; import '../repository/export.dart';
Map<String?, DateTime> _onlineUsers = {}; Map<String?, DateTime> _onlineUsers = {};
@ -23,6 +23,7 @@ Map<String?, DateTime> _onlineUsers = {};
class UserManager { class UserManager {
final AppDatabase _db; final AppDatabase _db;
final UserRepository _userRepository; final UserRepository _userRepository;
final PackRepository _packRepository;
final FreePacksDistributor _freePacksDistributor; final FreePacksDistributor _freePacksDistributor;
final SessionTracker _sessionTracker; final SessionTracker _sessionTracker;
final StatisticsCalculator _statisticsCalculator; final StatisticsCalculator _statisticsCalculator;
@ -32,6 +33,7 @@ class UserManager {
UserManager( UserManager(
this._db, this._db,
this._userRepository, this._userRepository,
this._packRepository,
this._freePacksDistributor, this._freePacksDistributor,
this._sessionTracker, this._sessionTracker,
this._statisticsCalculator, this._statisticsCalculator,
@ -50,15 +52,15 @@ class UserManager {
throw Exception('Cant create token for empty id'); throw Exception('Cant create token for empty id');
} }
final now = DateTime.now(); final now = DateTime.now();
final token = await _db.userDao.getTokenByUserId(user.id!); final token = await _userRepository.getTokenByUserId(user.id!);
if (token != null) { if (token != null) {
if (token.expires.dateTime.isAfter(now)) { if (token.expires.dateTime.isAfter(now)) {
return token.token; return token.token;
} }
await _db.userDao.softDeleteToken(token.id); await _userRepository.softDeleteToken(token.id);
} }
final userToken = Secure.token(); final userToken = Secure.token();
await _db.userDao.createToken( await _userRepository.createToken(
TokensCompanion.insert( TokensCompanion.insert(
token: userToken, token: userToken,
externalUserId: externalId, externalUserId: externalId,
@ -70,13 +72,13 @@ class UserManager {
} }
Future<UserModel?> getUserByToken(String authToken) async { Future<UserModel?> getUserByToken(String authToken) async {
final token = await _db.userDao.getTokenByValue(authToken); final token = await _userRepository.getTokenByValue(authToken);
if (token == null) { if (token == null) {
return null; return null;
} }
final now = DateTime.now(); final now = DateTime.now();
if (token.expires.dateTime.isBefore(now)) { if (token.expires.dateTime.isBefore(now)) {
await _db.userDao.softDeleteToken(token.id); await _userRepository.softDeleteToken(token.id);
return null; return null;
} }
final user = await fetchUser(token.userId); final user = await fetchUser(token.userId);
@ -99,7 +101,7 @@ class UserManager {
for (final userId in userIds) { for (final userId in userIds) {
final lastOnline = _onlineUsers[userId]; final lastOnline = _onlineUsers[userId];
if (lastOnline != null) { if (lastOnline != null) {
await _db.userDao.updateUserDataPartial( await _userRepository.updateUserDataPartial(
UserDatasCompanion( UserDatasCompanion(
userId: drift.Value(userId), userId: drift.Value(userId),
lastTimeOnline: drift.Value(PgDateTime(lastOnline)), lastTimeOnline: drift.Value(PgDateTime(lastOnline)),
@ -217,7 +219,7 @@ class UserManager {
// Получить или создать UserData // Получить или создать UserData
var userData = await _userRepository.getUserData(user.id!); var userData = await _userRepository.getUserData(user.id!);
if (userData == null) { if (userData == null) {
await _db.userDao.createUserData( await _userRepository.createUserData(
UserDatasCompanion.insert(userId: user.id!), UserDatasCompanion.insert(userId: user.id!),
); );
userData = await _userRepository.getUserData(user.id!); userData = await _userRepository.getUserData(user.id!);
@ -282,9 +284,13 @@ class UserManager {
for (final wordStat in testStat.words.words) { for (final wordStat in testStat.words.words) {
// Найти карточку по слову (original) // Найти карточку по слову (original)
// Примечание: если несколько карточек с одинаковым original, берем первую // Примечание: если несколько карточек с одинаковым original, берем первую
final cards = await _db.packDao.searchCardsByOriginal(wordStat.word); final cardModels = await _packRepository.searchCardsByOriginal(
if (cards.isNotEmpty) { wordStat.word,
final card = cards.first; );
if (cardModels.isNotEmpty) {
final cardModel = cardModels.first;
final card = await _db.packDao.getCardById(cardModel.id!);
if (card == null) continue;
// Записать ответы // Записать ответы
// correct и incorrect в WordStatisticsDto - это уже агрегированные значения // correct и incorrect в WordStatisticsDto - это уже агрегированные значения
@ -324,7 +330,7 @@ class UserManager {
final longestStreak = math.max(userData.longestStreak, currentStreak); final longestStreak = math.max(userData.longestStreak, currentStreak);
// Обновить UserData (без studyDates - это поле удалено) // Обновить UserData (без studyDates - это поле удалено)
await _db.userDao.updateUserDataPartial( await _userRepository.updateUserDataPartial(
UserDatasCompanion( UserDatasCompanion(
userId: drift.Value(user.id!), userId: drift.Value(user.id!),
lastTestSessionToken: drift.Value(testStat.sessionToken), lastTestSessionToken: drift.Value(testStat.sessionToken),

View file

@ -7,9 +7,14 @@ extension UserModelExtension on UserModel {
/// ///
/// Примечание: для расчета packProgress, studyDates, categoryMinutes на лету /// Примечание: для расчета packProgress, studyDates, categoryMinutes на лету
/// используйте toDtoWithCalculatedData() /// используйте toDtoWithCalculatedData()
Future<UserDto> toDto() async { /// [userData] - опциональные данные пользователя
/// [subscriptionModel] - опциональная подписка пользователя
Future<UserDto> toDto({
UserDataModel? userData,
UserSubscriptionModel? subscriptionModel,
}) async {
final activeSubscription = final activeSubscription =
subscriptionModel != null && subscriptionModel!.isActive; subscriptionModel != null && subscriptionModel.isActive;
return UserDto( return UserDto(
id: id, id: id,
name: name, name: name,
@ -27,14 +32,18 @@ extension UserModelExtension on UserModel {
/// ///
/// Используется когда нужно рассчитать packProgress, studyDates, categoryMinutes /// Используется когда нужно рассчитать packProgress, studyDates, categoryMinutes
/// из WordStatistics и StudySessions вместо использования удаленных полей UserDatas /// из WordStatistics и StudySessions вместо использования удаленных полей UserDatas
/// [userData] - опциональные данные пользователя
/// [subscriptionModel] - опциональная подписка пользователя
Future<UserDto> toDtoWithCalculatedData({ Future<UserDto> toDtoWithCalculatedData({
required List<PackProgressDto> packProgress, required List<PackProgressDto> packProgress,
required List<DateTime> studyDates, required List<DateTime> studyDates,
required Map<String, int> categoryMinutes, required Map<String, int> categoryMinutes,
AllWordsStatisticsDto? wordsStatistics, AllWordsStatisticsDto? wordsStatistics,
UserDataModel? userData,
UserSubscriptionModel? subscriptionModel,
}) async { }) async {
final activeSubscription = final activeSubscription =
subscriptionModel != null && subscriptionModel!.isActive; subscriptionModel != null && subscriptionModel.isActive;
return UserDto( return UserDto(
id: id, id: id,
name: name, name: name,

View file

@ -1,158 +0,0 @@
import 'package:drift/drift.dart';
import 'package:drift_postgres/drift_postgres.dart';
import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/packs/card_pack_drift_extension.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'user_drift_extension.dart';
/// Repository для работы с пользователями
/// Работает с доменными моделями (UserModel), использует DAO для доступа к БД
@lazySingleton
class UserRepository {
final AppDatabase _db;
UserRepository(this._db);
/// Получить пользователя по ID
/// [includePacks] - загружать ли связанные паки пользователя
/// [withData] - загружать ли UserData пользователя
Future<UserModel?> getUserById(
String id, {
bool includePacks = false,
bool withData = true,
}) async {
if (withData) {
final userWithData = await _db.userDao.getUserWithDataById(id);
if (userWithData == null) return null;
if (includePacks) {
final packs = await _db.userDao.getUserPacks(id);
return await userWithData.user.toUserModel(packs);
}
return await userWithData.user.toUserModel();
}
final user = await _db.userDao.getUserById(id);
if (user == null) return null;
if (includePacks) {
final packs = await _db.userDao.getUserPacks(id);
return await user.toUserModel(packs);
}
return await user.toUserModel();
}
/// Получить пользователя по email
Future<UserModel?> getUserByEmail(String email) async {
final user = await _db.userDao.getUserByEmail(email);
if (user == null) return null;
return await user.toUserModel();
}
/// Получить пользователя по externalUserId
Future<UserModel?> getUserByExternalId(String externalId) async {
final user = await _db.userDao.getUserByExternalId(externalId);
if (user == null) return null;
return await user.toUserModel();
}
/// Создать пользователя
Future<String> createUser(UserModel userModel) async {
final userCompanion = userModel.toUsersCompanion();
return await _db.userDao.createUser(userCompanion);
}
/// Создать пользователя с UserData
Future<String> createUserWithData({
required UserModel userModel,
required UserDatasCompanion userData,
}) async {
final userCompanion = userModel.toUsersCompanion();
return await _db.userDao.createUserWithData(
user: userCompanion,
userData: userData,
);
}
/// Обновить пользователя
Future<void> updateUser(UserModel userModel) async {
if (userModel.id == null) {
throw ArgumentError('User ID is required');
}
await _db.userDao.updateUserPartial(
userModel.toUsersCompanion().copyWith(
updatedAt: Value(PgDateTime(DateTime.now())),
),
);
}
/// Обновить пользователя частично
Future<void> updateUserPartial(UsersCompanion updates) async {
await _db.userDao.updateUserPartial(updates);
}
/// Удалить пользователя (soft delete)
Future<void> softDeleteUser(String userId) async {
await _db.userDao.softDeleteUser(userId);
}
/// Получить всех пользователей (для админки)
Future<List<UserModel>> getAllUsers({
int? limit,
int? offset,
bool includeDeleted = false,
bool includePacks = false,
}) async {
final users = await _db.userDao.getAllUsers(
limit: limit,
offset: offset,
includeDeleted: includeDeleted,
);
if (includePacks) {
final userModels = <UserModel>[];
for (final user in users) {
final packs = await _db.userDao.getUserPacks(user.id);
final userModel = await user.toUserModel(packs);
userModels.add(userModel);
}
return userModels;
}
return await Future.wait(users.map((u) => u.toUserModel()));
}
/// Подсчитать пользователей
Future<int> countUsers({bool includeDeleted = false}) async {
return await _db.userDao.countUsers(includeDeleted: includeDeleted);
}
/// Получить паки пользователя
Future<List<CardPackModel>> getUserPacks(String userId) async {
final packs = await _db.userDao.getUserPacks(userId);
return await Future.wait(packs.map((p) => p.toModel()));
}
/// Получить UserData пользователя
Future<UserData?> getUserData(String userId) async {
return await _db.userDao.getUserData(userId);
}
/// Обновить UserData частично
Future<void> updateUserDataPartial(UserDatasCompanion updates) async {
await _db.userDao.updateUserDataPartial(updates);
}
/// Обновить время последнего визита
Future<void> updateLastOnline(String userId) async {
await _db.userDao.updateLastOnline(userId);
}
}

View file

@ -1,6 +1,5 @@
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:copy_with_extension/copy_with_extension.dart'; import 'package:copy_with_extension/copy_with_extension.dart';
import 'discount_model.dart';
part 'discount_campaign_model.g.dart'; part 'discount_campaign_model.g.dart';
@ -14,8 +13,6 @@ class DiscountCampaignModel {
final DiscountCampaignModelStatus status; final DiscountCampaignModelStatus status;
final String? name; final String? name;
final List<String> tags; final List<String> tags;
// Relations - loaded separately from database
final List<DiscountModel> discounts = [];
DiscountCampaignModel({ DiscountCampaignModel({
required this.start, required this.start,

View file

@ -1,9 +1,6 @@
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:copy_with_extension/copy_with_extension.dart'; import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:mnemo_cards_common_backend/src/models/product_model.dart'; import 'package:mnemo_cards_common_backend/src/models/product_model.dart';
import 'package:mnemo_cards_common_backend/src/models/user/user_data_model.dart';
import 'discount_campaign_model.dart';
part 'discount_model.g.dart'; part 'discount_model.g.dart';
@ -13,9 +10,6 @@ class DiscountModel implements MnemoCardsProductModel {
String? id; String? id;
final double discountPercent; // 0 - 100 final double discountPercent; // 0 - 100
final List<MnemoCardsProductModelBase> products; final List<MnemoCardsProductModelBase> products;
// Relations - loaded separately from database
final List<UserDataModel> usersData = [];
final List<DiscountCampaignModel> campaigns = [];
DiscountModel({ DiscountModel({
required this.discountPercent, required this.discountPercent,

View file

@ -1,7 +1,5 @@
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:copy_with_extension/copy_with_extension.dart'; import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:mnemo_cards_common_backend/src/models/card_pack_model.dart';
import 'package:mnemo_cards_common_backend/src/models/voice_model.dart';
part 'game_card_model.g.dart'; part 'game_card_model.g.dart';
@ -25,9 +23,6 @@ class GameCardModel {
final String? imageBack; final String? imageBack;
@JsonKey(name: 'back') @JsonKey(name: 'back')
final String? back; final String? back;
// Relations - loaded separately from database
final List<CardPackModel> packs = [];
final List<VoiceModel> voices = [];
GameCardModel({ GameCardModel({
required this.id, required this.id,

View file

@ -1,6 +1,5 @@
import 'package:copy_with_extension/copy_with_extension.dart'; import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:mnemo_cards_common_backend/src/models/card_pack_model.dart';
import 'test_question_model.dart'; import 'test_question_model.dart';
@ -11,7 +10,6 @@ part 'test_model.g.dart';
class TestModel { class TestModel {
// Relations - loaded separately from database // Relations - loaded separately from database
final List<TestQuestionModel> questions = []; final List<TestQuestionModel> questions = [];
final List<CardPackModel> packs = [];
final String name; final String name;
final String? color; final String? color;

View file

@ -1,6 +1,5 @@
import 'package:copy_with_extension/copy_with_extension.dart'; import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:mnemo_cards_common_backend/src/models/game_tests/test_model.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
part 'test_question_model.g.dart'; part 'test_question_model.g.dart';
@ -12,9 +11,6 @@ class TestQuestionModel {
final TestQuestionType questionType; final TestQuestionType questionType;
final String body; final String body;
// Relations - loaded separately from database
final List<TestModel> tests = [];
String? id; String? id;
TestQuestionModel({ TestQuestionModel({

View file

@ -1,7 +1,5 @@
import 'package:copy_with_extension/copy_with_extension.dart'; import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:mnemo_cards_common_backend/src/models/game_tests/test_model.dart';
import 'package:mnemo_cards_common_backend/src/models/user/user_data_model.dart';
import '../statistics/word_statistics_model.dart'; import '../statistics/word_statistics_model.dart';
@ -10,9 +8,6 @@ part 'test_statistics_model.g.dart';
@CopyWith() @CopyWith()
class TestStatisticsModel { class TestStatisticsModel {
final String id; final String id;
// Relations - loaded separately from database
String? testId;
String? userId;
final List<TestAttempt> attempts; final List<TestAttempt> attempts;
TestStatisticsModel({ TestStatisticsModel({

View file

@ -5,7 +5,8 @@ extension PromoCodesCampaignModelExt on PromoCodesCampaignModel {
Future<PromoCodesCampaignDto> toDto({bool withCodes = false}) async { Future<PromoCodesCampaignDto> toDto({bool withCodes = false}) async {
List<String>? codes; List<String>? codes;
if (withCodes) { if (withCodes) {
codes = promoCodes.map((c) => c.code).toList(); // promoCodes should be loaded separately from database if needed
codes = null; // TODO: Load promoCodes from database if withCodes is true
} }
return PromoCodesCampaignDto( return PromoCodesCampaignDto(
id: id, id: id,

View file

@ -1,8 +1,4 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:copy_with_extension/copy_with_extension.dart'; import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'promo_codes_campaign_model.dart';
part 'promo_code_model.g.dart'; part 'promo_code_model.g.dart';
@ -11,8 +7,6 @@ class PromoCodeModel {
String? id; String? id;
final String code; final String code;
final int activations; final int activations;
// Relations - loaded separately from database
PromoCodesCampaignModel? campaign;
// Individual promo code // Individual promo code
String? userId; String? userId;

View file

@ -2,7 +2,6 @@ import 'package:json_annotation/json_annotation.dart';
import 'package:copy_with_extension/copy_with_extension.dart'; import 'package:copy_with_extension/copy_with_extension.dart';
import '../product_model.dart'; import '../product_model.dart';
import 'promo_code_model.dart';
part 'promo_codes_campaign_model.g.dart'; part 'promo_codes_campaign_model.g.dart';
@ -15,8 +14,6 @@ class PromoCodesCampaignModel {
final int activationsPerCode; final int activationsPerCode;
final int activationsPerUser; final int activationsPerUser;
final int generationSize; final int generationSize;
// Relations - loaded separately from database
final List<PromoCodeModel> promoCodes = [];
final DateTime start; final DateTime start;
final DateTime finish; final DateTime finish;
@JsonKey(name: 'status') @JsonKey(name: 'status')

View file

@ -1,6 +1,5 @@
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:copy_with_extension/copy_with_extension.dart'; import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:mnemo_cards_common_backend/src/models/user/user_model.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
part 'user_subscription_model.g.dart'; part 'user_subscription_model.g.dart';
@ -8,8 +7,6 @@ part 'user_subscription_model.g.dart';
@CopyWith() @CopyWith()
class UserSubscriptionModel { class UserSubscriptionModel {
String? id; String? id;
// Relations - loaded separately from database
UserModel? user;
final DateTime start; final DateTime start;
final DateTime finish; final DateTime finish;

View file

@ -2,9 +2,6 @@ import 'package:json_annotation/json_annotation.dart';
import 'package:copy_with_extension/copy_with_extension.dart'; import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import '../discount/discount_model.dart';
import '../game_tests/test_statistics_model.dart';
import '../promo_code/promo_code_model.dart';
import '../statistics/word_statistics_model.dart'; import '../statistics/word_statistics_model.dart';
import '../statistics/pack_progress_model.dart'; import '../statistics/pack_progress_model.dart';
import '../statistics/achievement_model.dart'; import '../statistics/achievement_model.dart';
@ -14,11 +11,6 @@ part 'user_data_model.g.dart';
@CopyWith() @CopyWith()
class UserDataModel { class UserDataModel {
String? id; String? id;
// Relations - loaded separately from database
final List<TestStatisticsModel> testsStatistics = [];
final List<PromoCodeModel> activatedPromoCodes = [];
final List<PromoCodeModel> individualPromoCodes = [];
final List<DiscountModel> activeDiscounts = [];
final List<WordStatisticsModel> words; final List<WordStatisticsModel> words;
final String? lastTestSessionToken; final String? lastTestSessionToken;
@ -36,18 +28,15 @@ class UserDataModel {
final int longestStreak; final int longestStreak;
/// Progress tracking for each pack /// Progress tracking for each pack
@JsonKey(fromJson: _packProgressFromJson, toJson: _packProgressToJson)
final List<PackProgressModel> packProgress; final List<PackProgressModel> packProgress;
/// List of dates when user studied (for streak calculation) /// List of dates when user studied (for streak calculation)
final List<DateTime> studyDates; final List<DateTime> studyDates;
/// Study time by category/language in minutes /// Study time by category/language in minutes
@JsonKey(includeFromJson: false, includeToJson: false)
final Map<String, int> categoryMinutes; final Map<String, int> categoryMinutes;
/// User's achievements /// User's achievements
@JsonKey(fromJson: _achievementsFromJson, toJson: _achievementsToJson)
final List<AchievementModel> achievements; final List<AchievementModel> achievements;
UserDataModel({ UserDataModel({

View file

@ -19,6 +19,8 @@ abstract class _$UserModelCWProxy {
UserModel purchases(List<String> purchases); UserModel purchases(List<String> purchases);
UserModel packs(List<CardPackModel> packs);
UserModel userSettings(String? userSettings); UserModel userSettings(String? userSettings);
/// Creates a new instance with the provided field values. /// Creates a new instance with the provided field values.
@ -35,6 +37,7 @@ abstract class _$UserModelCWProxy {
String? telegram, String? telegram,
bool admin, bool admin,
List<String> purchases, List<String> purchases,
List<CardPackModel> packs,
String? userSettings, String? userSettings,
}); });
} }
@ -64,6 +67,9 @@ class _$UserModelCWProxyImpl implements _$UserModelCWProxy {
@override @override
UserModel purchases(List<String> purchases) => call(purchases: purchases); UserModel purchases(List<String> purchases) => call(purchases: purchases);
@override
UserModel packs(List<CardPackModel> packs) => call(packs: packs);
@override @override
UserModel userSettings(String? userSettings) => UserModel userSettings(String? userSettings) =>
call(userSettings: userSettings); call(userSettings: userSettings);
@ -83,6 +89,7 @@ class _$UserModelCWProxyImpl implements _$UserModelCWProxy {
Object? telegram = const $CopyWithPlaceholder(), Object? telegram = const $CopyWithPlaceholder(),
Object? admin = const $CopyWithPlaceholder(), Object? admin = const $CopyWithPlaceholder(),
Object? purchases = const $CopyWithPlaceholder(), Object? purchases = const $CopyWithPlaceholder(),
Object? packs = const $CopyWithPlaceholder(),
Object? userSettings = const $CopyWithPlaceholder(), Object? userSettings = const $CopyWithPlaceholder(),
}) { }) {
return UserModel( return UserModel(
@ -110,6 +117,10 @@ class _$UserModelCWProxyImpl implements _$UserModelCWProxy {
? _value.purchases ? _value.purchases
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
: purchases as List<String>, : purchases as List<String>,
packs: packs == const $CopyWithPlaceholder() || packs == null
? _value.packs
// ignore: cast_nullable_to_non_nullable
: packs as List<CardPackModel>,
userSettings: userSettings == const $CopyWithPlaceholder() userSettings: userSettings == const $CopyWithPlaceholder()
? _value.userSettings ? _value.userSettings
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable

View file

@ -1,8 +1,6 @@
import 'package:copy_with_extension/copy_with_extension.dart'; import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'game_card_model.dart';
part 'voice_model.g.dart'; part 'voice_model.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake) @JsonSerializable(fieldRename: FieldRename.snake)
@ -18,8 +16,6 @@ class VoiceModel {
final DateTime? updatedAt; final DateTime? updatedAt;
@JsonKey(defaultValue: false) @JsonKey(defaultValue: false)
final bool isDeleted; final bool isDeleted;
// Relations - loaded separately from database
final List<GameCardModel> cards = [];
VoiceModel({ VoiceModel({
required this.phrase, required this.phrase,