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
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:
parent
dfcb7a1030
commit
336bafc600
70 changed files with 2659 additions and 1129 deletions
|
|
@ -6,6 +6,7 @@ import CardsPage from '@/pages/CardsPage'
|
|||
import PacksPage from '@/pages/PacksPage'
|
||||
import UsersPage from '@/pages/UsersPage'
|
||||
import TestsPage from '@/pages/TestsPage'
|
||||
import TasksPage from '@/pages/TasksPage'
|
||||
import Layout from '@/components/layout/Layout'
|
||||
import { TokenRefreshProvider } from '@/components/TokenRefreshProvider'
|
||||
|
||||
|
|
@ -31,6 +32,7 @@ function App() {
|
|||
<Route path="/packs" element={<PacksPage />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/tests" element={<TestsPage />} />
|
||||
<Route path="/tasks" element={<TasksPage />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { adminApiClient } from './client'
|
||||
import { tasksApi } from './tasks'
|
||||
|
||||
export interface DashboardStats {
|
||||
users: number
|
||||
|
|
@ -8,6 +9,13 @@ export interface DashboardStats {
|
|||
payments: number
|
||||
}
|
||||
|
||||
export interface TasksStats {
|
||||
total: number
|
||||
active: number
|
||||
completed: number
|
||||
expired: number
|
||||
}
|
||||
|
||||
export interface RecentUser {
|
||||
id: number
|
||||
name?: string
|
||||
|
|
@ -52,4 +60,34 @@ export const analyticsApi = {
|
|||
const response = await adminApiClient.get('/api/v2/admin/analytics/revenue/chart')
|
||||
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,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
|
|||
202
mnemo_cards_admin/src/api/tasks.ts
Normal file
202
mnemo_cards_admin/src/api/tasks.ts
Normal 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
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
Package,
|
||||
Users,
|
||||
ClipboardList,
|
||||
CheckSquare,
|
||||
LogOut,
|
||||
Menu,
|
||||
X
|
||||
|
|
@ -24,6 +25,7 @@ const navigation = [
|
|||
{ name: 'Packs', href: '/packs', icon: Package },
|
||||
{ name: 'Tests', href: '/tests', icon: ClipboardList },
|
||||
{ name: 'Users', href: '/users', icon: Users },
|
||||
{ name: 'Tasks', href: '/tasks', icon: CheckSquare },
|
||||
]
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
|
|
|
|||
90
mnemo_cards_admin/src/components/tasks/TaskCard.tsx
Normal file
90
mnemo_cards_admin/src/components/tasks/TaskCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
160
mnemo_cards_admin/src/components/tasks/TaskDetailsDialog.tsx
Normal file
160
mnemo_cards_admin/src/components/tasks/TaskDetailsDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
619
mnemo_cards_admin/src/components/tasks/TaskForm.tsx
Normal file
619
mnemo_cards_admin/src/components/tasks/TaskForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
61
mnemo_cards_admin/src/components/tasks/TaskRewardsList.tsx
Normal file
61
mnemo_cards_admin/src/components/tasks/TaskRewardsList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
50
mnemo_cards_admin/src/components/tasks/TaskStatusBadge.tsx
Normal file
50
mnemo_cards_admin/src/components/tasks/TaskStatusBadge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
37
mnemo_cards_admin/src/components/tasks/TaskTypeBadge.tsx
Normal file
37
mnemo_cards_admin/src/components/tasks/TaskTypeBadge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
8
mnemo_cards_admin/src/components/tasks/index.ts
Normal file
8
mnemo_cards_admin/src/components/tasks/index.ts
Normal 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'
|
||||
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
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'
|
||||
|
||||
export default function DashboardPage() {
|
||||
|
|
@ -30,6 +30,12 @@ export default function DashboardPage() {
|
|||
enabled: isReady,
|
||||
})
|
||||
|
||||
const { data: tasksStats } = useQuery<TasksStats>({
|
||||
queryKey: ['tasks-stats'],
|
||||
queryFn: analyticsApi.getTasksStats,
|
||||
enabled: isReady,
|
||||
})
|
||||
|
||||
if (dashboardLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
|
@ -65,7 +71,7 @@ export default function DashboardPage() {
|
|||
</div>
|
||||
|
||||
{/* 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>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
|
||||
|
|
@ -117,6 +123,19 @@ export default function DashboardPage() {
|
|||
</p>
|
||||
</CardContent>
|
||||
</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>
|
||||
|
||||
{/* Charts */}
|
||||
|
|
|
|||
519
mnemo_cards_admin/src/pages/TasksPage.tsx
Normal file
519
mnemo_cards_admin/src/pages/TasksPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -196,3 +196,55 @@ export interface TestDto {
|
|||
statistics?: unknown
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,10 +19,26 @@ import '../../discounts/discounts_manager.dart' as _i891;
|
|||
import '../../packs/free_packs_distributor.dart' as _i1062;
|
||||
import '../../packs/pack_dto_converter.dart' as _i433;
|
||||
import '../../packs/pack_manager.dart' as _i833;
|
||||
import '../../packs/pack_repository.dart' as _i258;
|
||||
import '../../packs/product_availability_manager.dart' as _i797;
|
||||
import '../../packs/products_price_resolver.dart' as _i908;
|
||||
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/session_tracker.dart' as _i71;
|
||||
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 '../../user/user_manager.dart' as _i280;
|
||||
import '../../user/user_manager_drift.dart' as _i560;
|
||||
import '../../user/user_repository.dart' as _i950;
|
||||
import '../ads/ads_manager.dart' as _i846;
|
||||
import '../mnemo_shelf.dart' as _i561;
|
||||
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_cards_api_v2.dart' as _i922;
|
||||
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_users_api_v2.dart' as _i895;
|
||||
import '../v2/auth_api_v2.dart' as _i52;
|
||||
|
|
@ -80,6 +96,14 @@ extension GetItInjectableX on _i174.GetIt {
|
|||
gh.lazySingleton<_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>(
|
||||
() => _i747.MinioService(gh<_i533.MinioConfig>()),
|
||||
);
|
||||
|
|
@ -95,132 +119,213 @@ extension GetItInjectableX on _i174.GetIt {
|
|||
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>(
|
||||
() => _i108.JwtService(gh<_i1072.AppDatabase>()),
|
||||
);
|
||||
gh.lazySingleton<_i258.PackRepository>(
|
||||
() => _i258.PackRepository(gh<_i1072.AppDatabase>()),
|
||||
gh.lazySingleton<_i403.AchievementRepository>(
|
||||
() => _i403.AchievementRepository(gh<_i1072.AppDatabase>()),
|
||||
);
|
||||
gh.lazySingleton<_i71.SessionTracker>(
|
||||
() => _i71.SessionTracker(gh<_i1072.AppDatabase>()),
|
||||
gh.lazySingleton<_i864.DiscountRepository>(
|
||||
() => _i864.DiscountRepository(gh<_i1072.AppDatabase>()),
|
||||
);
|
||||
gh.lazySingleton<_i909.WordStatisticsManager>(
|
||||
() => _i909.WordStatisticsManager(gh<_i1072.AppDatabase>()),
|
||||
gh.lazySingleton<_i32.PaymentRepository>(
|
||||
() => _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>(
|
||||
() => _i586.TaskManager(gh<_i1072.AppDatabase>()),
|
||||
);
|
||||
gh.lazySingleton<_i950.UserRepository>(
|
||||
() => _i950.UserRepository(gh<_i1072.AppDatabase>()),
|
||||
);
|
||||
gh.lazySingleton<_i964.SubscriptionsApiV2>(
|
||||
() => _i964.SubscriptionsApiV2(gh<_i377.SubscriptionManager>()),
|
||||
);
|
||||
gh.lazySingleton<_i296.TelegramBotApiV2>(
|
||||
() => _i296.TelegramBotApiV2(
|
||||
() => _i586.TaskManager(
|
||||
gh<_i69.TaskRepository>(),
|
||||
gh<_i1072.AppDatabase>(),
|
||||
gh<_i950.UserRepository>(),
|
||||
),
|
||||
);
|
||||
gh.lazySingleton<_i891.DiscountsManager>(
|
||||
() => _i891.DiscountsManager(
|
||||
gh.lazySingleton<_i71.SessionTracker>(
|
||||
() => _i71.SessionTracker(gh<_i69.StatisticsRepository>()),
|
||||
);
|
||||
gh.lazySingleton<_i851.PackRepository>(
|
||||
() => _i851.PackRepository(
|
||||
gh<_i1072.AppDatabase>(),
|
||||
gh<_i950.UserRepository>(),
|
||||
gh<_i381.CardPackConverter>(),
|
||||
gh<_i381.GameCardConverter>(),
|
||||
),
|
||||
);
|
||||
gh.lazySingleton<_i802.AchievementManager>(
|
||||
() => _i802.AchievementManager(
|
||||
gh.lazySingleton<_i1018.SubscriptionRepository>(
|
||||
() => _i1018.SubscriptionRepository(
|
||||
gh<_i1072.AppDatabase>(),
|
||||
gh<_i950.UserRepository>(),
|
||||
),
|
||||
);
|
||||
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<_i768.SubscriptionConverter>(),
|
||||
),
|
||||
);
|
||||
gh.lazySingleton<_i365.MediaApiV2>(
|
||||
() => _i365.MediaApiV2(gh<_i747.MinioService>()),
|
||||
);
|
||||
gh.factory<_i1071.AdminTasksApiV2>(
|
||||
() => _i1071.AdminTasksApiV2(
|
||||
gh<_i1072.AppDatabase>(),
|
||||
gh<_i69.TaskRepository>(),
|
||||
),
|
||||
);
|
||||
gh.lazySingleton<_i985.TasksApiV2>(
|
||||
() => _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>(
|
||||
() => _i1015.AdminPacksApiV2(
|
||||
gh<_i1072.AppDatabase>(),
|
||||
gh<_i258.PackRepository>(),
|
||||
gh<_i69.PackRepository>(),
|
||||
gh<_i69.TestRepository>(),
|
||||
),
|
||||
);
|
||||
gh.lazySingleton<_i368.AdminAnalyticsApiV2>(
|
||||
() => _i368.AdminAnalyticsApiV2(
|
||||
gh<_i950.UserRepository>(),
|
||||
gh.lazySingleton<_i377.SubscriptionManager>(
|
||||
() => _i377.SubscriptionManager(gh<_i69.SubscriptionRepository>()),
|
||||
);
|
||||
gh.lazySingleton<_i259.TestManager>(
|
||||
() => _i259.TestManager(
|
||||
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>(
|
||||
() => _i908.ProductsPriceResolver(
|
||||
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>(
|
||||
() => _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>(
|
||||
() => _i433.PackDtoConverter(
|
||||
gh<_i908.ProductsPriceResolver>(),
|
||||
gh<_i846.AdsManager>(),
|
||||
gh<_i69.UserRepository>(),
|
||||
),
|
||||
);
|
||||
gh.lazySingleton<_i833.PackManager>(
|
||||
() => _i833.PackManager(
|
||||
gh<_i1072.AppDatabase>(),
|
||||
gh<_i258.PackRepository>(),
|
||||
gh<_i69.PackRepository>(),
|
||||
gh<_i433.PackDtoConverter>(),
|
||||
gh<_i1072.AppDatabase>(),
|
||||
),
|
||||
);
|
||||
gh.lazySingleton<_i797.ProductAvailabilityManager>(
|
||||
() => _i797.ProductAvailabilityManager(
|
||||
gh<_i833.PackManager>(),
|
||||
gh<_i1072.AppDatabase>(),
|
||||
gh<_i69.UserRepository>(),
|
||||
),
|
||||
);
|
||||
gh.lazySingleton<_i1062.FreePacksDistributor>(
|
||||
() => _i1062.FreePacksDistributor(
|
||||
gh<_i797.ProductAvailabilityManager>(),
|
||||
gh<_i258.PackRepository>(),
|
||||
gh<_i69.PackRepository>(),
|
||||
),
|
||||
);
|
||||
gh.lazySingleton<_i280.UserManager>(
|
||||
() => _i280.UserManager(
|
||||
gh.lazySingleton<_i1009.PaymentManager>(
|
||||
() => _i1009.PaymentManager(
|
||||
gh<_i69.PaymentRepository>(),
|
||||
gh<_i69.SubscriptionRepository>(),
|
||||
gh<_i69.UserRepository>(),
|
||||
gh<_i797.ProductAvailabilityManager>(),
|
||||
gh<_i988.YooMoneyHandler>(),
|
||||
gh<_i222.RustorePurchaseHandler>(),
|
||||
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>(
|
||||
() => _i483.AdminAuthApiV2(
|
||||
gh<_i240.TelegramAuthCodeService>(),
|
||||
gh<_i280.UserManager>(),
|
||||
gh<_i108.JwtService>(),
|
||||
gh.lazySingleton<_i800.PacksApiV2>(
|
||||
() => _i800.PacksApiV2(
|
||||
gh<_i833.PackManager>(),
|
||||
gh<_i259.TestManager>(),
|
||||
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>(
|
||||
|
|
@ -232,35 +337,20 @@ extension GetItInjectableX on _i174.GetIt {
|
|||
gh<_i802.AchievementManager>(),
|
||||
),
|
||||
);
|
||||
gh.lazySingleton<_i561.MnemoShelf>(
|
||||
() => _i561.MnemoShelf(gh<_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.lazySingleton<_i280.UserManager>(
|
||||
() => _i280.UserManager(
|
||||
gh<_i1072.AppDatabase>(),
|
||||
gh<_i950.UserRepository>(),
|
||||
gh<_i797.ProductAvailabilityManager>(),
|
||||
gh<_i988.YooMoneyHandler>(),
|
||||
gh<_i222.RustorePurchaseHandler>(),
|
||||
gh<_i69.UserRepository>(),
|
||||
gh<_i69.PackRepository>(),
|
||||
gh<_i1062.FreePacksDistributor>(),
|
||||
gh<_i71.SessionTracker>(),
|
||||
gh<_i1029.StatisticsCalculator>(),
|
||||
gh<_i802.AchievementManager>(),
|
||||
gh<_i909.WordStatisticsManager>(),
|
||||
),
|
||||
);
|
||||
gh.lazySingleton<_i800.PacksApiV2>(
|
||||
() => _i800.PacksApiV2(
|
||||
gh<_i833.PackManager>(),
|
||||
gh<_i259.TestManager>(),
|
||||
gh<_i258.PackRepository>(),
|
||||
gh<_i797.ProductAvailabilityManager>(),
|
||||
gh<_i1072.AppDatabase>(),
|
||||
gh<_i747.MinioService>(),
|
||||
),
|
||||
gh.lazySingleton<_i273.PromocodesApiV2>(
|
||||
() => _i273.PromocodesApiV2(gh<_i151.PromoCodesManager>()),
|
||||
);
|
||||
gh.lazySingleton<_i735.CheckPaymentTask>(
|
||||
() => _i735.CheckPaymentTask(
|
||||
|
|
@ -275,13 +365,11 @@ extension GetItInjectableX on _i174.GetIt {
|
|||
gh<_i1072.AppDatabase>(),
|
||||
),
|
||||
);
|
||||
gh.lazySingleton<_i69.PurchasesApiV2>(
|
||||
() => _i69.PurchasesApiV2(
|
||||
gh<_i1009.PaymentManager>(),
|
||||
gh<_i833.PackManager>(),
|
||||
gh<_i950.UserRepository>(),
|
||||
gh<_i797.ProductAvailabilityManager>(),
|
||||
gh<_i1072.AppDatabase>(),
|
||||
gh.lazySingleton<_i483.AdminAuthApiV2>(
|
||||
() => _i483.AdminAuthApiV2(
|
||||
gh<_i240.TelegramAuthCodeService>(),
|
||||
gh<_i280.UserManager>(),
|
||||
gh<_i108.JwtService>(),
|
||||
),
|
||||
);
|
||||
gh.lazySingleton<_i247.UsersApiV2>(
|
||||
|
|
@ -289,18 +377,20 @@ extension GetItInjectableX on _i174.GetIt {
|
|||
gh<_i280.UserManager>(),
|
||||
gh<_i1009.PaymentManager>(),
|
||||
gh<_i1029.StatisticsCalculator>(),
|
||||
gh<_i950.UserRepository>(),
|
||||
gh<_i69.UserRepository>(),
|
||||
gh<_i1072.AppDatabase>(),
|
||||
),
|
||||
);
|
||||
gh.lazySingleton<_i151.PromoCodesManager>(
|
||||
() => _i151.PromoCodesManager(
|
||||
gh<_i1072.AppDatabase>(),
|
||||
gh<_i1009.PaymentManager>(),
|
||||
),
|
||||
gh.lazySingleton<_i561.MnemoShelf>(
|
||||
() => _i561.MnemoShelf(gh<_i280.UserManager>()),
|
||||
);
|
||||
gh.lazySingleton<_i273.PromocodesApiV2>(
|
||||
() => _i273.PromocodesApiV2(gh<_i151.PromoCodesManager>()),
|
||||
gh.lazySingleton<_i52.AuthApiV2>(
|
||||
() => _i52.AuthApiV2(
|
||||
gh<_i280.UserManager>(),
|
||||
gh<_i972.GoogleApi>(),
|
||||
gh<_i108.JwtService>(),
|
||||
gh<_i240.TelegramAuthCodeService>(),
|
||||
),
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import 'v2/admin_analytics_api_v2.dart';
|
|||
import 'v2/admin_auth_api_v2.dart';
|
||||
import 'v2/admin_cards_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_users_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<AdminCardsApiV2>().router);
|
||||
v2Router.mount('/', getIt.get<AdminPacksApiV2>().router);
|
||||
v2Router.mount('/', getIt.get<AdminTasksApiV2>().router);
|
||||
v2Router.mount('/', getIt.get<AdminTestsApiV2>().router);
|
||||
v2Router.mount('/', getIt.get<AdminUsersApiV2>().router);
|
||||
v2Router.mount('/', getIt.get<PacksApiV2>().router);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import 'package:googleapis/androidpublisher/v3.dart' as ap;
|
|||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
|
||||
import 'constaints.dart';
|
||||
import 'iap_repository.dart';
|
||||
import '../../repository/export.dart';
|
||||
import 'products.dart';
|
||||
|
||||
class GooglePlayPurchaseHandler {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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:drift/drift.dart' as drift;
|
||||
|
||||
import 'payment_drift_extension.dart';
|
||||
import 'rustore/rustore_purchase_handler.dart';
|
||||
import 'yoo_money.dart';
|
||||
import '../../packs/product_availability_manager.dart';
|
||||
import '../../user/user_repository.dart';
|
||||
import '../../repository/export.dart';
|
||||
|
||||
/// Result of creating YooKassa payment URL
|
||||
class YookassaPaymentResult {
|
||||
|
|
@ -26,19 +25,23 @@ class YookassaPaymentResult {
|
|||
|
||||
@lazySingleton
|
||||
class PaymentManager {
|
||||
final AppDatabase _db;
|
||||
final PaymentRepository _paymentRepository;
|
||||
final SubscriptionRepository _subscriptionRepository;
|
||||
final UserRepository _userRepository;
|
||||
final ProductAvailabilityManager _productAvailabilityManager;
|
||||
late final GooglePlayPurchaseHandler googlePurchaseHandler;
|
||||
final YooMoneyHandler _yooMoneyHandler;
|
||||
final RustorePurchaseHandler _rustorePurchaseHandler;
|
||||
final AppDatabase _db;
|
||||
|
||||
PaymentManager(
|
||||
this._db,
|
||||
this._paymentRepository,
|
||||
this._subscriptionRepository,
|
||||
this._userRepository,
|
||||
this._productAvailabilityManager,
|
||||
this._yooMoneyHandler,
|
||||
this._rustorePurchaseHandler,
|
||||
this._db,
|
||||
);
|
||||
|
||||
/// Создать платеж в базе данных
|
||||
|
|
@ -47,32 +50,22 @@ class PaymentManager {
|
|||
print(
|
||||
'🔍 PaymentManager.createPayment: userId=$userId, externalToken=${paymentDto.externalToken}',
|
||||
);
|
||||
final companion = paymentDto.toCompanion(userId);
|
||||
print('🔍 PaymentManager.createPayment: Calling paymentDao.createPayment');
|
||||
final paymentId = await _db.paymentDao.createPayment(companion);
|
||||
print('✅ PaymentManager.createPayment: Payment created, id=$paymentId');
|
||||
print('🔍 PaymentManager.createPayment: Getting payment by id');
|
||||
final payment = await _db.paymentDao.getPaymentById(paymentId);
|
||||
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();
|
||||
print(
|
||||
'🔍 PaymentManager.createPayment: Calling paymentRepository.createPayment',
|
||||
);
|
||||
final payment = await _paymentRepository.createPayment(paymentDto, userId);
|
||||
print('✅ PaymentManager.createPayment: Payment created successfully');
|
||||
return payment;
|
||||
}
|
||||
|
||||
/// Обновить платеж в базе данных
|
||||
Future<void> updatePayment(String paymentId, PaymentDto paymentDto) async {
|
||||
final companion = paymentDto.toUpdateCompanion();
|
||||
await _db.paymentDao.updatePaymentCompanion(paymentId, companion);
|
||||
await _paymentRepository.updatePayment(paymentId, paymentDto);
|
||||
}
|
||||
|
||||
/// Получить платеж по ID
|
||||
Future<PaymentDto?> getPaymentById(String id) async {
|
||||
final payment = await _db.paymentDao.getPaymentById(id);
|
||||
return payment?.toDto();
|
||||
return await _paymentRepository.getPaymentById(id);
|
||||
}
|
||||
|
||||
/// Получить последний платеж для пользователя и пака
|
||||
|
|
@ -81,11 +74,10 @@ class PaymentManager {
|
|||
required String userId,
|
||||
required String packId,
|
||||
}) async {
|
||||
final payment = await _db.paymentDao.getLatestPaymentForUserAndPack(
|
||||
return await _paymentRepository.getLatestPaymentForUserAndPack(
|
||||
userId: userId,
|
||||
packId: packId,
|
||||
);
|
||||
return payment?.toDto();
|
||||
}
|
||||
|
||||
/// Выдать продукт пользователю (для промокодов и других бесплатных активаций)
|
||||
|
|
@ -104,17 +96,17 @@ class PaymentManager {
|
|||
} else if (product.type == MnemoCardsProductType.subscription &&
|
||||
product.id != null) {
|
||||
final planId = product.id!;
|
||||
final plan = await _db.subscriptionDao.getPlanById(planId);
|
||||
final plan = await _subscriptionRepository.getPlanById(planId);
|
||||
if (plan != null) {
|
||||
final now = DateTime.now();
|
||||
final endDate = now.add(Duration(days: plan.durationDays));
|
||||
|
||||
await _db.subscriptionDao.createUserSubscription(
|
||||
await _subscriptionRepository.createSubscription(
|
||||
UserSubscriptionsCompanion.insert(
|
||||
userId: userId,
|
||||
start: PgDateTime(now),
|
||||
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');
|
||||
|
|
@ -135,10 +127,7 @@ class PaymentManager {
|
|||
}
|
||||
|
||||
// Получить пользователя
|
||||
final userModel = await _userRepository.getUserById(
|
||||
payment.userId,
|
||||
withData: true,
|
||||
);
|
||||
final userModel = await _userRepository.getUserById(payment.userId);
|
||||
if (userModel == null) {
|
||||
log('User not found: ${payment.userId}');
|
||||
return;
|
||||
|
|
@ -188,17 +177,17 @@ class PaymentManager {
|
|||
|
||||
// Создать подписки
|
||||
for (final subscriptionId in subscriptionIds) {
|
||||
final plan = await _db.subscriptionDao.getPlanById(subscriptionId);
|
||||
final plan = await _subscriptionRepository.getPlanById(subscriptionId);
|
||||
if (plan != null) {
|
||||
final now = DateTime.now();
|
||||
final endDate = now.add(Duration(days: plan.durationDays));
|
||||
|
||||
await _db.subscriptionDao.createUserSubscription(
|
||||
await _subscriptionRepository.createSubscription(
|
||||
UserSubscriptionsCompanion.insert(
|
||||
userId: payment.userId,
|
||||
start: PgDateTime(now),
|
||||
finish: PgDateTime(endDate),
|
||||
features: drift.Value(plan.features),
|
||||
features: drift.Value(plan.features.map((f) => f.name).toList()),
|
||||
),
|
||||
);
|
||||
log(
|
||||
|
|
@ -215,7 +204,7 @@ class PaymentManager {
|
|||
try {
|
||||
// Try to update by externalToken (if it's used as identifier)
|
||||
// TODO: Once database is regenerated, use payment.id directly
|
||||
await _db.paymentDao.updatePaymentStatus(
|
||||
await _paymentRepository.updatePaymentStatus(
|
||||
payment.externalToken!,
|
||||
PaymentStatus.processed.name,
|
||||
);
|
||||
|
|
@ -294,24 +283,30 @@ class PaymentManager {
|
|||
.checkPayment(subscriptionToken);
|
||||
|
||||
// Найти платеж по продукту
|
||||
final payments = await _db.paymentDao.getPaymentsByProduct(productId);
|
||||
final payment = payments.isNotEmpty ? payments.first : null;
|
||||
final paymentsDto = await _paymentRepository.getPaymentsByProduct(
|
||||
productId,
|
||||
);
|
||||
final paymentDto = paymentsDto.isNotEmpty ? paymentsDto.first : null;
|
||||
|
||||
if (payment == null) {
|
||||
if (paymentDto == null) {
|
||||
log('Payment not found for product: $productId');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (rustorePurchaseResponse?.invoiceStatus.name == 'paid') {
|
||||
// Find payment id by productId or use subscriptionToken
|
||||
final token = payment.externalToken ?? subscriptionToken;
|
||||
final token = paymentDto.externalToken ?? subscriptionToken;
|
||||
if (token.isNotEmpty) {
|
||||
await _db.paymentDao.updatePaymentStatus(
|
||||
await _paymentRepository.updatePaymentStatus(
|
||||
token,
|
||||
PaymentStatus.succeeded.name,
|
||||
);
|
||||
}
|
||||
await processPayment(payment);
|
||||
// Обработать платеж - нужно получить Payment из БД для processPayment
|
||||
final payment = await _db.paymentDao.getPaymentByExternalToken(token);
|
||||
if (payment != null) {
|
||||
await processPayment(payment);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -325,8 +320,10 @@ class PaymentManager {
|
|||
/// Проверить платеж YooKassa
|
||||
Future<bool> checkYookassaPayment(String token) async {
|
||||
try {
|
||||
final payment = await _db.paymentDao.getPaymentByExternalToken(token);
|
||||
if (payment == null) {
|
||||
final paymentDto = await _paymentRepository.getPaymentByExternalToken(
|
||||
token,
|
||||
);
|
||||
if (paymentDto == null) {
|
||||
print('Payment not found for token: $token');
|
||||
return false;
|
||||
}
|
||||
|
|
@ -336,14 +333,18 @@ class PaymentManager {
|
|||
|
||||
if (yookassaPayment.status == 'succeeded') {
|
||||
// Use token as payment identifier
|
||||
await _db.paymentDao.updatePaymentStatus(
|
||||
await _paymentRepository.updatePaymentStatus(
|
||||
token,
|
||||
PaymentStatus.succeeded.name,
|
||||
);
|
||||
await processPayment(payment);
|
||||
// Обработать платеж - нужно получить Payment из БД для processPayment
|
||||
final payment = await _db.paymentDao.getPaymentByExternalToken(token);
|
||||
if (payment != null) {
|
||||
await processPayment(payment);
|
||||
}
|
||||
return true;
|
||||
} else if (yookassaPayment.status == 'canceled') {
|
||||
await _db.paymentDao.updatePaymentStatus(
|
||||
await _paymentRepository.updatePaymentStatus(
|
||||
token,
|
||||
PaymentStatus.canceled.name,
|
||||
);
|
||||
|
|
@ -415,8 +416,7 @@ class PaymentManager {
|
|||
|
||||
/// Получить платежи пользователя
|
||||
Future<List<PaymentDto>> getUserPayments(String userIdString) async {
|
||||
final payments = await _db.paymentDao.getPaymentsByUserId(userIdString);
|
||||
return payments.map((p) => p.toDto()).toList();
|
||||
return await _paymentRepository.getPaymentsByUserId(userIdString);
|
||||
}
|
||||
|
||||
/// Проверить и обработать платеж (для cron задач)
|
||||
|
|
|
|||
|
|
@ -259,11 +259,7 @@ class YooMoneyHandler {
|
|||
|
||||
/// Build return URL for payment confirmation
|
||||
/// Uses YOOKASSA_RETURN_URL env var if set, otherwise defaults to web app URL
|
||||
String _buildReturnUrl(
|
||||
String userId, {
|
||||
String? packId,
|
||||
String? paymentId,
|
||||
}) {
|
||||
String _buildReturnUrl(String userId, {String? packId, String? paymentId}) {
|
||||
// Construct query parameters
|
||||
final params = <String, String>{
|
||||
'userId': userId,
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
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/repository/export.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
@lazySingleton
|
||||
class SubscriptionManager {
|
||||
final AppDatabase _db;
|
||||
final SubscriptionRepository _subscriptionRepository;
|
||||
|
||||
SubscriptionManager(this._db);
|
||||
SubscriptionManager(this._subscriptionRepository);
|
||||
|
||||
Future<SubscriptionDto> getSubscriptionDto(UserModel user) async {
|
||||
if (user.id == null) {
|
||||
|
|
@ -21,15 +22,15 @@ class SubscriptionManager {
|
|||
);
|
||||
}
|
||||
|
||||
final subscription = await _db.subscriptionDao.getActiveUserSubscription(
|
||||
final subscription = await _subscriptionRepository.getActiveSubscription(
|
||||
user.id!,
|
||||
);
|
||||
|
||||
return SubscriptionDto(
|
||||
page: null,
|
||||
isActive: subscription != null,
|
||||
start: subscription?.start.dateTime,
|
||||
finish: subscription?.finish.dateTime,
|
||||
start: subscription?.start,
|
||||
finish: subscription?.finish,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -44,7 +45,7 @@ class SubscriptionManager {
|
|||
final now = DateTime.now();
|
||||
final endDate = now.add(Duration(days: plan.durationDays));
|
||||
|
||||
await _db.subscriptionDao.createUserSubscription(
|
||||
await _subscriptionRepository.createSubscription(
|
||||
UserSubscriptionsCompanion.insert(
|
||||
userId: user.id!,
|
||||
start: PgDateTime(now),
|
||||
|
|
@ -55,25 +56,7 @@ class SubscriptionManager {
|
|||
}
|
||||
|
||||
Future<List<SubscriptionPlanModel>> getAllPlans() async {
|
||||
final plans = await _db.subscriptionDao.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();
|
||||
return await _subscriptionRepository.getAllPlans();
|
||||
}
|
||||
|
||||
Future<List<SubscriptionPlanModel>> getAllSubscriptionPlans() async {
|
||||
|
|
@ -81,7 +64,7 @@ class SubscriptionManager {
|
|||
}
|
||||
|
||||
Future<void> purchaseSubscription(String userId, String planId) async {
|
||||
final plan = await _db.subscriptionDao.getPlanById(planId);
|
||||
final plan = await _subscriptionRepository.getPlanById(planId);
|
||||
if (plan == null) {
|
||||
throw StateError('Subscription plan not found');
|
||||
}
|
||||
|
|
@ -89,17 +72,17 @@ class SubscriptionManager {
|
|||
final now = DateTime.now();
|
||||
final endDate = now.add(Duration(days: plan.durationDays));
|
||||
|
||||
await _db.subscriptionDao.createUserSubscription(
|
||||
await _subscriptionRepository.createSubscription(
|
||||
UserSubscriptionsCompanion.insert(
|
||||
userId: userId,
|
||||
start: PgDateTime(now),
|
||||
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 {
|
||||
await _db.subscriptionDao.cancelUserSubscription(userId);
|
||||
await _subscriptionRepository.cancelSubscription(userId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/helpers.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_router/shelf_router.dart';
|
||||
|
||||
|
|
|
|||
|
|
@ -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/packs/card_pack_drift_extension.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_backend/mnemo_cards_common_backend.dart';
|
||||
import 'package:shelf/shelf.dart';
|
||||
|
|
@ -22,8 +22,9 @@ part 'admin_packs_api_v2.g.dart';
|
|||
class AdminPacksApiV2 {
|
||||
final AppDatabase _db;
|
||||
final PackRepository _packRepository;
|
||||
final TestRepository _testRepository;
|
||||
|
||||
AdminPacksApiV2(this._db, this._packRepository);
|
||||
AdminPacksApiV2(this._db, this._packRepository, this._testRepository);
|
||||
|
||||
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}$',
|
||||
|
|
@ -70,8 +71,7 @@ class AdminPacksApiV2 {
|
|||
mnemo: const drift.Value('test_image'),
|
||||
);
|
||||
|
||||
// Note: createCard is not in PackRepository, keeping direct DAO call for now
|
||||
final cardId = await _db.packDao.createCard(companion);
|
||||
final cardId = await _packRepository.createCard(companion);
|
||||
await _tryLinkCardToPack(packId: packId, cardId: cardId);
|
||||
|
||||
final stored = await CardImageStorage.persistFromBase64(
|
||||
|
|
@ -85,12 +85,11 @@ class AdminPacksApiV2 {
|
|||
final created = await _packRepository.getCardById(cardId);
|
||||
if (created == null) return null;
|
||||
|
||||
// Note: updateCard is not in PackRepository, keeping direct DAO call for now
|
||||
// We need to get the Drift GameCard to update it
|
||||
// TODO: Refactor to use PackRepository.updateCard with GameCardModel
|
||||
final driftCard = await _db.packDao.getCardById(cardId);
|
||||
if (driftCard == null) return null;
|
||||
|
||||
await _db.packDao.updateCard(
|
||||
await _packRepository.updateCard(
|
||||
driftCard.copyWith(
|
||||
image: stored.fileName,
|
||||
updatedAt: PgDateTime(DateTime.now()),
|
||||
|
|
@ -143,6 +142,7 @@ class AdminPacksApiV2 {
|
|||
packId: packId,
|
||||
);
|
||||
if ((test.cover ?? '').trim() != (normalizedCover ?? '').trim()) {
|
||||
// TODO: Refactor to use TestRepository.updateTest
|
||||
await _db.testDao.updateTest(
|
||||
test.copyWith(
|
||||
cover: drift.Value(normalizedCover),
|
||||
|
|
@ -155,7 +155,7 @@ class AdminPacksApiV2 {
|
|||
}
|
||||
|
||||
// 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) {
|
||||
// Parse options/buttons
|
||||
List<dynamic> buttons;
|
||||
|
|
@ -220,6 +220,7 @@ class AdminPacksApiV2 {
|
|||
}
|
||||
|
||||
if (mutated) {
|
||||
// TODO: Refactor to use TestRepository.updateTestQuestion
|
||||
await _db.testDao.updateTestQuestion(
|
||||
q.copyWith(
|
||||
options: jsonEncode(normalizedButtons),
|
||||
|
|
@ -402,7 +403,7 @@ class AdminPacksApiV2 {
|
|||
final previewCardIds = previewCards.map((c) => c.id).toList();
|
||||
|
||||
// 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();
|
||||
|
||||
// Create EditCardPackDto
|
||||
|
|
@ -596,7 +597,7 @@ class AdminPacksApiV2 {
|
|||
try {
|
||||
for (final testId in editDto.addTestIds!) {
|
||||
// Verify test exists before adding
|
||||
final test = await _db.testDao.getTestById(testId);
|
||||
final test = await _testRepository.getTestById(testId);
|
||||
if (test == null) {
|
||||
return _json({
|
||||
'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.',
|
||||
}, statusCode: 404);
|
||||
}
|
||||
await _db.testDao.linkTestToPack(testId, packId);
|
||||
await _testRepository.linkTestToPack(testId, packId);
|
||||
// Important: tests may reference cardIds (or even base64) in
|
||||
// question/button images. Ensure those cards are linked to this pack
|
||||
// so `/api/v2/packs/<packId>/cards/<cardId>/image` works.
|
||||
|
|
@ -631,7 +632,7 @@ class AdminPacksApiV2 {
|
|||
|
||||
if (editDto.removeTestIds != null && editDto.removeTestIds!.isNotEmpty) {
|
||||
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();
|
||||
|
||||
// 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 updatedDto = EditCardPackDto(
|
||||
|
|
|
|||
|
|
@ -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/user/user_model.dart'
|
||||
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_backend/mnemo_cards_common_backend.dart';
|
||||
import 'package:shelf/shelf.dart';
|
||||
|
|
@ -25,6 +26,30 @@ class AdminUsersApiV2 {
|
|||
|
||||
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(
|
||||
Object? data, {
|
||||
int statusCode = 200,
|
||||
|
|
@ -96,7 +121,13 @@ class AdminUsersApiV2 {
|
|||
// Convert to DTOs
|
||||
final userDtos = <Map<String, dynamic>>[];
|
||||
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());
|
||||
}
|
||||
|
||||
|
|
@ -173,7 +204,13 @@ class AdminUsersApiV2 {
|
|||
}, 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());
|
||||
} catch (e, s) {
|
||||
|
|
@ -272,7 +309,13 @@ class AdminUsersApiV2 {
|
|||
}, 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()});
|
||||
} else {
|
||||
|
|
@ -330,7 +373,13 @@ class AdminUsersApiV2 {
|
|||
}, 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()});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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_model_extension.dart';
|
||||
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/voice_storage.dart';
|
||||
import 'package:mnemo_cards_backend/storage/minio_config.dart';
|
||||
|
|
|
|||
|
|
@ -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/packs/pack_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/mnemo_cards_common.dart';
|
||||
import 'package:shelf/shelf.dart';
|
||||
|
|
@ -23,6 +23,8 @@ class PurchasesApiV2 {
|
|||
final PaymentManager _paymentManager;
|
||||
final PackManager _packManager;
|
||||
final UserRepository _userRepository;
|
||||
final SubscriptionRepository _subscriptionRepository;
|
||||
final PaymentRepository _paymentRepository;
|
||||
final ProductAvailabilityManager _productAvailabilityManager;
|
||||
final AppDatabase _db;
|
||||
|
||||
|
|
@ -30,6 +32,8 @@ class PurchasesApiV2 {
|
|||
this._paymentManager,
|
||||
this._packManager,
|
||||
this._userRepository,
|
||||
this._subscriptionRepository,
|
||||
this._paymentRepository,
|
||||
this._productAvailabilityManager,
|
||||
this._db,
|
||||
);
|
||||
|
|
@ -65,10 +69,7 @@ class PurchasesApiV2 {
|
|||
|
||||
Response _forbidden(String message) => Response(
|
||||
403,
|
||||
body: jsonEncode({
|
||||
'error': 'Forbidden',
|
||||
'message': message,
|
||||
}),
|
||||
body: jsonEncode({'error': 'Forbidden', 'message': message}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
|
||||
|
|
@ -250,15 +251,15 @@ class PurchasesApiV2 {
|
|||
];
|
||||
} else if (productType == MnemoCardsProductType.subscription) {
|
||||
// Get subscription plan
|
||||
final planDrift = await _db.subscriptionDao.getPlanById(productId);
|
||||
final planDrift = await _subscriptionRepository.getPlanById(productId);
|
||||
if (planDrift == null) {
|
||||
return _notFound('Subscription plan not found');
|
||||
}
|
||||
|
||||
// Convert to SubscriptionPlanModel
|
||||
final uiMap = planDrift.ui;
|
||||
final ui = uiMap is Map<String, dynamic>
|
||||
? SubscriptionPlanUI.fromJson(uiMap)
|
||||
final ui = uiMap != null
|
||||
? SubscriptionPlanUI.fromJson(uiMap as Map<String, dynamic>)
|
||||
: null;
|
||||
final plan = SubscriptionPlanModel(
|
||||
id: planDrift.id,
|
||||
|
|
@ -344,9 +345,9 @@ class PurchasesApiV2 {
|
|||
if (paymentId == 'latest' && user.id != null) {
|
||||
final latestPayment = await _paymentManager
|
||||
.getLatestPaymentForUserAndPack(
|
||||
userId: user.id!,
|
||||
packId: productId,
|
||||
);
|
||||
userId: user.id!,
|
||||
packId: productId,
|
||||
);
|
||||
if (latestPayment == null || latestPayment.externalToken == null) {
|
||||
return _notFound('No payment found for this pack');
|
||||
}
|
||||
|
|
@ -354,8 +355,9 @@ class PurchasesApiV2 {
|
|||
}
|
||||
|
||||
// Check payment status
|
||||
final isSuccess =
|
||||
await _paymentManager.checkYookassaPayment(actualPaymentId);
|
||||
final isSuccess = await _paymentManager.checkYookassaPayment(
|
||||
actualPaymentId,
|
||||
);
|
||||
|
||||
// Get product information
|
||||
MnemoCardsProductDto? product;
|
||||
|
|
@ -411,7 +413,10 @@ class PurchasesApiV2 {
|
|||
);
|
||||
|
||||
// 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
|
||||
final activeSubscription = await _db.subscriptionDao
|
||||
|
|
@ -458,20 +463,27 @@ class PurchasesApiV2 {
|
|||
);
|
||||
|
||||
// Find payment by external token (YooKassa payment ID)
|
||||
final payment = await _db.paymentDao.getPaymentByExternalToken(paymentId);
|
||||
final payment = await _paymentRepository.getPaymentByExternalToken(
|
||||
paymentId,
|
||||
);
|
||||
|
||||
if (payment == null) {
|
||||
developer.log(
|
||||
'Payment not found: $paymentId',
|
||||
name: 'PurchasesApiV2',
|
||||
);
|
||||
developer.log('Payment not found: $paymentId', name: 'PurchasesApiV2');
|
||||
return _notFound('Payment not found');
|
||||
}
|
||||
|
||||
// Get Payment from DB to check userId
|
||||
final paymentDrift = await _db.paymentDao.getPaymentByExternalToken(
|
||||
paymentId,
|
||||
);
|
||||
if (paymentDrift == null) {
|
||||
return _notFound('Payment not found');
|
||||
}
|
||||
|
||||
// Verify payment belongs to current user
|
||||
if (payment.userId != user.id) {
|
||||
if (paymentDrift.userId != user.id) {
|
||||
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',
|
||||
);
|
||||
return _forbidden('Access denied');
|
||||
|
|
@ -483,11 +495,12 @@ class PurchasesApiV2 {
|
|||
);
|
||||
|
||||
// Force check and process payment
|
||||
await _paymentManager.checkAndProcessPayment(payment);
|
||||
await _paymentManager.checkAndProcessPayment(paymentDrift);
|
||||
|
||||
// Get updated payment status
|
||||
final updatedPayment =
|
||||
await _db.paymentDao.getPaymentByExternalToken(paymentId);
|
||||
final updatedPayment = await _paymentRepository.getPaymentByExternalToken(
|
||||
paymentId,
|
||||
);
|
||||
|
||||
final finalStatus = updatedPayment?.status ?? payment.status;
|
||||
|
||||
|
|
|
|||
|
|
@ -20,5 +20,10 @@ Router _$PurchasesApiV2Router(PurchasesApiV2 service) {
|
|||
r'/purchases/packs/<packId>/status',
|
||||
service.getPackPurchaseStatus,
|
||||
);
|
||||
router.add(
|
||||
'POST',
|
||||
r'/purchases/check-payment/<paymentId>',
|
||||
service.checkPayment,
|
||||
);
|
||||
return router;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ 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/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:shelf/shelf.dart';
|
||||
import 'package:shelf_open_api/shelf_open_api.dart';
|
||||
|
|
|
|||
|
|
@ -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/user/user_manager.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_backend/mnemo_cards_common_backend.dart';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:shelf_router/shelf_router.dart';
|
||||
|
||||
|
|
@ -99,11 +100,30 @@ class UsersApiV2 {
|
|||
|
||||
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(
|
||||
packProgress: packProgress,
|
||||
studyDates: studyDates,
|
||||
categoryMinutes: categoryMinutes,
|
||||
wordsStatistics: wordsDto,
|
||||
userData: userDataModel,
|
||||
subscriptionModel: subscription,
|
||||
);
|
||||
return _json(dto.toJson());
|
||||
}
|
||||
|
|
@ -155,7 +175,28 @@ class UsersApiV2 {
|
|||
);
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
|
|
@ -252,20 +293,11 @@ class UsersApiV2 {
|
|||
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) {
|
||||
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
|
||||
final studyDates = await _statisticsCalculator.calculateStudyDates(
|
||||
user.id!,
|
||||
|
|
@ -345,27 +377,27 @@ class UsersApiV2 {
|
|||
final validatedLimit = limit.clamp(1, 100);
|
||||
final validatedOffset = offset < 0 ? 0 : offset;
|
||||
|
||||
final userData = user.userData;
|
||||
if (userData == null) {
|
||||
return _json({
|
||||
'words': [],
|
||||
'totalCount': 0,
|
||||
'page': 0,
|
||||
'pageSize': validatedLimit,
|
||||
'hasMore': false,
|
||||
});
|
||||
// Get words statistics from database
|
||||
final wordStats = await _db.wordStatisticsDao.getUserStatistics(user.id!);
|
||||
final wordsList = <DetailedWordStatisticsDto>[];
|
||||
|
||||
// For each statistic, get the card and extract word data
|
||||
for (final stat in wordStats) {
|
||||
final card = await _db.packDao.getCardById(stat.cardId);
|
||||
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 = 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();
|
||||
var words = wordsList;
|
||||
|
||||
// Apply pack filter if specified
|
||||
if (packId != null) {
|
||||
|
|
@ -552,13 +584,26 @@ class UsersApiV2 {
|
|||
}
|
||||
|
||||
try {
|
||||
final userData = user.userData;
|
||||
final userData = await _userRepository.getUserDataById(user.id!);
|
||||
if (userData == null) {
|
||||
return _json([]);
|
||||
}
|
||||
|
||||
final achievements = _statisticsCalculator.calculateAchievemegntProgress(
|
||||
userData,
|
||||
// Convert UserData to UserDataModel
|
||||
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());
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
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/user/user_repository.dart';
|
||||
import 'package:mnemo_cards_backend/repository/export.dart';
|
||||
|
||||
import 'task.dart' as task;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:drift/drift.dart';
|
||||
import 'package:drift_postgres/drift_postgres.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 'task.dart' as task;
|
||||
|
|
|
|||
|
|
@ -213,7 +213,9 @@ class PaymentDao extends DatabaseAccessor<AppDatabase>
|
|||
required String packId,
|
||||
}) async {
|
||||
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)])
|
||||
..limit(1);
|
||||
|
||||
|
|
|
|||
|
|
@ -143,6 +143,14 @@ class TaskDao extends DatabaseAccessor<AppDatabase> with _$TaskDaoMixin {
|
|||
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 ====================
|
||||
|
||||
/// Получить прогресс задачи пользователя
|
||||
|
|
|
|||
|
|
@ -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/repository/export.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
|
||||
extension DiscountCampaignModelExt on DiscountCampaignModel {
|
||||
Future<DiscountCampaignDto> toDto() async {
|
||||
Future<DiscountCampaignDto> toDto(
|
||||
DiscountRepository discountRepository,
|
||||
) async {
|
||||
final discounts = await discountRepository.getDiscountsByCampaignId(id!);
|
||||
return DiscountCampaignDto(
|
||||
id: id,
|
||||
start: this.start,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import 'package:drift/drift.dart' as drift;
|
||||
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/repository/export.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
|
||||
/// Extension для конвертации DiscountCampaign (Drift) в DTO
|
||||
extension DiscountCampaignToDto on DiscountCampaign {
|
||||
Future<DiscountCampaignDto> toDto(DiscountDao discountDao) async {
|
||||
final discounts = await discountDao.getDiscountsByCampaignId(id);
|
||||
Future<DiscountCampaignDto> toDto(
|
||||
DiscountRepository discountRepository,
|
||||
) async {
|
||||
final discounts = await discountRepository.getDiscountsByCampaignId(id);
|
||||
final discountsDto = discounts.map((d) => d.toDto()).toList();
|
||||
|
||||
DiscountCampaignStatus statusEnum;
|
||||
|
|
@ -44,7 +46,7 @@ extension DiscountCampaignToDto on DiscountCampaign {
|
|||
/// Extension для конвертации Discount (Drift) в DTO
|
||||
extension DiscountToDto on Discount {
|
||||
DiscountDto toDto() {
|
||||
final productsList = (products ?? [])
|
||||
final productsList = products
|
||||
.map((p) {
|
||||
if (p is Map<String, dynamic>) {
|
||||
return MnemoCardsProductDto.fromJson(p);
|
||||
|
|
|
|||
|
|
@ -1,22 +1,19 @@
|
|||
import 'dart:developer';
|
||||
|
||||
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/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/mnemo_cards_common.dart' hide DateTimeExt;
|
||||
import 'package:mnemo_cards_common/src/utils/utils.dart';
|
||||
|
||||
@lazySingleton
|
||||
class DiscountsManager {
|
||||
final AppDatabase _db;
|
||||
final DiscountDao _discountDao;
|
||||
final DiscountRepository _discountRepository;
|
||||
final UserRepository _userRepository;
|
||||
|
||||
DiscountsManager(this._db, this._userRepository)
|
||||
: _discountDao = _db.discountDao;
|
||||
DiscountsManager(this._discountRepository, this._userRepository);
|
||||
|
||||
Future<void> applyDiscount({
|
||||
required Iterable<DiscountModel> discounts,
|
||||
|
|
@ -33,7 +30,7 @@ class DiscountsManager {
|
|||
if (discountIds.isEmpty) return;
|
||||
|
||||
// Добавляем скидки пользователю через junction таблицу
|
||||
await _discountDao.grantDiscountsToUser(user.id!, discountIds);
|
||||
await _discountRepository.grantDiscountsToUser(user.id!, discountIds);
|
||||
}
|
||||
|
||||
Future<void> changeCampaignStatus(DiscountCampaign campaign) async {
|
||||
|
|
@ -52,7 +49,7 @@ class DiscountsManager {
|
|||
}
|
||||
|
||||
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;
|
||||
|
||||
// Получаем скидки кампании
|
||||
final discounts = await _discountDao.getDiscountsByCampaignId(campaign.id);
|
||||
final discounts = await _discountRepository.getDiscountsByCampaignId(
|
||||
campaign.id,
|
||||
);
|
||||
final discountIds = discounts.map((d) => d.id).toList();
|
||||
|
||||
if (discountIds.isEmpty) return;
|
||||
|
||||
// Удаляем скидки пользователя
|
||||
await _discountDao.revokeDiscountsFromUser(user.id!, discountIds);
|
||||
await _discountRepository.revokeDiscountsFromUser(user.id!, discountIds);
|
||||
}
|
||||
|
||||
Future<double> getProductDiscount(
|
||||
|
|
@ -92,7 +91,9 @@ class DiscountsManager {
|
|||
double maxDiscount = 0;
|
||||
|
||||
// Получаем активные скидки пользователя из junction таблицы
|
||||
final userDiscounts = await _discountDao.getUserDiscounts(userData.userId);
|
||||
final userDiscounts = await _discountRepository.getUserDiscounts(
|
||||
userData.userId,
|
||||
);
|
||||
for (final discount in userDiscounts) {
|
||||
final discountDto = discount.toDto();
|
||||
if (discountDto.discountPercent > maxDiscount &&
|
||||
|
|
@ -108,14 +109,14 @@ class DiscountsManager {
|
|||
final userDataModel = await _userRepository.getUserData(userData.userId);
|
||||
final userTags = userDataModel?.tags ?? [];
|
||||
|
||||
final activeCampaigns = await _discountDao.getActiveCampaignsForUser(
|
||||
final activeCampaigns = await _discountRepository.getActiveCampaignsForUser(
|
||||
userTags: userTags,
|
||||
productType: model.type.name,
|
||||
productId: model.id.toString(),
|
||||
);
|
||||
|
||||
for (final campaign in activeCampaigns) {
|
||||
final discounts = await _discountDao.getDiscountsByCampaignId(
|
||||
final discounts = await _discountRepository.getDiscountsByCampaignId(
|
||||
campaign.id,
|
||||
);
|
||||
for (final discount in discounts) {
|
||||
|
|
@ -135,7 +136,7 @@ class DiscountsManager {
|
|||
MnemoCardsProductModel? product,
|
||||
}) async {
|
||||
final userTags = userData.tags;
|
||||
return await _discountDao.getActiveCampaignsForUser(
|
||||
return await _discountRepository.getActiveCampaignsForUser(
|
||||
userTags: userTags,
|
||||
productType: product?.type.name,
|
||||
productId: product?.id.toString(),
|
||||
|
|
@ -143,23 +144,23 @@ class DiscountsManager {
|
|||
}
|
||||
|
||||
Future<List<DiscountCampaignDto>> discountCampaigns() async {
|
||||
final campaigns = await _discountDao.getAllCampaigns();
|
||||
final campaigns = await _discountRepository.getAllCampaigns();
|
||||
final dtos = await Future.wait(
|
||||
campaigns.map((c) async => await c.toDto(_discountDao)),
|
||||
campaigns.map((c) async => await c.toDto(_discountRepository)),
|
||||
);
|
||||
return dtos;
|
||||
}
|
||||
|
||||
Future<String?> deleteDiscountCampaign(String id) async {
|
||||
try {
|
||||
final campaign = await _discountDao.getCampaignById(id);
|
||||
final campaign = await _discountRepository.getCampaignById(id);
|
||||
if (campaign == null) {
|
||||
return 'Campaign $id not found';
|
||||
}
|
||||
if (campaign.status != 'disabled') {
|
||||
return 'Disable campaign before deleting';
|
||||
}
|
||||
await _discountDao.deleteCampaign(id);
|
||||
await _discountRepository.deleteCampaign(id);
|
||||
return null;
|
||||
} catch (e, s) {
|
||||
log('Error when deleting discount campaign', error: e, stackTrace: s);
|
||||
|
|
@ -169,22 +170,22 @@ class DiscountsManager {
|
|||
|
||||
Future<bool> addDiscount(DiscountCampaignDto dto) async {
|
||||
try {
|
||||
return await _db.transaction(() async {
|
||||
// Создаем кампанию
|
||||
final campaignCompanion = dto.toCompanion();
|
||||
final campaignId = await _discountDao.createCampaign(campaignCompanion);
|
||||
// Создаем кампанию
|
||||
final campaignCompanion = dto.toCompanion();
|
||||
final campaignId = await _discountRepository.createCampaign(
|
||||
campaignCompanion,
|
||||
);
|
||||
|
||||
// Создаем скидки
|
||||
final discountCompanions = dto.discounts
|
||||
.map((discountDto) => discountDto.toCompanion(campaignId))
|
||||
.toList();
|
||||
// Создаем скидки
|
||||
final discountCompanions = dto.discounts
|
||||
.map((discountDto) => discountDto.toCompanion(campaignId))
|
||||
.toList();
|
||||
|
||||
for (final discountCompanion in discountCompanions) {
|
||||
await _discountDao.createDiscount(discountCompanion);
|
||||
}
|
||||
for (final discountCompanion in discountCompanions) {
|
||||
await _discountRepository.createDiscount(discountCompanion);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
return true;
|
||||
} catch (e, s) {
|
||||
print('error when adding campaign');
|
||||
log('error when adding campaign', error: e, stackTrace: s);
|
||||
|
|
|
|||
|
|
@ -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/user/user_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 'cron/check_admins.dart';
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:injectable/injectable.dart';
|
||||
import 'package:mnemo_cards_backend/packs/product_availability_manager.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
import 'pack_repository.dart';
|
||||
import '../repository/export.dart';
|
||||
|
||||
@lazySingleton
|
||||
class FreePacksDistributor {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:injectable/injectable.dart';
|
||||
import 'package:mnemo_cards_backend/api/ads/ads_manager.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_backend/packs/card_model_extension.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
|
|
@ -13,8 +14,13 @@ bool canOpenForAd(String? id) => id != null;
|
|||
class PackDtoConverter {
|
||||
final ProductsPriceResolver _productsPriceResolver;
|
||||
final AdsManager _adsManager;
|
||||
final UserRepository _userRepository;
|
||||
|
||||
const PackDtoConverter(this._productsPriceResolver, this._adsManager);
|
||||
PackDtoConverter(
|
||||
this._productsPriceResolver,
|
||||
this._adsManager,
|
||||
this._userRepository,
|
||||
);
|
||||
|
||||
Future<CardPackPreviewDto> toCardPackPreviewDto(
|
||||
CardPackModel model,
|
||||
|
|
@ -23,12 +29,14 @@ class PackDtoConverter {
|
|||
bool available =
|
||||
userModel != null &&
|
||||
(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 =
|
||||
userModel.subscriptionModel?.features.contains(
|
||||
SubscriptionFeatureEnum.packs,
|
||||
) ==
|
||||
true;
|
||||
subscription != null &&
|
||||
subscription.isActive &&
|
||||
subscription.features.contains(SubscriptionFeatureEnum.packs);
|
||||
}
|
||||
|
||||
String? price = model.price;
|
||||
|
|
|
|||
|
|
@ -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 'pack_dto_converter.dart';
|
||||
import 'card_pack_drift_extension.dart';
|
||||
import 'pack_repository.dart';
|
||||
import '../repository/export.dart';
|
||||
|
||||
@lazySingleton
|
||||
class PackManager {
|
||||
final AppDatabase _db;
|
||||
final PackRepository _packRepository;
|
||||
final PackDtoConverter packDtoConverter;
|
||||
final AppDatabase _db;
|
||||
|
||||
PackManager(this._db, this._packRepository, this.packDtoConverter);
|
||||
PackManager(this._packRepository, this.packDtoConverter, this._db);
|
||||
|
||||
Future<List<CardPackPreviewDto>> listPacksPreviews(
|
||||
UserModel? userModel,
|
||||
|
|
@ -29,6 +29,7 @@ class PackManager {
|
|||
);
|
||||
|
||||
// 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(
|
||||
packModels.map((packModel) async {
|
||||
final pack = await _db.packDao.getPackById(packModel.id!);
|
||||
|
|
@ -63,11 +64,11 @@ class PackManager {
|
|||
}
|
||||
|
||||
Future<VoiceModel?> getVoice(String id) async {
|
||||
return await _db.packDao.getVoiceById(id);
|
||||
return await _packRepository.getVoiceById(id);
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
@ -77,19 +78,21 @@ class PackManager {
|
|||
}
|
||||
|
||||
// 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);
|
||||
if (pack == null) {
|
||||
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>[];
|
||||
|
||||
for (final card in cards) {
|
||||
for (final card in cardsDrift) {
|
||||
voices.addAll(await getVoices(card.id));
|
||||
}
|
||||
|
||||
return await pack.toDto(cards, voices, userModel);
|
||||
return await pack.toDto(cardsDrift, voices, userModel);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import 'dart:developer';
|
||||
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/repository/export.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
|
||||
|
|
@ -9,10 +9,10 @@ import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
|||
@lazySingleton
|
||||
class ProductAvailabilityManager {
|
||||
final PackManager _packManager;
|
||||
final AppDatabase _db;
|
||||
final UserRepository _userRepository;
|
||||
final Set<String> _publicPackIds;
|
||||
|
||||
ProductAvailabilityManager(this._packManager, this._db)
|
||||
ProductAvailabilityManager(this._packManager, this._userRepository)
|
||||
: _publicPackIds = const {'10'};
|
||||
|
||||
/// Проверить доступность пакета для пользователя
|
||||
|
|
@ -56,14 +56,18 @@ class ProductAvailabilityManager {
|
|||
}
|
||||
|
||||
// Проверить прямой доступ через user_packs
|
||||
final hasDirectAccess = await _db.userDao.hasPackAccess(user.id!, packId);
|
||||
final hasDirectAccess = await _userRepository.hasPackAccess(
|
||||
user.id!,
|
||||
packId,
|
||||
);
|
||||
if (hasDirectAccess) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Проверить доступ через подписку
|
||||
if (user.subscriptionModel != null && user.subscriptionModel!.isActive) {
|
||||
final hasSubscriptionAccess = user.subscriptionModel!.features.contains(
|
||||
final subscription = await _userRepository.getUserSubscription(user.id!);
|
||||
if (subscription != null && subscription.isActive) {
|
||||
final hasSubscriptionAccess = subscription.features.contains(
|
||||
SubscriptionFeatureEnum.packs,
|
||||
);
|
||||
if (hasSubscriptionAccess) {
|
||||
|
|
@ -97,11 +101,15 @@ class ProductAvailabilityManager {
|
|||
case MnemoCardsProductType.subscription:
|
||||
// Подписки не проверяются на доступность, они являются продуктами для покупки
|
||||
// Но можно проверить, есть ли у пользователя уже активная подписка
|
||||
if (user?.subscriptionModel != null &&
|
||||
user!.subscriptionModel!.isActive) {
|
||||
// Пользователь уже имеет активную подписку
|
||||
// В зависимости от бизнес-логики, можно вернуть true или false
|
||||
return false; // По умолчанию, если есть активная подписка, покупка новой недоступна
|
||||
if (user?.id != null) {
|
||||
final subscription = await _userRepository.getUserSubscription(
|
||||
user!.id!,
|
||||
);
|
||||
if (subscription != null && subscription.isActive) {
|
||||
// Пользователь уже имеет активную подписку
|
||||
// В зависимости от бизнес-логики, можно вернуть true или false
|
||||
return false; // По умолчанию, если есть активная подписка, покупка новой недоступна
|
||||
}
|
||||
}
|
||||
return true; // Подписка доступна для покупки
|
||||
case MnemoCardsProductType.discount:
|
||||
|
|
@ -144,7 +152,7 @@ class ProductAvailabilityManager {
|
|||
throw ArgumentError('Pack is disabled: $packId');
|
||||
}
|
||||
|
||||
await _db.userDao.grantPackAccess(
|
||||
await _userRepository.grantPackAccess(
|
||||
userId: userId,
|
||||
packId: packId,
|
||||
grantType: grantType,
|
||||
|
|
@ -161,7 +169,7 @@ class ProductAvailabilityManager {
|
|||
required String userId,
|
||||
required String packId,
|
||||
}) async {
|
||||
await _db.userDao.revokePackAccess(userId, packId);
|
||||
await _userRepository.revokePackAccess(userId, packId);
|
||||
log('Revoked access to pack $packId for user $userId');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import 'package:injectable/injectable.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';
|
||||
|
||||
@LazySingleton()
|
||||
|
|
|
|||
|
|
@ -2,15 +2,17 @@ import 'package:drift_postgres/drift_postgres.dart';
|
|||
import 'package:injectable/injectable.dart';
|
||||
import 'package:mnemo_cards_backend/api/purchase/payment_manager.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:drift/drift.dart' as drift;
|
||||
|
||||
@lazySingleton
|
||||
class PromoCodesManager {
|
||||
final AppDatabase _db;
|
||||
final PromoCodeRepository _promoCodeRepository;
|
||||
final PaymentManager _paymentManager;
|
||||
final AppDatabase _db;
|
||||
|
||||
PromoCodesManager(this._db, this._paymentManager);
|
||||
PromoCodesManager(this._promoCodeRepository, this._paymentManager, this._db);
|
||||
|
||||
PromoCodeCampaignStatus _parseCampaignStatus(String status) {
|
||||
switch (status) {
|
||||
|
|
@ -32,13 +34,13 @@ class PromoCodesManager {
|
|||
Future<List<PromoCodesCampaignDto>> promoCodeCampaigns({
|
||||
List<String> withCodes = const [],
|
||||
}) async {
|
||||
final campaigns = await _db.promoCodeDao.getActiveCampaigns();
|
||||
final campaigns = await _promoCodeRepository.getActiveCampaigns();
|
||||
|
||||
final campaignDtos = <PromoCodesCampaignDto>[];
|
||||
for (final campaign in campaigns) {
|
||||
List<String>? promoCodes;
|
||||
if (withCodes.isNotEmpty) {
|
||||
final codes = await _db.promoCodeDao.getPromoCodesByCampaignId(
|
||||
final codes = await _promoCodeRepository.getPromoCodesByCampaignId(
|
||||
campaign.id,
|
||||
);
|
||||
promoCodes = codes.map((code) => code.code).toList();
|
||||
|
|
@ -70,10 +72,10 @@ class PromoCodesManager {
|
|||
}
|
||||
|
||||
Future<PromoCodesCampaignDto?> promoCodeCampaign(String id) async {
|
||||
final campaign = await _db.promoCodeDao.getCampaignById(id);
|
||||
final campaign = await _promoCodeRepository.getCampaignById(id);
|
||||
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 products = (campaign.products ?? [])
|
||||
|
|
@ -112,14 +114,14 @@ class PromoCodesManager {
|
|||
tags: drift.Value(dto.tags),
|
||||
);
|
||||
|
||||
await _db.promoCodeDao.createCampaign(companion);
|
||||
await _promoCodeRepository.createCampaign(companion);
|
||||
}
|
||||
|
||||
Future<void> updatePromoCodeCampaign(
|
||||
String id,
|
||||
PromoCodesCampaignDto dto,
|
||||
) async {
|
||||
final existing = await _db.promoCodeDao.getCampaignById(id);
|
||||
final existing = await _promoCodeRepository.getCampaignById(id);
|
||||
if (existing == null) {
|
||||
throw StateError('Campaign not found: $id');
|
||||
}
|
||||
|
|
@ -140,15 +142,15 @@ class PromoCodesManager {
|
|||
updatedAt: PgDateTime(DateTime.now()),
|
||||
);
|
||||
|
||||
await _db.promoCodeDao.updateCampaign(updated);
|
||||
await _promoCodeRepository.updateCampaign(updated);
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
// Soft delete - mark as deleted
|
||||
await _db.promoCodeDao.updateCampaign(
|
||||
await _promoCodeRepository.updateCampaign(
|
||||
existing.copyWith(isDeleted: true, updatedAt: PgDateTime(DateTime.now())),
|
||||
);
|
||||
|
||||
|
|
@ -159,7 +161,7 @@ class PromoCodesManager {
|
|||
// Get user ID
|
||||
if (userId.isEmpty) return [];
|
||||
|
||||
final userCodes = await _db.promoCodeDao.getUserPromoCodes(userId);
|
||||
final userCodes = await _promoCodeRepository.getUserPromoCodes(userId);
|
||||
return userCodes
|
||||
.map(
|
||||
(code) => {
|
||||
|
|
@ -175,14 +177,14 @@ class PromoCodesManager {
|
|||
String code,
|
||||
dynamic user,
|
||||
) async {
|
||||
final promoCode = await _db.promoCodeDao.getPromoCodeByCode(
|
||||
final promoCode = await _promoCodeRepository.getPromoCodeByCode(
|
||||
code.toUpperCase(),
|
||||
);
|
||||
if (promoCode == null) {
|
||||
return {'valid': false, 'message': 'Promo code not found'};
|
||||
}
|
||||
|
||||
final campaign = await _db.promoCodeDao.getCampaignById(
|
||||
final campaign = await _promoCodeRepository.getCampaignById(
|
||||
promoCode.campaignId,
|
||||
);
|
||||
if (campaign == null) {
|
||||
|
|
@ -222,7 +224,7 @@ class PromoCodesManager {
|
|||
throw StateError(validation['message']);
|
||||
}
|
||||
|
||||
final promoCode = await _db.promoCodeDao.getPromoCodeByCode(code);
|
||||
final promoCode = await _promoCodeRepository.getPromoCodeByCode(code);
|
||||
if (promoCode == null) return null;
|
||||
|
||||
// Get user ID
|
||||
|
|
@ -231,7 +233,7 @@ class PromoCodesManager {
|
|||
}
|
||||
|
||||
// Increment activations
|
||||
await _db.promoCodeDao.incrementActivations(promoCode.id);
|
||||
await _promoCodeRepository.incrementActivations(promoCode.id);
|
||||
|
||||
// Apply the promo code benefits to user
|
||||
final campaign = validation['campaign'] as Map<String, dynamic>;
|
||||
|
|
@ -250,7 +252,7 @@ class PromoCodesManager {
|
|||
}
|
||||
|
||||
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.status != 'ready') {
|
||||
|
|
@ -267,13 +269,13 @@ class PromoCodesManager {
|
|||
// Insert codes into database
|
||||
await _db.transaction(() async {
|
||||
for (final code in codes) {
|
||||
await _db.promoCodeDao.createPromoCode(
|
||||
await _promoCodeRepository.createPromoCode(
|
||||
PromoCodesCompanion.insert(campaignId: campaignId, code: code),
|
||||
);
|
||||
}
|
||||
|
||||
// Update campaign status to active
|
||||
await _db.promoCodeDao.updateCampaign(
|
||||
await _promoCodeRepository.updateCampaign(
|
||||
campaign.copyWith(
|
||||
status: 'active',
|
||||
updatedAt: PgDateTime(DateTime.now()),
|
||||
|
|
|
|||
|
|
@ -2,16 +2,16 @@ 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/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_backend/mnemo_cards_common_backend.dart';
|
||||
|
||||
@lazySingleton
|
||||
class AchievementManager {
|
||||
final AppDatabase _db;
|
||||
final AchievementRepository _achievementRepository;
|
||||
final UserRepository _userRepository;
|
||||
|
||||
AchievementManager(this._db, this._userRepository);
|
||||
AchievementManager(this._achievementRepository, this._userRepository);
|
||||
|
||||
/// Get all available achievements with their definitions
|
||||
List<AchievementDto> get allAchievementDefinitions =>
|
||||
|
|
@ -50,7 +50,7 @@ class AchievementManager {
|
|||
achievement,
|
||||
);
|
||||
if (progress > 0.0) {
|
||||
await _db.achievementDao.updateAchievementProgress(
|
||||
await _achievementRepository.updateAchievementProgress(
|
||||
userId,
|
||||
achievement.id,
|
||||
progress,
|
||||
|
|
@ -64,10 +64,12 @@ class AchievementManager {
|
|||
|
||||
/// Get achievements for a user
|
||||
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,
|
||||
);
|
||||
final progressMap = await _db.achievementDao.getAchievementProgress(userId);
|
||||
|
||||
final achievements = <AchievementDto>[];
|
||||
|
||||
|
|
@ -98,7 +100,7 @@ class AchievementManager {
|
|||
|
||||
/// Check if user has specific achievement
|
||||
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
|
||||
|
|
@ -117,11 +119,11 @@ class AchievementManager {
|
|||
);
|
||||
|
||||
try {
|
||||
await _db.achievementDao.unlockAchievement(companion);
|
||||
await _achievementRepository.unlockAchievement(companion);
|
||||
return definition.unlock();
|
||||
} catch (e) {
|
||||
// Achievement might already exist, return existing
|
||||
final existing = await _db.achievementDao.getUserAchievement(
|
||||
final existing = await _achievementRepository.getUserAchievement(
|
||||
userId,
|
||||
achievementId,
|
||||
);
|
||||
|
|
@ -131,7 +133,7 @@ class AchievementManager {
|
|||
|
||||
/// Get achievement progress for user
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:math';
|
||||
|
||||
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/repository/export.dart';
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
|
||||
@lazySingleton
|
||||
class SessionTracker {
|
||||
final AppDatabase _db;
|
||||
final StatisticsRepository _statisticsRepository;
|
||||
|
||||
/// Active sessions cache: userId -> sessionId
|
||||
final Map<String, String> _activeSessions = {};
|
||||
|
|
@ -20,7 +20,7 @@ class SessionTracker {
|
|||
/// Session timeout duration (30 minutes by default)
|
||||
static const Duration _sessionTimeout = Duration(minutes: 30);
|
||||
|
||||
SessionTracker(this._db);
|
||||
SessionTracker(this._statisticsRepository);
|
||||
|
||||
/// Get or create an active session for a user
|
||||
///
|
||||
|
|
@ -48,7 +48,7 @@ class SessionTracker {
|
|||
final sessionId = _generateSessionId();
|
||||
final now = PgDateTime(DateTime.now());
|
||||
|
||||
await _db.statisticsDao.createSession(
|
||||
await _statisticsRepository.createSession(
|
||||
StudySessionsCompanion.insert(
|
||||
userId: userId,
|
||||
sessionId: drift.Value(sessionId),
|
||||
|
|
@ -91,11 +91,13 @@ class SessionTracker {
|
|||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// Update session in database using database ID
|
||||
await _db.statisticsDao.endSession(
|
||||
await _statisticsRepository.endSession(
|
||||
session.id,
|
||||
wordsLearned: wordsLearned,
|
||||
testsCompleted: testsCompleted,
|
||||
|
|
@ -111,7 +113,9 @@ class SessionTracker {
|
|||
double? accuracy,
|
||||
}) async {
|
||||
// Find session by sessionId
|
||||
final session = await _db.statisticsDao.getSessionBySessionId(sessionId);
|
||||
final session = await _statisticsRepository.getSessionBySessionId(
|
||||
sessionId,
|
||||
);
|
||||
if (session == null) return;
|
||||
|
||||
// Update session
|
||||
|
|
@ -122,12 +126,14 @@ class SessionTracker {
|
|||
updatedAt: PgDateTime(DateTime.now()),
|
||||
);
|
||||
|
||||
await _db.statisticsDao.updateSession(updatedSession);
|
||||
await _statisticsRepository.updateSession(updatedSession);
|
||||
}
|
||||
|
||||
/// Get active session for user
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -138,7 +144,7 @@ class SessionTracker {
|
|||
DateTime? fromDate,
|
||||
DateTime? toDate,
|
||||
}) async {
|
||||
return await _db.statisticsDao.getSessionsByUserId(
|
||||
return await _statisticsRepository.getSessionsByUserId(
|
||||
userId,
|
||||
limit: limit,
|
||||
fromDate: fromDate,
|
||||
|
|
@ -154,7 +160,9 @@ class SessionTracker {
|
|||
|
||||
for (final entry in _activeSessions.entries) {
|
||||
final sessionId = entry.value;
|
||||
final session = await _db.statisticsDao.getSessionBySessionId(sessionId);
|
||||
final session = await _statisticsRepository.getSessionBySessionId(
|
||||
sessionId,
|
||||
);
|
||||
|
||||
if (session != null &&
|
||||
session.endTime == null &&
|
||||
|
|
@ -191,9 +199,11 @@ class SessionTracker {
|
|||
|
||||
/// Update session activity timestamp
|
||||
Future<void> _updateSessionActivity(String sessionId) async {
|
||||
final session = await _db.statisticsDao.getSessionBySessionId(sessionId);
|
||||
final session = await _statisticsRepository.getSessionBySessionId(
|
||||
sessionId,
|
||||
);
|
||||
if (session != null) {
|
||||
await _db.statisticsDao.updateSession(
|
||||
await _statisticsRepository.updateSession(
|
||||
session.copyWith(updatedAt: PgDateTime(DateTime.now())),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import 'dart:math';
|
||||
|
||||
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_backend/mnemo_cards_common_backend.dart';
|
||||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
|
|
@ -11,8 +11,17 @@ import 'package:mnemo_cards_backend/database/database.dart';
|
|||
class StatisticsCalculator {
|
||||
final AppDatabase _db;
|
||||
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
|
||||
///
|
||||
|
|
@ -23,19 +32,19 @@ class StatisticsCalculator {
|
|||
String packId,
|
||||
) async {
|
||||
// Получить информацию о паке
|
||||
final pack = await _db.packDao.getPackById(packId);
|
||||
final pack = await _packRepository.getPackById(packId);
|
||||
if (pack == null) {
|
||||
return PackProgressDto.empty(packId, 0);
|
||||
}
|
||||
|
||||
// Получить статистику по словам пака из WordStatistics
|
||||
final wordStats = await _db.wordStatisticsDao.getPackStatistics(
|
||||
final wordStats = await _wordStatisticsRepository.getPackStatistics(
|
||||
userId,
|
||||
packId,
|
||||
);
|
||||
|
||||
// Получить сессии изучения этого пака
|
||||
final sessions = await _db.statisticsDao.getSessionsByUserId(
|
||||
final sessions = await _statisticsRepository.getSessionsByUserId(
|
||||
userId,
|
||||
limit: null,
|
||||
);
|
||||
|
|
@ -123,7 +132,7 @@ class StatisticsCalculator {
|
|||
///
|
||||
/// Данные теперь берутся из StudySessions вместо UserDatas.studyDates
|
||||
Future<List<DateTime>> calculateStudyDates(String userId) async {
|
||||
final sessions = await _db.statisticsDao.getSessionsByUserId(userId);
|
||||
final sessions = await _statisticsRepository.getSessionsByUserId(userId);
|
||||
|
||||
// Получить уникальные даты (без времени)
|
||||
final uniqueDates = <DateTime>{};
|
||||
|
|
@ -147,7 +156,7 @@ class StatisticsCalculator {
|
|||
/// Данные теперь берутся из StudySessions + CardPacks.category
|
||||
/// вместо UserDatas.categoryMinutes
|
||||
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>{};
|
||||
|
||||
|
|
@ -157,7 +166,7 @@ class StatisticsCalculator {
|
|||
// Получить категорию пака
|
||||
// Примечание: в CardPacks нет поля category, используем 'unknown'
|
||||
// В будущем можно добавить поле category в CardPacks или использовать другую логику
|
||||
final pack = await _db.packDao.getPackById(session.packId!);
|
||||
final pack = await _packRepository.getPackById(session.packId!);
|
||||
final category = pack != null
|
||||
? 'unknown'
|
||||
: 'unknown'; // TODO: добавить category в CardPacks
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:injectable/injectable.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
|
||||
class WordStatisticsManager {
|
||||
final AppDatabase _db;
|
||||
final WordStatisticsRepository _wordStatisticsRepository;
|
||||
|
||||
WordStatisticsManager(this._db);
|
||||
WordStatisticsManager(this._wordStatisticsRepository);
|
||||
|
||||
/// Записать ответ пользователя на карточку
|
||||
///
|
||||
|
|
@ -26,14 +27,14 @@ class WordStatisticsManager {
|
|||
required bool isCorrect,
|
||||
}) async {
|
||||
// Получить существующую статистику или создать новую
|
||||
final existing = await _db.wordStatisticsDao.getByUserAndCard(
|
||||
final existing = await _wordStatisticsRepository.getByUserAndCard(
|
||||
userId,
|
||||
cardId,
|
||||
);
|
||||
|
||||
if (existing == null) {
|
||||
// Создать новую запись при первом ответе
|
||||
await _db.wordStatisticsDao.create(
|
||||
await _wordStatisticsRepository.create(
|
||||
userId: userId,
|
||||
cardId: cardId,
|
||||
correctAnswers: isCorrect ? 1 : 0,
|
||||
|
|
@ -44,7 +45,7 @@ class WordStatisticsManager {
|
|||
final newCorrect = existing.correctAnswers + (isCorrect ? 1 : 0);
|
||||
final newIncorrect = existing.incorrectAnswers + (isCorrect ? 0 : 1);
|
||||
|
||||
await _db.wordStatisticsDao.updateStatistics(
|
||||
await _wordStatisticsRepository.updateStatistics(
|
||||
id: existing.id,
|
||||
correctAnswers: newCorrect,
|
||||
incorrectAnswers: newIncorrect,
|
||||
|
|
@ -74,11 +75,11 @@ class WordStatisticsManager {
|
|||
String userId,
|
||||
String packId,
|
||||
) async {
|
||||
return _db.wordStatisticsDao.getPackStatistics(userId, packId);
|
||||
return _wordStatisticsRepository.getPackStatistics(userId, packId);
|
||||
}
|
||||
|
||||
/// Получить всю статистику пользователя
|
||||
Future<List<WordStatistic>> getUserStatistics(String userId) async {
|
||||
return _db.wordStatisticsDao.getUserStatistics(userId);
|
||||
return _wordStatisticsRepository.getUserStatistics(userId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@ import 'package:drift_postgres/drift_postgres.dart';
|
|||
import 'package:injectable/injectable.dart';
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
import 'package:mnemo_cards_backend/repository/export.dart';
|
||||
|
||||
@lazySingleton
|
||||
class TaskManager {
|
||||
final TaskRepository _taskRepository;
|
||||
final AppDatabase _db;
|
||||
|
||||
TaskManager(this._db);
|
||||
TaskManager(this._taskRepository, this._db);
|
||||
|
||||
/// Получить все доступные задачи пользователя
|
||||
Future<List<UserTask>> getUserTasks(
|
||||
|
|
@ -19,7 +21,7 @@ class TaskManager {
|
|||
int? limit,
|
||||
int? offset,
|
||||
}) async {
|
||||
return await _db.taskDao.getUserTasks(
|
||||
return await _taskRepository.getUserTasks(
|
||||
userId,
|
||||
status: status,
|
||||
activeOnly: status == 'available',
|
||||
|
|
@ -28,7 +30,7 @@ class TaskManager {
|
|||
|
||||
/// Получить задачу по ID
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -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(
|
||||
userId: userId,
|
||||
taskId: taskId,
|
||||
|
|
@ -66,12 +70,12 @@ class TaskManager {
|
|||
|
||||
// Обновить статус задачи
|
||||
final now = PgDateTime(DateTime.now());
|
||||
await _db.taskDao.updateUserTask(
|
||||
await _taskRepository.updateUserTask(
|
||||
task.copyWith(status: 'completed', completedAt: drift.Value(now)),
|
||||
);
|
||||
|
||||
// Создать запись результата
|
||||
await _db.taskDao.createTaskResult(
|
||||
await _taskRepository.createTaskResult(
|
||||
UserTaskResultsCompanion.insert(
|
||||
userId: userId,
|
||||
taskId: taskId,
|
||||
|
|
@ -88,11 +92,11 @@ class TaskManager {
|
|||
Future<List<UserTaskProgressesData>> getUserTaskProgress(
|
||||
String userId,
|
||||
) async {
|
||||
final tasks = await _db.taskDao.getUserTasks(userId);
|
||||
final tasks = await _taskRepository.getUserTasks(userId);
|
||||
final progresses = <UserTaskProgressesData>[];
|
||||
|
||||
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) {
|
||||
progresses.add(progress);
|
||||
}
|
||||
|
|
@ -103,7 +107,7 @@ class TaskManager {
|
|||
|
||||
/// Получить категории задач
|
||||
Future<List<String>> getTaskCategories() async {
|
||||
final tasks = await _db.taskDao.getAllUserTasks();
|
||||
final tasks = await _taskRepository.getAllUserTasks();
|
||||
final categories = <String>{};
|
||||
|
||||
for (final task in tasks) {
|
||||
|
|
@ -117,16 +121,16 @@ class TaskManager {
|
|||
|
||||
/// Создать новую задачу для пользователя
|
||||
Future<String> createUserTask(UserTasksCompanion task) async {
|
||||
return await _db.taskDao.createUserTask(task);
|
||||
return await _taskRepository.createUserTask(task);
|
||||
}
|
||||
|
||||
/// Обновить задачу пользователя
|
||||
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 {
|
||||
return await _db.taskDao.countUserTasks(userId, status: status);
|
||||
return await _taskRepository.countUserTasks(userId, status: status);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import 'package:drift_postgres/drift_postgres.dart';
|
|||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
import 'package:mnemo_cards_backend/packs/card_image_storage.dart';
|
||||
import 'package:mnemo_cards_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_service.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
|
|
@ -18,9 +19,16 @@ import 'generators/pack_test_generator.dart';
|
|||
@lazySingleton
|
||||
class TestManager {
|
||||
final AppDatabase _db;
|
||||
final PackRepository _packRepository;
|
||||
final TestRepository _testRepository;
|
||||
final MinioService _minioService;
|
||||
|
||||
TestManager(this._db, this._minioService);
|
||||
TestManager(
|
||||
this._db,
|
||||
this._packRepository,
|
||||
this._testRepository,
|
||||
this._minioService,
|
||||
);
|
||||
|
||||
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}$',
|
||||
|
|
@ -47,9 +55,9 @@ class TestManager {
|
|||
required String cardId,
|
||||
}) async {
|
||||
try {
|
||||
final card = await _db.packDao.getCardById(cardId);
|
||||
final card = await _packRepository.getCardById(cardId);
|
||||
if (card == null) return;
|
||||
await _db.packDao.addCardToPack(packId: packId, cardId: cardId);
|
||||
await _packRepository.addCardToPack(packId: packId, cardId: cardId);
|
||||
} catch (_) {
|
||||
// Best-effort only.
|
||||
}
|
||||
|
|
@ -67,7 +75,7 @@ class TestManager {
|
|||
mnemo: const drift.Value('test_image'),
|
||||
);
|
||||
|
||||
final cardId = await _db.packDao.createCard(companion);
|
||||
final cardId = await _packRepository.createCard(companion);
|
||||
if (packId != null) {
|
||||
await _tryLinkCardToPack(packId: packId, cardId: cardId);
|
||||
}
|
||||
|
|
@ -80,15 +88,19 @@ class TestManager {
|
|||
);
|
||||
if (stored == null) return null;
|
||||
|
||||
final created = await _db.packDao.getCardById(cardId);
|
||||
final created = await _packRepository.getCardById(cardId);
|
||||
if (created == null) return null;
|
||||
|
||||
await _db.packDao.updateCard(
|
||||
created.copyWith(
|
||||
image: stored.fileName,
|
||||
updatedAt: PgDateTime(DateTime.now()),
|
||||
),
|
||||
);
|
||||
// TODO: Refactor to use PackRepository.updateCard with GameCardModel
|
||||
final card = await _db.packDao.getCardById(cardId);
|
||||
if (card != null) {
|
||||
await _packRepository.updateCard(
|
||||
card.copyWith(
|
||||
image: stored.fileName,
|
||||
updatedAt: PgDateTime(DateTime.now()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return cardId;
|
||||
} catch (_) {
|
||||
|
|
@ -276,7 +288,7 @@ class TestManager {
|
|||
String userId,
|
||||
String testId,
|
||||
) async {
|
||||
final statistics = await _db.testDao.getTestStatistics(userId, testId);
|
||||
final statistics = await _testRepository.getTestStatistics(userId, testId);
|
||||
if (statistics == null) return null;
|
||||
|
||||
// Convert TestStatistic to TestStatisticsDto
|
||||
|
|
@ -411,6 +423,7 @@ class TestManager {
|
|||
questionJson['buttons'] = normalizedButtons;
|
||||
|
||||
if (mutated) {
|
||||
// TODO: Refactor to use TestRepository.updateTestQuestion
|
||||
await _db.testDao.updateTestQuestion(
|
||||
q.copyWith(
|
||||
options: jsonEncode(normalizedButtons),
|
||||
|
|
@ -455,7 +468,7 @@ class TestManager {
|
|||
final currentButtons =
|
||||
(questionJson['buttons'] as List<dynamic>?) ?? [];
|
||||
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 resolvedSize = matrixSize.clamp(1, maxSize);
|
||||
final total = resolvedSize * resolvedSize;
|
||||
|
|
@ -554,11 +567,11 @@ class TestManager {
|
|||
}
|
||||
|
||||
Future<List<TestDto>> availableTests(UserModel userModel) async {
|
||||
final tests = await _db.testDao.getAllTests();
|
||||
final tests = await _testRepository.getAllTests();
|
||||
|
||||
final testDtos = <TestDto>[];
|
||||
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 questionsList = questions.map((q) {
|
||||
|
|
@ -616,11 +629,11 @@ class TestManager {
|
|||
final packId = model.id;
|
||||
if (packId == null) return [];
|
||||
|
||||
final tests = await _db.testDao.getTestsByPackId(packId);
|
||||
final tests = await _testRepository.getTestsByPackId(packId);
|
||||
|
||||
final testDtos = <TestDto>[];
|
||||
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 questionsList = await Future.wait(
|
||||
|
|
@ -750,7 +763,7 @@ class TestManager {
|
|||
final testDataItems = await Future.wait(
|
||||
cards.map((card) async {
|
||||
// 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
|
||||
String? audioUuid;
|
||||
|
|
@ -849,7 +862,7 @@ class TestManager {
|
|||
uiData: drift.Value(json.encode(uiData)),
|
||||
);
|
||||
|
||||
await _db.testDao.createTestQuestion(questionCompanion);
|
||||
await _testRepository.createTestQuestion(questionCompanion);
|
||||
}
|
||||
});
|
||||
return createdTestId!;
|
||||
|
|
|
|||
|
|
@ -19,9 +19,7 @@ extension UserDataModelExtension on UserDataModel {
|
|||
words: words.map((model) => model.toDto()).toList(),
|
||||
),
|
||||
allTestsStatistics: AllTestsStatisticsDto(
|
||||
tests: Map.fromEntries(
|
||||
testsStatistics.map((model) => MapEntry(model.id!, model.toDto())),
|
||||
),
|
||||
tests: {}, // testsStatistics removed - always empty
|
||||
lastSessionToken: this.lastTestSessionToken,
|
||||
),
|
||||
// NEW STATISTICS FIELDS
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
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/packs/card_pack_drift_extension.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 UserModelToUser on UserModel {
|
||||
UsersCompanion toUsersCompanion() {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import '../statistics/statistics_calculator.dart';
|
|||
import '../statistics/achievement_manager.dart';
|
||||
import '../statistics/word_statistics_manager.dart';
|
||||
import 'secure.dart';
|
||||
import 'user_repository.dart';
|
||||
import '../repository/export.dart';
|
||||
|
||||
Map<String?, DateTime> _onlineUsers = {};
|
||||
|
||||
|
|
@ -23,6 +23,7 @@ Map<String?, DateTime> _onlineUsers = {};
|
|||
class UserManager {
|
||||
final AppDatabase _db;
|
||||
final UserRepository _userRepository;
|
||||
final PackRepository _packRepository;
|
||||
final FreePacksDistributor _freePacksDistributor;
|
||||
final SessionTracker _sessionTracker;
|
||||
final StatisticsCalculator _statisticsCalculator;
|
||||
|
|
@ -32,6 +33,7 @@ class UserManager {
|
|||
UserManager(
|
||||
this._db,
|
||||
this._userRepository,
|
||||
this._packRepository,
|
||||
this._freePacksDistributor,
|
||||
this._sessionTracker,
|
||||
this._statisticsCalculator,
|
||||
|
|
@ -50,15 +52,15 @@ class UserManager {
|
|||
throw Exception('Cant create token for empty id');
|
||||
}
|
||||
final now = DateTime.now();
|
||||
final token = await _db.userDao.getTokenByUserId(user.id!);
|
||||
final token = await _userRepository.getTokenByUserId(user.id!);
|
||||
if (token != null) {
|
||||
if (token.expires.dateTime.isAfter(now)) {
|
||||
return token.token;
|
||||
}
|
||||
await _db.userDao.softDeleteToken(token.id);
|
||||
await _userRepository.softDeleteToken(token.id);
|
||||
}
|
||||
final userToken = Secure.token();
|
||||
await _db.userDao.createToken(
|
||||
await _userRepository.createToken(
|
||||
TokensCompanion.insert(
|
||||
token: userToken,
|
||||
externalUserId: externalId,
|
||||
|
|
@ -70,13 +72,13 @@ class UserManager {
|
|||
}
|
||||
|
||||
Future<UserModel?> getUserByToken(String authToken) async {
|
||||
final token = await _db.userDao.getTokenByValue(authToken);
|
||||
final token = await _userRepository.getTokenByValue(authToken);
|
||||
if (token == null) {
|
||||
return null;
|
||||
}
|
||||
final now = DateTime.now();
|
||||
if (token.expires.dateTime.isBefore(now)) {
|
||||
await _db.userDao.softDeleteToken(token.id);
|
||||
await _userRepository.softDeleteToken(token.id);
|
||||
return null;
|
||||
}
|
||||
final user = await fetchUser(token.userId);
|
||||
|
|
@ -99,7 +101,7 @@ class UserManager {
|
|||
for (final userId in userIds) {
|
||||
final lastOnline = _onlineUsers[userId];
|
||||
if (lastOnline != null) {
|
||||
await _db.userDao.updateUserDataPartial(
|
||||
await _userRepository.updateUserDataPartial(
|
||||
UserDatasCompanion(
|
||||
userId: drift.Value(userId),
|
||||
lastTimeOnline: drift.Value(PgDateTime(lastOnline)),
|
||||
|
|
@ -217,7 +219,7 @@ class UserManager {
|
|||
// Получить или создать UserData
|
||||
var userData = await _userRepository.getUserData(user.id!);
|
||||
if (userData == null) {
|
||||
await _db.userDao.createUserData(
|
||||
await _userRepository.createUserData(
|
||||
UserDatasCompanion.insert(userId: user.id!),
|
||||
);
|
||||
userData = await _userRepository.getUserData(user.id!);
|
||||
|
|
@ -282,9 +284,13 @@ class UserManager {
|
|||
for (final wordStat in testStat.words.words) {
|
||||
// Найти карточку по слову (original)
|
||||
// Примечание: если несколько карточек с одинаковым original, берем первую
|
||||
final cards = await _db.packDao.searchCardsByOriginal(wordStat.word);
|
||||
if (cards.isNotEmpty) {
|
||||
final card = cards.first;
|
||||
final cardModels = await _packRepository.searchCardsByOriginal(
|
||||
wordStat.word,
|
||||
);
|
||||
if (cardModels.isNotEmpty) {
|
||||
final cardModel = cardModels.first;
|
||||
final card = await _db.packDao.getCardById(cardModel.id!);
|
||||
if (card == null) continue;
|
||||
|
||||
// Записать ответы
|
||||
// correct и incorrect в WordStatisticsDto - это уже агрегированные значения
|
||||
|
|
@ -324,7 +330,7 @@ class UserManager {
|
|||
final longestStreak = math.max(userData.longestStreak, currentStreak);
|
||||
|
||||
// Обновить UserData (без studyDates - это поле удалено)
|
||||
await _db.userDao.updateUserDataPartial(
|
||||
await _userRepository.updateUserDataPartial(
|
||||
UserDatasCompanion(
|
||||
userId: drift.Value(user.id!),
|
||||
lastTestSessionToken: drift.Value(testStat.sessionToken),
|
||||
|
|
|
|||
|
|
@ -7,9 +7,14 @@ extension UserModelExtension on UserModel {
|
|||
///
|
||||
/// Примечание: для расчета packProgress, studyDates, categoryMinutes на лету
|
||||
/// используйте toDtoWithCalculatedData()
|
||||
Future<UserDto> toDto() async {
|
||||
/// [userData] - опциональные данные пользователя
|
||||
/// [subscriptionModel] - опциональная подписка пользователя
|
||||
Future<UserDto> toDto({
|
||||
UserDataModel? userData,
|
||||
UserSubscriptionModel? subscriptionModel,
|
||||
}) async {
|
||||
final activeSubscription =
|
||||
subscriptionModel != null && subscriptionModel!.isActive;
|
||||
subscriptionModel != null && subscriptionModel.isActive;
|
||||
return UserDto(
|
||||
id: id,
|
||||
name: name,
|
||||
|
|
@ -27,14 +32,18 @@ extension UserModelExtension on UserModel {
|
|||
///
|
||||
/// Используется когда нужно рассчитать packProgress, studyDates, categoryMinutes
|
||||
/// из WordStatistics и StudySessions вместо использования удаленных полей UserDatas
|
||||
/// [userData] - опциональные данные пользователя
|
||||
/// [subscriptionModel] - опциональная подписка пользователя
|
||||
Future<UserDto> toDtoWithCalculatedData({
|
||||
required List<PackProgressDto> packProgress,
|
||||
required List<DateTime> studyDates,
|
||||
required Map<String, int> categoryMinutes,
|
||||
AllWordsStatisticsDto? wordsStatistics,
|
||||
UserDataModel? userData,
|
||||
UserSubscriptionModel? subscriptionModel,
|
||||
}) async {
|
||||
final activeSubscription =
|
||||
subscriptionModel != null && subscriptionModel!.isActive;
|
||||
subscriptionModel != null && subscriptionModel.isActive;
|
||||
return UserDto(
|
||||
id: id,
|
||||
name: name,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:copy_with_extension/copy_with_extension.dart';
|
||||
import 'discount_model.dart';
|
||||
|
||||
part 'discount_campaign_model.g.dart';
|
||||
|
||||
|
|
@ -14,8 +13,6 @@ class DiscountCampaignModel {
|
|||
final DiscountCampaignModelStatus status;
|
||||
final String? name;
|
||||
final List<String> tags;
|
||||
// Relations - loaded separately from database
|
||||
final List<DiscountModel> discounts = [];
|
||||
|
||||
DiscountCampaignModel({
|
||||
required this.start,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
import 'package:json_annotation/json_annotation.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/user/user_data_model.dart';
|
||||
|
||||
import 'discount_campaign_model.dart';
|
||||
|
||||
part 'discount_model.g.dart';
|
||||
|
||||
|
|
@ -13,9 +10,6 @@ class DiscountModel implements MnemoCardsProductModel {
|
|||
String? id;
|
||||
final double discountPercent; // 0 - 100
|
||||
final List<MnemoCardsProductModelBase> products;
|
||||
// Relations - loaded separately from database
|
||||
final List<UserDataModel> usersData = [];
|
||||
final List<DiscountCampaignModel> campaigns = [];
|
||||
|
||||
DiscountModel({
|
||||
required this.discountPercent,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import 'package:json_annotation/json_annotation.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';
|
||||
|
||||
|
|
@ -25,9 +23,6 @@ class GameCardModel {
|
|||
final String? imageBack;
|
||||
@JsonKey(name: 'back')
|
||||
final String? back;
|
||||
// Relations - loaded separately from database
|
||||
final List<CardPackModel> packs = [];
|
||||
final List<VoiceModel> voices = [];
|
||||
|
||||
GameCardModel({
|
||||
required this.id,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import 'package:copy_with_extension/copy_with_extension.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';
|
||||
|
||||
|
|
@ -11,7 +10,6 @@ part 'test_model.g.dart';
|
|||
class TestModel {
|
||||
// Relations - loaded separately from database
|
||||
final List<TestQuestionModel> questions = [];
|
||||
final List<CardPackModel> packs = [];
|
||||
|
||||
final String name;
|
||||
final String? color;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import 'package:copy_with_extension/copy_with_extension.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';
|
||||
|
||||
part 'test_question_model.g.dart';
|
||||
|
|
@ -12,9 +11,6 @@ class TestQuestionModel {
|
|||
final TestQuestionType questionType;
|
||||
final String body;
|
||||
|
||||
// Relations - loaded separately from database
|
||||
final List<TestModel> tests = [];
|
||||
|
||||
String? id;
|
||||
|
||||
TestQuestionModel({
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import 'package:copy_with_extension/copy_with_extension.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';
|
||||
|
||||
|
|
@ -10,9 +8,6 @@ part 'test_statistics_model.g.dart';
|
|||
@CopyWith()
|
||||
class TestStatisticsModel {
|
||||
final String id;
|
||||
// Relations - loaded separately from database
|
||||
String? testId;
|
||||
String? userId;
|
||||
final List<TestAttempt> attempts;
|
||||
|
||||
TestStatisticsModel({
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ extension PromoCodesCampaignModelExt on PromoCodesCampaignModel {
|
|||
Future<PromoCodesCampaignDto> toDto({bool withCodes = false}) async {
|
||||
List<String>? codes;
|
||||
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(
|
||||
id: id,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
import 'package:json_annotation/json_annotation.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';
|
||||
|
||||
|
|
@ -11,8 +7,6 @@ class PromoCodeModel {
|
|||
String? id;
|
||||
final String code;
|
||||
final int activations;
|
||||
// Relations - loaded separately from database
|
||||
PromoCodesCampaignModel? campaign;
|
||||
// Individual promo code
|
||||
String? userId;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import 'package:json_annotation/json_annotation.dart';
|
|||
import 'package:copy_with_extension/copy_with_extension.dart';
|
||||
|
||||
import '../product_model.dart';
|
||||
import 'promo_code_model.dart';
|
||||
|
||||
part 'promo_codes_campaign_model.g.dart';
|
||||
|
||||
|
|
@ -15,8 +14,6 @@ class PromoCodesCampaignModel {
|
|||
final int activationsPerCode;
|
||||
final int activationsPerUser;
|
||||
final int generationSize;
|
||||
// Relations - loaded separately from database
|
||||
final List<PromoCodeModel> promoCodes = [];
|
||||
final DateTime start;
|
||||
final DateTime finish;
|
||||
@JsonKey(name: 'status')
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import 'package:json_annotation/json_annotation.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';
|
||||
|
||||
part 'user_subscription_model.g.dart';
|
||||
|
|
@ -8,8 +7,6 @@ part 'user_subscription_model.g.dart';
|
|||
@CopyWith()
|
||||
class UserSubscriptionModel {
|
||||
String? id;
|
||||
// Relations - loaded separately from database
|
||||
UserModel? user;
|
||||
|
||||
final DateTime start;
|
||||
final DateTime finish;
|
||||
|
|
|
|||
|
|
@ -2,9 +2,6 @@ import 'package:json_annotation/json_annotation.dart';
|
|||
import 'package:copy_with_extension/copy_with_extension.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/pack_progress_model.dart';
|
||||
import '../statistics/achievement_model.dart';
|
||||
|
|
@ -14,11 +11,6 @@ part 'user_data_model.g.dart';
|
|||
@CopyWith()
|
||||
class UserDataModel {
|
||||
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 String? lastTestSessionToken;
|
||||
|
|
@ -36,18 +28,15 @@ class UserDataModel {
|
|||
final int longestStreak;
|
||||
|
||||
/// Progress tracking for each pack
|
||||
@JsonKey(fromJson: _packProgressFromJson, toJson: _packProgressToJson)
|
||||
final List<PackProgressModel> packProgress;
|
||||
|
||||
/// List of dates when user studied (for streak calculation)
|
||||
final List<DateTime> studyDates;
|
||||
|
||||
/// Study time by category/language in minutes
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
final Map<String, int> categoryMinutes;
|
||||
|
||||
/// User's achievements
|
||||
@JsonKey(fromJson: _achievementsFromJson, toJson: _achievementsToJson)
|
||||
final List<AchievementModel> achievements;
|
||||
|
||||
UserDataModel({
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ abstract class _$UserModelCWProxy {
|
|||
|
||||
UserModel purchases(List<String> purchases);
|
||||
|
||||
UserModel packs(List<CardPackModel> packs);
|
||||
|
||||
UserModel userSettings(String? userSettings);
|
||||
|
||||
/// Creates a new instance with the provided field values.
|
||||
|
|
@ -35,6 +37,7 @@ abstract class _$UserModelCWProxy {
|
|||
String? telegram,
|
||||
bool admin,
|
||||
List<String> purchases,
|
||||
List<CardPackModel> packs,
|
||||
String? userSettings,
|
||||
});
|
||||
}
|
||||
|
|
@ -64,6 +67,9 @@ class _$UserModelCWProxyImpl implements _$UserModelCWProxy {
|
|||
@override
|
||||
UserModel purchases(List<String> purchases) => call(purchases: purchases);
|
||||
|
||||
@override
|
||||
UserModel packs(List<CardPackModel> packs) => call(packs: packs);
|
||||
|
||||
@override
|
||||
UserModel userSettings(String? userSettings) =>
|
||||
call(userSettings: userSettings);
|
||||
|
|
@ -83,6 +89,7 @@ class _$UserModelCWProxyImpl implements _$UserModelCWProxy {
|
|||
Object? telegram = const $CopyWithPlaceholder(),
|
||||
Object? admin = const $CopyWithPlaceholder(),
|
||||
Object? purchases = const $CopyWithPlaceholder(),
|
||||
Object? packs = const $CopyWithPlaceholder(),
|
||||
Object? userSettings = const $CopyWithPlaceholder(),
|
||||
}) {
|
||||
return UserModel(
|
||||
|
|
@ -110,6 +117,10 @@ class _$UserModelCWProxyImpl implements _$UserModelCWProxy {
|
|||
? _value.purchases
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: 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()
|
||||
? _value.userSettings
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import 'package:copy_with_extension/copy_with_extension.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import 'game_card_model.dart';
|
||||
|
||||
part 'voice_model.g.dart';
|
||||
|
||||
@JsonSerializable(fieldRename: FieldRename.snake)
|
||||
|
|
@ -18,8 +16,6 @@ class VoiceModel {
|
|||
final DateTime? updatedAt;
|
||||
@JsonKey(defaultValue: false)
|
||||
final bool isDeleted;
|
||||
// Relations - loaded separately from database
|
||||
final List<GameCardModel> cards = [];
|
||||
|
||||
VoiceModel({
|
||||
required this.phrase,
|
||||
|
|
|
|||
Loading…
Reference in a new issue