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 PacksPage from '@/pages/PacksPage'
|
||||||
import UsersPage from '@/pages/UsersPage'
|
import UsersPage from '@/pages/UsersPage'
|
||||||
import TestsPage from '@/pages/TestsPage'
|
import TestsPage from '@/pages/TestsPage'
|
||||||
|
import TasksPage from '@/pages/TasksPage'
|
||||||
import Layout from '@/components/layout/Layout'
|
import Layout from '@/components/layout/Layout'
|
||||||
import { TokenRefreshProvider } from '@/components/TokenRefreshProvider'
|
import { TokenRefreshProvider } from '@/components/TokenRefreshProvider'
|
||||||
|
|
||||||
|
|
@ -31,6 +32,7 @@ function App() {
|
||||||
<Route path="/packs" element={<PacksPage />} />
|
<Route path="/packs" element={<PacksPage />} />
|
||||||
<Route path="/users" element={<UsersPage />} />
|
<Route path="/users" element={<UsersPage />} />
|
||||||
<Route path="/tests" element={<TestsPage />} />
|
<Route path="/tests" element={<TestsPage />} />
|
||||||
|
<Route path="/tasks" element={<TasksPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { adminApiClient } from './client'
|
import { adminApiClient } from './client'
|
||||||
|
import { tasksApi } from './tasks'
|
||||||
|
|
||||||
export interface DashboardStats {
|
export interface DashboardStats {
|
||||||
users: number
|
users: number
|
||||||
|
|
@ -8,6 +9,13 @@ export interface DashboardStats {
|
||||||
payments: number
|
payments: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TasksStats {
|
||||||
|
total: number
|
||||||
|
active: number
|
||||||
|
completed: number
|
||||||
|
expired: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface RecentUser {
|
export interface RecentUser {
|
||||||
id: number
|
id: number
|
||||||
name?: string
|
name?: string
|
||||||
|
|
@ -52,4 +60,34 @@ export const analyticsApi = {
|
||||||
const response = await adminApiClient.get('/api/v2/admin/analytics/revenue/chart')
|
const response = await adminApiClient.get('/api/v2/admin/analytics/revenue/chart')
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Get tasks statistics
|
||||||
|
getTasksStats: async (): Promise<TasksStats> => {
|
||||||
|
try {
|
||||||
|
// Get all tasks counts by status
|
||||||
|
const [allTasks, availableTasks, inProgressTasks, completedTasks, expiredTasks] = await Promise.all([
|
||||||
|
tasksApi.getTasks({ limit: 1, page: 1 }),
|
||||||
|
tasksApi.getTasks({ limit: 1, page: 1, status: 'available' }),
|
||||||
|
tasksApi.getTasks({ limit: 1, page: 1, status: 'in_progress' }),
|
||||||
|
tasksApi.getTasks({ limit: 1, page: 1, status: 'completed' }),
|
||||||
|
tasksApi.getTasks({ limit: 1, page: 1, status: 'expired' }),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: allTasks.total,
|
||||||
|
active: (availableTasks.total || 0) + (inProgressTasks.total || 0),
|
||||||
|
completed: completedTasks.total || 0,
|
||||||
|
expired: expiredTasks.total || 0,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// If there's an error, return zeros
|
||||||
|
console.error('Error fetching tasks stats:', error)
|
||||||
|
return {
|
||||||
|
total: 0,
|
||||||
|
active: 0,
|
||||||
|
completed: 0,
|
||||||
|
expired: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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,
|
Package,
|
||||||
Users,
|
Users,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
|
CheckSquare,
|
||||||
LogOut,
|
LogOut,
|
||||||
Menu,
|
Menu,
|
||||||
X
|
X
|
||||||
|
|
@ -24,6 +25,7 @@ const navigation = [
|
||||||
{ name: 'Packs', href: '/packs', icon: Package },
|
{ name: 'Packs', href: '/packs', icon: Package },
|
||||||
{ name: 'Tests', href: '/tests', icon: ClipboardList },
|
{ name: 'Tests', href: '/tests', icon: ClipboardList },
|
||||||
{ name: 'Users', href: '/users', icon: Users },
|
{ name: 'Users', href: '/users', icon: Users },
|
||||||
|
{ name: 'Tasks', href: '/tasks', icon: CheckSquare },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function Layout({ children }: LayoutProps) {
|
export default function Layout({ children }: LayoutProps) {
|
||||||
|
|
|
||||||
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 { useQuery } from '@tanstack/react-query'
|
||||||
import { Users, FileText, Package, DollarSign, TrendingUp, Clock } from 'lucide-react'
|
import { Users, FileText, Package, DollarSign, TrendingUp, Clock, CheckSquare } from 'lucide-react'
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar } from 'recharts'
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar } from 'recharts'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { analyticsApi, type DashboardData, type ChartDataPoint } from '@/api/analytics'
|
import { analyticsApi, type DashboardData, type ChartDataPoint, type TasksStats } from '@/api/analytics'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
|
|
@ -30,6 +30,12 @@ export default function DashboardPage() {
|
||||||
enabled: isReady,
|
enabled: isReady,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { data: tasksStats } = useQuery<TasksStats>({
|
||||||
|
queryKey: ['tasks-stats'],
|
||||||
|
queryFn: analyticsApi.getTasksStats,
|
||||||
|
enabled: isReady,
|
||||||
|
})
|
||||||
|
|
||||||
if (dashboardLoading) {
|
if (dashboardLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
@ -65,7 +71,7 @@ export default function DashboardPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
|
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
|
||||||
|
|
@ -117,6 +123,19 @@ export default function DashboardPage() {
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Active Tasks</CardTitle>
|
||||||
|
<CheckSquare className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{tasksStats?.active || 0}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
of {tasksStats?.total || 0} total tasks
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Charts */}
|
{/* Charts */}
|
||||||
|
|
|
||||||
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
|
statistics?: unknown
|
||||||
packs?: TestPackInfo[]
|
packs?: TestPackInfo[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Task types
|
||||||
|
export interface TaskRewardDto {
|
||||||
|
type: 'xp' | 'coins' | 'achievement'
|
||||||
|
amount: number
|
||||||
|
achievementId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskDto {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
type: 'app_internal' | 'external' | 'social'
|
||||||
|
difficulty: 'easy' | 'medium' | 'hard'
|
||||||
|
status: 'available' | 'in_progress' | 'completed' | 'expired' | 'failed'
|
||||||
|
rewards: TaskRewardDto[]
|
||||||
|
createdAt: string
|
||||||
|
expiresAt: string
|
||||||
|
completedAt?: string
|
||||||
|
proofUrl?: string
|
||||||
|
instructions?: string
|
||||||
|
tags: string[]
|
||||||
|
imageUrl?: string
|
||||||
|
userId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTaskDto {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
type: 'app_internal' | 'external' | 'social'
|
||||||
|
difficulty: 'easy' | 'medium' | 'hard'
|
||||||
|
rewards: TaskRewardDto[]
|
||||||
|
expiresAt: string
|
||||||
|
instructions?: string
|
||||||
|
tags?: string[]
|
||||||
|
imageUrl?: string
|
||||||
|
userId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTaskDto extends Partial<CreateTaskDto> {
|
||||||
|
status?: TaskDto['status']
|
||||||
|
completedAt?: string
|
||||||
|
proofUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskFilters {
|
||||||
|
type?: TaskDto['type']
|
||||||
|
difficulty?: TaskDto['difficulty']
|
||||||
|
status?: TaskDto['status']
|
||||||
|
userId?: string
|
||||||
|
search?: string
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,26 @@ import '../../discounts/discounts_manager.dart' as _i891;
|
||||||
import '../../packs/free_packs_distributor.dart' as _i1062;
|
import '../../packs/free_packs_distributor.dart' as _i1062;
|
||||||
import '../../packs/pack_dto_converter.dart' as _i433;
|
import '../../packs/pack_dto_converter.dart' as _i433;
|
||||||
import '../../packs/pack_manager.dart' as _i833;
|
import '../../packs/pack_manager.dart' as _i833;
|
||||||
import '../../packs/pack_repository.dart' as _i258;
|
|
||||||
import '../../packs/product_availability_manager.dart' as _i797;
|
import '../../packs/product_availability_manager.dart' as _i797;
|
||||||
import '../../packs/products_price_resolver.dart' as _i908;
|
import '../../packs/products_price_resolver.dart' as _i908;
|
||||||
import '../../promo_codes/promo_codes_manager.dart' as _i151;
|
import '../../promo_codes/promo_codes_manager.dart' as _i151;
|
||||||
|
import '../../repository/%D1%81onverters/card_pack_converter.dart' as _i103;
|
||||||
|
import '../../repository/%D1%81onverters/export.dart' as _i381;
|
||||||
|
import '../../repository/%D1%81onverters/game_card_converter.dart' as _i650;
|
||||||
|
import '../../repository/%D1%81onverters/subscription_converter.dart' as _i768;
|
||||||
|
import '../../repository/%D1%81onverters/user_converter.dart' as _i491;
|
||||||
|
import '../../repository/achievement_repository.dart' as _i403;
|
||||||
|
import '../../repository/discount_repository.dart' as _i864;
|
||||||
|
import '../../repository/export.dart' as _i69;
|
||||||
|
import '../../repository/pack_repository.dart' as _i851;
|
||||||
|
import '../../repository/payment_repository.dart' as _i32;
|
||||||
|
import '../../repository/promo_code_repository.dart' as _i1060;
|
||||||
|
import '../../repository/statistics_repository.dart' as _i499;
|
||||||
|
import '../../repository/subscription_repository.dart' as _i1018;
|
||||||
|
import '../../repository/task_repository.dart' as _i499;
|
||||||
|
import '../../repository/test_repository.dart' as _i223;
|
||||||
|
import '../../repository/user_repository.dart' as _i384;
|
||||||
|
import '../../repository/word_statistics_repository.dart' as _i226;
|
||||||
import '../../statistics/achievement_manager.dart' as _i802;
|
import '../../statistics/achievement_manager.dart' as _i802;
|
||||||
import '../../statistics/session_tracker.dart' as _i71;
|
import '../../statistics/session_tracker.dart' as _i71;
|
||||||
import '../../statistics/statistics_calculator.dart' as _i1029;
|
import '../../statistics/statistics_calculator.dart' as _i1029;
|
||||||
|
|
@ -33,7 +49,6 @@ import '../../tasks/task_manager.dart' as _i586;
|
||||||
import '../../tests/test_manager.dart' as _i259;
|
import '../../tests/test_manager.dart' as _i259;
|
||||||
import '../../user/user_manager.dart' as _i280;
|
import '../../user/user_manager.dart' as _i280;
|
||||||
import '../../user/user_manager_drift.dart' as _i560;
|
import '../../user/user_manager_drift.dart' as _i560;
|
||||||
import '../../user/user_repository.dart' as _i950;
|
|
||||||
import '../ads/ads_manager.dart' as _i846;
|
import '../ads/ads_manager.dart' as _i846;
|
||||||
import '../mnemo_shelf.dart' as _i561;
|
import '../mnemo_shelf.dart' as _i561;
|
||||||
import '../purchase/payment_manager.dart' as _i1009;
|
import '../purchase/payment_manager.dart' as _i1009;
|
||||||
|
|
@ -45,6 +60,7 @@ import '../v2/admin_analytics_api_v2.dart' as _i368;
|
||||||
import '../v2/admin_auth_api_v2.dart' as _i483;
|
import '../v2/admin_auth_api_v2.dart' as _i483;
|
||||||
import '../v2/admin_cards_api_v2.dart' as _i922;
|
import '../v2/admin_cards_api_v2.dart' as _i922;
|
||||||
import '../v2/admin_packs_api_v2.dart' as _i1015;
|
import '../v2/admin_packs_api_v2.dart' as _i1015;
|
||||||
|
import '../v2/admin_tasks_api_v2.dart' as _i1071;
|
||||||
import '../v2/admin_tests_api_v2.dart' as _i116;
|
import '../v2/admin_tests_api_v2.dart' as _i116;
|
||||||
import '../v2/admin_users_api_v2.dart' as _i895;
|
import '../v2/admin_users_api_v2.dart' as _i895;
|
||||||
import '../v2/auth_api_v2.dart' as _i52;
|
import '../v2/auth_api_v2.dart' as _i52;
|
||||||
|
|
@ -80,6 +96,14 @@ extension GetItInjectableX on _i174.GetIt {
|
||||||
gh.lazySingleton<_i240.TelegramAuthCodeService>(
|
gh.lazySingleton<_i240.TelegramAuthCodeService>(
|
||||||
() => _i240.TelegramAuthCodeService(),
|
() => _i240.TelegramAuthCodeService(),
|
||||||
);
|
);
|
||||||
|
gh.lazySingleton<_i103.CardPackConverter>(() => _i103.CardPackConverter());
|
||||||
|
gh.lazySingleton<_i650.GameCardConverter>(() => _i650.GameCardConverter());
|
||||||
|
gh.lazySingleton<_i768.SubscriptionConverter>(
|
||||||
|
() => _i768.SubscriptionConverter(),
|
||||||
|
);
|
||||||
|
gh.lazySingleton<_i491.UserWithDataConverter>(
|
||||||
|
() => _i491.UserWithDataConverter(),
|
||||||
|
);
|
||||||
gh.lazySingleton<_i747.MinioService>(
|
gh.lazySingleton<_i747.MinioService>(
|
||||||
() => _i747.MinioService(gh<_i533.MinioConfig>()),
|
() => _i747.MinioService(gh<_i533.MinioConfig>()),
|
||||||
);
|
);
|
||||||
|
|
@ -95,132 +119,213 @@ extension GetItInjectableX on _i174.GetIt {
|
||||||
gh<_i747.MinioService>(),
|
gh<_i747.MinioService>(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
gh.lazySingleton<_i259.TestManager>(
|
|
||||||
() =>
|
|
||||||
_i259.TestManager(gh<_i1072.AppDatabase>(), gh<_i747.MinioService>()),
|
|
||||||
);
|
|
||||||
gh.lazySingleton<_i377.SubscriptionManager>(
|
|
||||||
() => _i377.SubscriptionManager(gh<_i1072.AppDatabase>()),
|
|
||||||
);
|
|
||||||
gh.lazySingleton<_i108.JwtService>(
|
gh.lazySingleton<_i108.JwtService>(
|
||||||
() => _i108.JwtService(gh<_i1072.AppDatabase>()),
|
() => _i108.JwtService(gh<_i1072.AppDatabase>()),
|
||||||
);
|
);
|
||||||
gh.lazySingleton<_i258.PackRepository>(
|
gh.lazySingleton<_i403.AchievementRepository>(
|
||||||
() => _i258.PackRepository(gh<_i1072.AppDatabase>()),
|
() => _i403.AchievementRepository(gh<_i1072.AppDatabase>()),
|
||||||
);
|
);
|
||||||
gh.lazySingleton<_i71.SessionTracker>(
|
gh.lazySingleton<_i864.DiscountRepository>(
|
||||||
() => _i71.SessionTracker(gh<_i1072.AppDatabase>()),
|
() => _i864.DiscountRepository(gh<_i1072.AppDatabase>()),
|
||||||
);
|
);
|
||||||
gh.lazySingleton<_i909.WordStatisticsManager>(
|
gh.lazySingleton<_i32.PaymentRepository>(
|
||||||
() => _i909.WordStatisticsManager(gh<_i1072.AppDatabase>()),
|
() => _i32.PaymentRepository(gh<_i1072.AppDatabase>()),
|
||||||
|
);
|
||||||
|
gh.lazySingleton<_i1060.PromoCodeRepository>(
|
||||||
|
() => _i1060.PromoCodeRepository(gh<_i1072.AppDatabase>()),
|
||||||
|
);
|
||||||
|
gh.lazySingleton<_i499.StatisticsRepository>(
|
||||||
|
() => _i499.StatisticsRepository(gh<_i1072.AppDatabase>()),
|
||||||
|
);
|
||||||
|
gh.lazySingleton<_i499.TaskRepository>(
|
||||||
|
() => _i499.TaskRepository(gh<_i1072.AppDatabase>()),
|
||||||
|
);
|
||||||
|
gh.lazySingleton<_i223.TestRepository>(
|
||||||
|
() => _i223.TestRepository(gh<_i1072.AppDatabase>()),
|
||||||
|
);
|
||||||
|
gh.lazySingleton<_i226.WordStatisticsRepository>(
|
||||||
|
() => _i226.WordStatisticsRepository(gh<_i1072.AppDatabase>()),
|
||||||
);
|
);
|
||||||
gh.lazySingleton<_i586.TaskManager>(
|
gh.lazySingleton<_i586.TaskManager>(
|
||||||
() => _i586.TaskManager(gh<_i1072.AppDatabase>()),
|
() => _i586.TaskManager(
|
||||||
);
|
gh<_i69.TaskRepository>(),
|
||||||
gh.lazySingleton<_i950.UserRepository>(
|
|
||||||
() => _i950.UserRepository(gh<_i1072.AppDatabase>()),
|
|
||||||
);
|
|
||||||
gh.lazySingleton<_i964.SubscriptionsApiV2>(
|
|
||||||
() => _i964.SubscriptionsApiV2(gh<_i377.SubscriptionManager>()),
|
|
||||||
);
|
|
||||||
gh.lazySingleton<_i296.TelegramBotApiV2>(
|
|
||||||
() => _i296.TelegramBotApiV2(
|
|
||||||
gh<_i1072.AppDatabase>(),
|
gh<_i1072.AppDatabase>(),
|
||||||
gh<_i950.UserRepository>(),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
gh.lazySingleton<_i891.DiscountsManager>(
|
gh.lazySingleton<_i71.SessionTracker>(
|
||||||
() => _i891.DiscountsManager(
|
() => _i71.SessionTracker(gh<_i69.StatisticsRepository>()),
|
||||||
|
);
|
||||||
|
gh.lazySingleton<_i851.PackRepository>(
|
||||||
|
() => _i851.PackRepository(
|
||||||
gh<_i1072.AppDatabase>(),
|
gh<_i1072.AppDatabase>(),
|
||||||
gh<_i950.UserRepository>(),
|
gh<_i381.CardPackConverter>(),
|
||||||
|
gh<_i381.GameCardConverter>(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
gh.lazySingleton<_i802.AchievementManager>(
|
gh.lazySingleton<_i1018.SubscriptionRepository>(
|
||||||
() => _i802.AchievementManager(
|
() => _i1018.SubscriptionRepository(
|
||||||
gh<_i1072.AppDatabase>(),
|
gh<_i1072.AppDatabase>(),
|
||||||
gh<_i950.UserRepository>(),
|
gh<_i768.SubscriptionConverter>(),
|
||||||
),
|
|
||||||
);
|
|
||||||
gh.lazySingleton<_i1029.StatisticsCalculator>(
|
|
||||||
() => _i1029.StatisticsCalculator(
|
|
||||||
gh<_i1072.AppDatabase>(),
|
|
||||||
gh<_i950.UserRepository>(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
gh.factory<_i895.AdminUsersApiV2>(
|
|
||||||
() => _i895.AdminUsersApiV2(
|
|
||||||
gh<_i1072.AppDatabase>(),
|
|
||||||
gh<_i950.UserRepository>(),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
gh.lazySingleton<_i365.MediaApiV2>(
|
gh.lazySingleton<_i365.MediaApiV2>(
|
||||||
() => _i365.MediaApiV2(gh<_i747.MinioService>()),
|
() => _i365.MediaApiV2(gh<_i747.MinioService>()),
|
||||||
);
|
);
|
||||||
|
gh.factory<_i1071.AdminTasksApiV2>(
|
||||||
|
() => _i1071.AdminTasksApiV2(
|
||||||
|
gh<_i1072.AppDatabase>(),
|
||||||
|
gh<_i69.TaskRepository>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
gh.lazySingleton<_i985.TasksApiV2>(
|
gh.lazySingleton<_i985.TasksApiV2>(
|
||||||
() => _i985.TasksApiV2(gh<_i586.TaskManager>()),
|
() => _i985.TasksApiV2(gh<_i586.TaskManager>()),
|
||||||
);
|
);
|
||||||
|
gh.lazySingleton<_i491.UserConverter>(
|
||||||
|
() => _i491.UserConverter(gh<_i103.CardPackConverter>()),
|
||||||
|
);
|
||||||
|
gh.lazySingleton<_i909.WordStatisticsManager>(
|
||||||
|
() => _i909.WordStatisticsManager(gh<_i69.WordStatisticsRepository>()),
|
||||||
|
);
|
||||||
gh.factory<_i1015.AdminPacksApiV2>(
|
gh.factory<_i1015.AdminPacksApiV2>(
|
||||||
() => _i1015.AdminPacksApiV2(
|
() => _i1015.AdminPacksApiV2(
|
||||||
gh<_i1072.AppDatabase>(),
|
gh<_i1072.AppDatabase>(),
|
||||||
gh<_i258.PackRepository>(),
|
gh<_i69.PackRepository>(),
|
||||||
|
gh<_i69.TestRepository>(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
gh.lazySingleton<_i368.AdminAnalyticsApiV2>(
|
gh.lazySingleton<_i377.SubscriptionManager>(
|
||||||
() => _i368.AdminAnalyticsApiV2(
|
() => _i377.SubscriptionManager(gh<_i69.SubscriptionRepository>()),
|
||||||
gh<_i950.UserRepository>(),
|
);
|
||||||
|
gh.lazySingleton<_i259.TestManager>(
|
||||||
|
() => _i259.TestManager(
|
||||||
gh<_i1072.AppDatabase>(),
|
gh<_i1072.AppDatabase>(),
|
||||||
|
gh<_i69.PackRepository>(),
|
||||||
|
gh<_i69.TestRepository>(),
|
||||||
|
gh<_i747.MinioService>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
gh.lazySingleton<_i384.UserRepository>(
|
||||||
|
() => _i384.UserRepository(
|
||||||
|
gh<_i1072.AppDatabase>(),
|
||||||
|
gh<_i381.UserConverter>(),
|
||||||
|
gh<_i381.CardPackConverter>(),
|
||||||
|
gh<_i381.SubscriptionConverter>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
gh.lazySingleton<_i891.DiscountsManager>(
|
||||||
|
() => _i891.DiscountsManager(
|
||||||
|
gh<_i69.DiscountRepository>(),
|
||||||
|
gh<_i69.UserRepository>(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
gh.lazySingleton<_i908.ProductsPriceResolver>(
|
gh.lazySingleton<_i908.ProductsPriceResolver>(
|
||||||
() => _i908.ProductsPriceResolver(
|
() => _i908.ProductsPriceResolver(
|
||||||
gh<_i891.DiscountsManager>(),
|
gh<_i891.DiscountsManager>(),
|
||||||
gh<_i950.UserRepository>(),
|
gh<_i69.UserRepository>(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
gh.lazySingleton<_i802.AchievementManager>(
|
||||||
|
() => _i802.AchievementManager(
|
||||||
|
gh<_i69.AchievementRepository>(),
|
||||||
|
gh<_i69.UserRepository>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
gh.lazySingleton<_i368.AdminAnalyticsApiV2>(
|
||||||
|
() => _i368.AdminAnalyticsApiV2(
|
||||||
|
gh<_i69.UserRepository>(),
|
||||||
|
gh<_i1072.AppDatabase>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
gh.lazySingleton<_i296.TelegramBotApiV2>(
|
||||||
|
() => _i296.TelegramBotApiV2(
|
||||||
|
gh<_i1072.AppDatabase>(),
|
||||||
|
gh<_i69.UserRepository>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
gh.factory<_i895.AdminUsersApiV2>(
|
||||||
|
() => _i895.AdminUsersApiV2(
|
||||||
|
gh<_i1072.AppDatabase>(),
|
||||||
|
gh<_i69.UserRepository>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
gh.lazySingleton<_i964.SubscriptionsApiV2>(
|
||||||
|
() => _i964.SubscriptionsApiV2(gh<_i377.SubscriptionManager>()),
|
||||||
|
);
|
||||||
gh.lazySingleton<_i858.DiscountsApiV2>(
|
gh.lazySingleton<_i858.DiscountsApiV2>(
|
||||||
() => _i858.DiscountsApiV2(gh<_i891.DiscountsManager>()),
|
() => _i858.DiscountsApiV2(gh<_i891.DiscountsManager>()),
|
||||||
);
|
);
|
||||||
|
gh.lazySingleton<_i1029.StatisticsCalculator>(
|
||||||
|
() => _i1029.StatisticsCalculator(
|
||||||
|
gh<_i1072.AppDatabase>(),
|
||||||
|
gh<_i69.UserRepository>(),
|
||||||
|
gh<_i69.PackRepository>(),
|
||||||
|
gh<_i69.WordStatisticsRepository>(),
|
||||||
|
gh<_i69.StatisticsRepository>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
gh.lazySingleton<_i433.PackDtoConverter>(
|
gh.lazySingleton<_i433.PackDtoConverter>(
|
||||||
() => _i433.PackDtoConverter(
|
() => _i433.PackDtoConverter(
|
||||||
gh<_i908.ProductsPriceResolver>(),
|
gh<_i908.ProductsPriceResolver>(),
|
||||||
gh<_i846.AdsManager>(),
|
gh<_i846.AdsManager>(),
|
||||||
|
gh<_i69.UserRepository>(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
gh.lazySingleton<_i833.PackManager>(
|
gh.lazySingleton<_i833.PackManager>(
|
||||||
() => _i833.PackManager(
|
() => _i833.PackManager(
|
||||||
gh<_i1072.AppDatabase>(),
|
gh<_i69.PackRepository>(),
|
||||||
gh<_i258.PackRepository>(),
|
|
||||||
gh<_i433.PackDtoConverter>(),
|
gh<_i433.PackDtoConverter>(),
|
||||||
|
gh<_i1072.AppDatabase>(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
gh.lazySingleton<_i797.ProductAvailabilityManager>(
|
gh.lazySingleton<_i797.ProductAvailabilityManager>(
|
||||||
() => _i797.ProductAvailabilityManager(
|
() => _i797.ProductAvailabilityManager(
|
||||||
gh<_i833.PackManager>(),
|
gh<_i833.PackManager>(),
|
||||||
gh<_i1072.AppDatabase>(),
|
gh<_i69.UserRepository>(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
gh.lazySingleton<_i1062.FreePacksDistributor>(
|
gh.lazySingleton<_i1062.FreePacksDistributor>(
|
||||||
() => _i1062.FreePacksDistributor(
|
() => _i1062.FreePacksDistributor(
|
||||||
gh<_i797.ProductAvailabilityManager>(),
|
gh<_i797.ProductAvailabilityManager>(),
|
||||||
gh<_i258.PackRepository>(),
|
gh<_i69.PackRepository>(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
gh.lazySingleton<_i280.UserManager>(
|
gh.lazySingleton<_i1009.PaymentManager>(
|
||||||
() => _i280.UserManager(
|
() => _i1009.PaymentManager(
|
||||||
|
gh<_i69.PaymentRepository>(),
|
||||||
|
gh<_i69.SubscriptionRepository>(),
|
||||||
|
gh<_i69.UserRepository>(),
|
||||||
|
gh<_i797.ProductAvailabilityManager>(),
|
||||||
|
gh<_i988.YooMoneyHandler>(),
|
||||||
|
gh<_i222.RustorePurchaseHandler>(),
|
||||||
gh<_i1072.AppDatabase>(),
|
gh<_i1072.AppDatabase>(),
|
||||||
gh<_i950.UserRepository>(),
|
|
||||||
gh<_i1062.FreePacksDistributor>(),
|
|
||||||
gh<_i71.SessionTracker>(),
|
|
||||||
gh<_i1029.StatisticsCalculator>(),
|
|
||||||
gh<_i802.AchievementManager>(),
|
|
||||||
gh<_i909.WordStatisticsManager>(),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
gh.lazySingleton<_i483.AdminAuthApiV2>(
|
gh.lazySingleton<_i800.PacksApiV2>(
|
||||||
() => _i483.AdminAuthApiV2(
|
() => _i800.PacksApiV2(
|
||||||
gh<_i240.TelegramAuthCodeService>(),
|
gh<_i833.PackManager>(),
|
||||||
gh<_i280.UserManager>(),
|
gh<_i259.TestManager>(),
|
||||||
gh<_i108.JwtService>(),
|
gh<_i69.PackRepository>(),
|
||||||
|
gh<_i797.ProductAvailabilityManager>(),
|
||||||
|
gh<_i1072.AppDatabase>(),
|
||||||
|
gh<_i747.MinioService>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
gh.lazySingleton<_i69.PurchasesApiV2>(
|
||||||
|
() => _i69.PurchasesApiV2(
|
||||||
|
gh<_i1009.PaymentManager>(),
|
||||||
|
gh<_i833.PackManager>(),
|
||||||
|
gh<_i69.UserRepository>(),
|
||||||
|
gh<_i69.SubscriptionRepository>(),
|
||||||
|
gh<_i69.PaymentRepository>(),
|
||||||
|
gh<_i797.ProductAvailabilityManager>(),
|
||||||
|
gh<_i1072.AppDatabase>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
gh.lazySingleton<_i151.PromoCodesManager>(
|
||||||
|
() => _i151.PromoCodesManager(
|
||||||
|
gh<_i69.PromoCodeRepository>(),
|
||||||
|
gh<_i1009.PaymentManager>(),
|
||||||
|
gh<_i1072.AppDatabase>(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
gh.lazySingleton<_i560.UserManager>(
|
gh.lazySingleton<_i560.UserManager>(
|
||||||
|
|
@ -232,35 +337,20 @@ extension GetItInjectableX on _i174.GetIt {
|
||||||
gh<_i802.AchievementManager>(),
|
gh<_i802.AchievementManager>(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
gh.lazySingleton<_i561.MnemoShelf>(
|
gh.lazySingleton<_i280.UserManager>(
|
||||||
() => _i561.MnemoShelf(gh<_i280.UserManager>()),
|
() => _i280.UserManager(
|
||||||
);
|
|
||||||
gh.lazySingleton<_i52.AuthApiV2>(
|
|
||||||
() => _i52.AuthApiV2(
|
|
||||||
gh<_i280.UserManager>(),
|
|
||||||
gh<_i972.GoogleApi>(),
|
|
||||||
gh<_i108.JwtService>(),
|
|
||||||
gh<_i240.TelegramAuthCodeService>(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
gh.lazySingleton<_i1009.PaymentManager>(
|
|
||||||
() => _i1009.PaymentManager(
|
|
||||||
gh<_i1072.AppDatabase>(),
|
gh<_i1072.AppDatabase>(),
|
||||||
gh<_i950.UserRepository>(),
|
gh<_i69.UserRepository>(),
|
||||||
gh<_i797.ProductAvailabilityManager>(),
|
gh<_i69.PackRepository>(),
|
||||||
gh<_i988.YooMoneyHandler>(),
|
gh<_i1062.FreePacksDistributor>(),
|
||||||
gh<_i222.RustorePurchaseHandler>(),
|
gh<_i71.SessionTracker>(),
|
||||||
|
gh<_i1029.StatisticsCalculator>(),
|
||||||
|
gh<_i802.AchievementManager>(),
|
||||||
|
gh<_i909.WordStatisticsManager>(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
gh.lazySingleton<_i800.PacksApiV2>(
|
gh.lazySingleton<_i273.PromocodesApiV2>(
|
||||||
() => _i800.PacksApiV2(
|
() => _i273.PromocodesApiV2(gh<_i151.PromoCodesManager>()),
|
||||||
gh<_i833.PackManager>(),
|
|
||||||
gh<_i259.TestManager>(),
|
|
||||||
gh<_i258.PackRepository>(),
|
|
||||||
gh<_i797.ProductAvailabilityManager>(),
|
|
||||||
gh<_i1072.AppDatabase>(),
|
|
||||||
gh<_i747.MinioService>(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
gh.lazySingleton<_i735.CheckPaymentTask>(
|
gh.lazySingleton<_i735.CheckPaymentTask>(
|
||||||
() => _i735.CheckPaymentTask(
|
() => _i735.CheckPaymentTask(
|
||||||
|
|
@ -275,13 +365,11 @@ extension GetItInjectableX on _i174.GetIt {
|
||||||
gh<_i1072.AppDatabase>(),
|
gh<_i1072.AppDatabase>(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
gh.lazySingleton<_i69.PurchasesApiV2>(
|
gh.lazySingleton<_i483.AdminAuthApiV2>(
|
||||||
() => _i69.PurchasesApiV2(
|
() => _i483.AdminAuthApiV2(
|
||||||
gh<_i1009.PaymentManager>(),
|
gh<_i240.TelegramAuthCodeService>(),
|
||||||
gh<_i833.PackManager>(),
|
gh<_i280.UserManager>(),
|
||||||
gh<_i950.UserRepository>(),
|
gh<_i108.JwtService>(),
|
||||||
gh<_i797.ProductAvailabilityManager>(),
|
|
||||||
gh<_i1072.AppDatabase>(),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
gh.lazySingleton<_i247.UsersApiV2>(
|
gh.lazySingleton<_i247.UsersApiV2>(
|
||||||
|
|
@ -289,18 +377,20 @@ extension GetItInjectableX on _i174.GetIt {
|
||||||
gh<_i280.UserManager>(),
|
gh<_i280.UserManager>(),
|
||||||
gh<_i1009.PaymentManager>(),
|
gh<_i1009.PaymentManager>(),
|
||||||
gh<_i1029.StatisticsCalculator>(),
|
gh<_i1029.StatisticsCalculator>(),
|
||||||
gh<_i950.UserRepository>(),
|
gh<_i69.UserRepository>(),
|
||||||
gh<_i1072.AppDatabase>(),
|
gh<_i1072.AppDatabase>(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
gh.lazySingleton<_i151.PromoCodesManager>(
|
gh.lazySingleton<_i561.MnemoShelf>(
|
||||||
() => _i151.PromoCodesManager(
|
() => _i561.MnemoShelf(gh<_i280.UserManager>()),
|
||||||
gh<_i1072.AppDatabase>(),
|
|
||||||
gh<_i1009.PaymentManager>(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
gh.lazySingleton<_i273.PromocodesApiV2>(
|
gh.lazySingleton<_i52.AuthApiV2>(
|
||||||
() => _i273.PromocodesApiV2(gh<_i151.PromoCodesManager>()),
|
() => _i52.AuthApiV2(
|
||||||
|
gh<_i280.UserManager>(),
|
||||||
|
gh<_i972.GoogleApi>(),
|
||||||
|
gh<_i108.JwtService>(),
|
||||||
|
gh<_i240.TelegramAuthCodeService>(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import 'v2/admin_analytics_api_v2.dart';
|
||||||
import 'v2/admin_auth_api_v2.dart';
|
import 'v2/admin_auth_api_v2.dart';
|
||||||
import 'v2/admin_cards_api_v2.dart';
|
import 'v2/admin_cards_api_v2.dart';
|
||||||
import 'v2/admin_packs_api_v2.dart';
|
import 'v2/admin_packs_api_v2.dart';
|
||||||
|
import 'v2/admin_tasks_api_v2.dart';
|
||||||
import 'v2/admin_tests_api_v2.dart';
|
import 'v2/admin_tests_api_v2.dart';
|
||||||
import 'v2/admin_users_api_v2.dart';
|
import 'v2/admin_users_api_v2.dart';
|
||||||
import 'v2/auth_api_v2.dart';
|
import 'v2/auth_api_v2.dart';
|
||||||
|
|
@ -56,6 +57,7 @@ class MnemoShelf {
|
||||||
v2Router.mount('/', getIt.get<AdminAnalyticsApiV2>().router);
|
v2Router.mount('/', getIt.get<AdminAnalyticsApiV2>().router);
|
||||||
v2Router.mount('/', getIt.get<AdminCardsApiV2>().router);
|
v2Router.mount('/', getIt.get<AdminCardsApiV2>().router);
|
||||||
v2Router.mount('/', getIt.get<AdminPacksApiV2>().router);
|
v2Router.mount('/', getIt.get<AdminPacksApiV2>().router);
|
||||||
|
v2Router.mount('/', getIt.get<AdminTasksApiV2>().router);
|
||||||
v2Router.mount('/', getIt.get<AdminTestsApiV2>().router);
|
v2Router.mount('/', getIt.get<AdminTestsApiV2>().router);
|
||||||
v2Router.mount('/', getIt.get<AdminUsersApiV2>().router);
|
v2Router.mount('/', getIt.get<AdminUsersApiV2>().router);
|
||||||
v2Router.mount('/', getIt.get<PacksApiV2>().router);
|
v2Router.mount('/', getIt.get<PacksApiV2>().router);
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import 'package:googleapis/androidpublisher/v3.dart' as ap;
|
||||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
|
|
||||||
import 'constaints.dart';
|
import 'constaints.dart';
|
||||||
import 'iap_repository.dart';
|
import '../../repository/export.dart';
|
||||||
import 'products.dart';
|
import 'products.dart';
|
||||||
|
|
||||||
class GooglePlayPurchaseHandler {
|
class GooglePlayPurchaseHandler {
|
||||||
|
|
|
||||||
|
|
@ -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:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
import 'package:drift/drift.dart' as drift;
|
import 'package:drift/drift.dart' as drift;
|
||||||
|
|
||||||
import 'payment_drift_extension.dart';
|
|
||||||
import 'rustore/rustore_purchase_handler.dart';
|
import 'rustore/rustore_purchase_handler.dart';
|
||||||
import 'yoo_money.dart';
|
import 'yoo_money.dart';
|
||||||
import '../../packs/product_availability_manager.dart';
|
import '../../packs/product_availability_manager.dart';
|
||||||
import '../../user/user_repository.dart';
|
import '../../repository/export.dart';
|
||||||
|
|
||||||
/// Result of creating YooKassa payment URL
|
/// Result of creating YooKassa payment URL
|
||||||
class YookassaPaymentResult {
|
class YookassaPaymentResult {
|
||||||
|
|
@ -26,19 +25,23 @@ class YookassaPaymentResult {
|
||||||
|
|
||||||
@lazySingleton
|
@lazySingleton
|
||||||
class PaymentManager {
|
class PaymentManager {
|
||||||
final AppDatabase _db;
|
final PaymentRepository _paymentRepository;
|
||||||
|
final SubscriptionRepository _subscriptionRepository;
|
||||||
final UserRepository _userRepository;
|
final UserRepository _userRepository;
|
||||||
final ProductAvailabilityManager _productAvailabilityManager;
|
final ProductAvailabilityManager _productAvailabilityManager;
|
||||||
late final GooglePlayPurchaseHandler googlePurchaseHandler;
|
late final GooglePlayPurchaseHandler googlePurchaseHandler;
|
||||||
final YooMoneyHandler _yooMoneyHandler;
|
final YooMoneyHandler _yooMoneyHandler;
|
||||||
final RustorePurchaseHandler _rustorePurchaseHandler;
|
final RustorePurchaseHandler _rustorePurchaseHandler;
|
||||||
|
final AppDatabase _db;
|
||||||
|
|
||||||
PaymentManager(
|
PaymentManager(
|
||||||
this._db,
|
this._paymentRepository,
|
||||||
|
this._subscriptionRepository,
|
||||||
this._userRepository,
|
this._userRepository,
|
||||||
this._productAvailabilityManager,
|
this._productAvailabilityManager,
|
||||||
this._yooMoneyHandler,
|
this._yooMoneyHandler,
|
||||||
this._rustorePurchaseHandler,
|
this._rustorePurchaseHandler,
|
||||||
|
this._db,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Создать платеж в базе данных
|
/// Создать платеж в базе данных
|
||||||
|
|
@ -47,32 +50,22 @@ class PaymentManager {
|
||||||
print(
|
print(
|
||||||
'🔍 PaymentManager.createPayment: userId=$userId, externalToken=${paymentDto.externalToken}',
|
'🔍 PaymentManager.createPayment: userId=$userId, externalToken=${paymentDto.externalToken}',
|
||||||
);
|
);
|
||||||
final companion = paymentDto.toCompanion(userId);
|
print(
|
||||||
print('🔍 PaymentManager.createPayment: Calling paymentDao.createPayment');
|
'🔍 PaymentManager.createPayment: Calling paymentRepository.createPayment',
|
||||||
final paymentId = await _db.paymentDao.createPayment(companion);
|
);
|
||||||
print('✅ PaymentManager.createPayment: Payment created, id=$paymentId');
|
final payment = await _paymentRepository.createPayment(paymentDto, userId);
|
||||||
print('🔍 PaymentManager.createPayment: Getting payment by id');
|
print('✅ PaymentManager.createPayment: Payment created successfully');
|
||||||
final payment = await _db.paymentDao.getPaymentById(paymentId);
|
return payment;
|
||||||
if (payment == null) {
|
|
||||||
print(
|
|
||||||
'❌ PaymentManager.createPayment: Payment not found after creation, id=$paymentId',
|
|
||||||
);
|
|
||||||
throw Exception('Failed to create payment');
|
|
||||||
}
|
|
||||||
print('✅ PaymentManager.createPayment: Payment retrieved successfully');
|
|
||||||
return payment.toDto();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Обновить платеж в базе данных
|
/// Обновить платеж в базе данных
|
||||||
Future<void> updatePayment(String paymentId, PaymentDto paymentDto) async {
|
Future<void> updatePayment(String paymentId, PaymentDto paymentDto) async {
|
||||||
final companion = paymentDto.toUpdateCompanion();
|
await _paymentRepository.updatePayment(paymentId, paymentDto);
|
||||||
await _db.paymentDao.updatePaymentCompanion(paymentId, companion);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Получить платеж по ID
|
/// Получить платеж по ID
|
||||||
Future<PaymentDto?> getPaymentById(String id) async {
|
Future<PaymentDto?> getPaymentById(String id) async {
|
||||||
final payment = await _db.paymentDao.getPaymentById(id);
|
return await _paymentRepository.getPaymentById(id);
|
||||||
return payment?.toDto();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Получить последний платеж для пользователя и пака
|
/// Получить последний платеж для пользователя и пака
|
||||||
|
|
@ -81,11 +74,10 @@ class PaymentManager {
|
||||||
required String userId,
|
required String userId,
|
||||||
required String packId,
|
required String packId,
|
||||||
}) async {
|
}) async {
|
||||||
final payment = await _db.paymentDao.getLatestPaymentForUserAndPack(
|
return await _paymentRepository.getLatestPaymentForUserAndPack(
|
||||||
userId: userId,
|
userId: userId,
|
||||||
packId: packId,
|
packId: packId,
|
||||||
);
|
);
|
||||||
return payment?.toDto();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Выдать продукт пользователю (для промокодов и других бесплатных активаций)
|
/// Выдать продукт пользователю (для промокодов и других бесплатных активаций)
|
||||||
|
|
@ -104,17 +96,17 @@ class PaymentManager {
|
||||||
} else if (product.type == MnemoCardsProductType.subscription &&
|
} else if (product.type == MnemoCardsProductType.subscription &&
|
||||||
product.id != null) {
|
product.id != null) {
|
||||||
final planId = product.id!;
|
final planId = product.id!;
|
||||||
final plan = await _db.subscriptionDao.getPlanById(planId);
|
final plan = await _subscriptionRepository.getPlanById(planId);
|
||||||
if (plan != null) {
|
if (plan != null) {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final endDate = now.add(Duration(days: plan.durationDays));
|
final endDate = now.add(Duration(days: plan.durationDays));
|
||||||
|
|
||||||
await _db.subscriptionDao.createUserSubscription(
|
await _subscriptionRepository.createSubscription(
|
||||||
UserSubscriptionsCompanion.insert(
|
UserSubscriptionsCompanion.insert(
|
||||||
userId: userId,
|
userId: userId,
|
||||||
start: PgDateTime(now),
|
start: PgDateTime(now),
|
||||||
finish: PgDateTime(endDate),
|
finish: PgDateTime(endDate),
|
||||||
features: drift.Value(plan.features),
|
features: drift.Value(plan.features.map((f) => f.name).toList()),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
log('Granted subscription $planId to user $userId via promo code');
|
log('Granted subscription $planId to user $userId via promo code');
|
||||||
|
|
@ -135,10 +127,7 @@ class PaymentManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получить пользователя
|
// Получить пользователя
|
||||||
final userModel = await _userRepository.getUserById(
|
final userModel = await _userRepository.getUserById(payment.userId);
|
||||||
payment.userId,
|
|
||||||
withData: true,
|
|
||||||
);
|
|
||||||
if (userModel == null) {
|
if (userModel == null) {
|
||||||
log('User not found: ${payment.userId}');
|
log('User not found: ${payment.userId}');
|
||||||
return;
|
return;
|
||||||
|
|
@ -188,17 +177,17 @@ class PaymentManager {
|
||||||
|
|
||||||
// Создать подписки
|
// Создать подписки
|
||||||
for (final subscriptionId in subscriptionIds) {
|
for (final subscriptionId in subscriptionIds) {
|
||||||
final plan = await _db.subscriptionDao.getPlanById(subscriptionId);
|
final plan = await _subscriptionRepository.getPlanById(subscriptionId);
|
||||||
if (plan != null) {
|
if (plan != null) {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final endDate = now.add(Duration(days: plan.durationDays));
|
final endDate = now.add(Duration(days: plan.durationDays));
|
||||||
|
|
||||||
await _db.subscriptionDao.createUserSubscription(
|
await _subscriptionRepository.createSubscription(
|
||||||
UserSubscriptionsCompanion.insert(
|
UserSubscriptionsCompanion.insert(
|
||||||
userId: payment.userId,
|
userId: payment.userId,
|
||||||
start: PgDateTime(now),
|
start: PgDateTime(now),
|
||||||
finish: PgDateTime(endDate),
|
finish: PgDateTime(endDate),
|
||||||
features: drift.Value(plan.features),
|
features: drift.Value(plan.features.map((f) => f.name).toList()),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
log(
|
log(
|
||||||
|
|
@ -215,7 +204,7 @@ class PaymentManager {
|
||||||
try {
|
try {
|
||||||
// Try to update by externalToken (if it's used as identifier)
|
// Try to update by externalToken (if it's used as identifier)
|
||||||
// TODO: Once database is regenerated, use payment.id directly
|
// TODO: Once database is regenerated, use payment.id directly
|
||||||
await _db.paymentDao.updatePaymentStatus(
|
await _paymentRepository.updatePaymentStatus(
|
||||||
payment.externalToken!,
|
payment.externalToken!,
|
||||||
PaymentStatus.processed.name,
|
PaymentStatus.processed.name,
|
||||||
);
|
);
|
||||||
|
|
@ -294,24 +283,30 @@ class PaymentManager {
|
||||||
.checkPayment(subscriptionToken);
|
.checkPayment(subscriptionToken);
|
||||||
|
|
||||||
// Найти платеж по продукту
|
// Найти платеж по продукту
|
||||||
final payments = await _db.paymentDao.getPaymentsByProduct(productId);
|
final paymentsDto = await _paymentRepository.getPaymentsByProduct(
|
||||||
final payment = payments.isNotEmpty ? payments.first : null;
|
productId,
|
||||||
|
);
|
||||||
|
final paymentDto = paymentsDto.isNotEmpty ? paymentsDto.first : null;
|
||||||
|
|
||||||
if (payment == null) {
|
if (paymentDto == null) {
|
||||||
log('Payment not found for product: $productId');
|
log('Payment not found for product: $productId');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rustorePurchaseResponse?.invoiceStatus.name == 'paid') {
|
if (rustorePurchaseResponse?.invoiceStatus.name == 'paid') {
|
||||||
// Find payment id by productId or use subscriptionToken
|
// Find payment id by productId or use subscriptionToken
|
||||||
final token = payment.externalToken ?? subscriptionToken;
|
final token = paymentDto.externalToken ?? subscriptionToken;
|
||||||
if (token.isNotEmpty) {
|
if (token.isNotEmpty) {
|
||||||
await _db.paymentDao.updatePaymentStatus(
|
await _paymentRepository.updatePaymentStatus(
|
||||||
token,
|
token,
|
||||||
PaymentStatus.succeeded.name,
|
PaymentStatus.succeeded.name,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await processPayment(payment);
|
// Обработать платеж - нужно получить Payment из БД для processPayment
|
||||||
|
final payment = await _db.paymentDao.getPaymentByExternalToken(token);
|
||||||
|
if (payment != null) {
|
||||||
|
await processPayment(payment);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -325,8 +320,10 @@ class PaymentManager {
|
||||||
/// Проверить платеж YooKassa
|
/// Проверить платеж YooKassa
|
||||||
Future<bool> checkYookassaPayment(String token) async {
|
Future<bool> checkYookassaPayment(String token) async {
|
||||||
try {
|
try {
|
||||||
final payment = await _db.paymentDao.getPaymentByExternalToken(token);
|
final paymentDto = await _paymentRepository.getPaymentByExternalToken(
|
||||||
if (payment == null) {
|
token,
|
||||||
|
);
|
||||||
|
if (paymentDto == null) {
|
||||||
print('Payment not found for token: $token');
|
print('Payment not found for token: $token');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -336,14 +333,18 @@ class PaymentManager {
|
||||||
|
|
||||||
if (yookassaPayment.status == 'succeeded') {
|
if (yookassaPayment.status == 'succeeded') {
|
||||||
// Use token as payment identifier
|
// Use token as payment identifier
|
||||||
await _db.paymentDao.updatePaymentStatus(
|
await _paymentRepository.updatePaymentStatus(
|
||||||
token,
|
token,
|
||||||
PaymentStatus.succeeded.name,
|
PaymentStatus.succeeded.name,
|
||||||
);
|
);
|
||||||
await processPayment(payment);
|
// Обработать платеж - нужно получить Payment из БД для processPayment
|
||||||
|
final payment = await _db.paymentDao.getPaymentByExternalToken(token);
|
||||||
|
if (payment != null) {
|
||||||
|
await processPayment(payment);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
} else if (yookassaPayment.status == 'canceled') {
|
} else if (yookassaPayment.status == 'canceled') {
|
||||||
await _db.paymentDao.updatePaymentStatus(
|
await _paymentRepository.updatePaymentStatus(
|
||||||
token,
|
token,
|
||||||
PaymentStatus.canceled.name,
|
PaymentStatus.canceled.name,
|
||||||
);
|
);
|
||||||
|
|
@ -415,8 +416,7 @@ class PaymentManager {
|
||||||
|
|
||||||
/// Получить платежи пользователя
|
/// Получить платежи пользователя
|
||||||
Future<List<PaymentDto>> getUserPayments(String userIdString) async {
|
Future<List<PaymentDto>> getUserPayments(String userIdString) async {
|
||||||
final payments = await _db.paymentDao.getPaymentsByUserId(userIdString);
|
return await _paymentRepository.getPaymentsByUserId(userIdString);
|
||||||
return payments.map((p) => p.toDto()).toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Проверить и обработать платеж (для cron задач)
|
/// Проверить и обработать платеж (для cron задач)
|
||||||
|
|
|
||||||
|
|
@ -259,11 +259,7 @@ class YooMoneyHandler {
|
||||||
|
|
||||||
/// Build return URL for payment confirmation
|
/// Build return URL for payment confirmation
|
||||||
/// Uses YOOKASSA_RETURN_URL env var if set, otherwise defaults to web app URL
|
/// Uses YOOKASSA_RETURN_URL env var if set, otherwise defaults to web app URL
|
||||||
String _buildReturnUrl(
|
String _buildReturnUrl(String userId, {String? packId, String? paymentId}) {
|
||||||
String userId, {
|
|
||||||
String? packId,
|
|
||||||
String? paymentId,
|
|
||||||
}) {
|
|
||||||
// Construct query parameters
|
// Construct query parameters
|
||||||
final params = <String, String>{
|
final params = <String, String>{
|
||||||
'userId': userId,
|
'userId': userId,
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
import 'package:drift_postgres/drift_postgres.dart';
|
import 'package:drift_postgres/drift_postgres.dart';
|
||||||
import 'package:injectable/injectable.dart';
|
import 'package:injectable/injectable.dart';
|
||||||
import 'package:mnemo_cards_backend/database/database.dart';
|
import 'package:mnemo_cards_backend/database/database.dart';
|
||||||
|
import 'package:mnemo_cards_backend/repository/export.dart';
|
||||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
@lazySingleton
|
@lazySingleton
|
||||||
class SubscriptionManager {
|
class SubscriptionManager {
|
||||||
final AppDatabase _db;
|
final SubscriptionRepository _subscriptionRepository;
|
||||||
|
|
||||||
SubscriptionManager(this._db);
|
SubscriptionManager(this._subscriptionRepository);
|
||||||
|
|
||||||
Future<SubscriptionDto> getSubscriptionDto(UserModel user) async {
|
Future<SubscriptionDto> getSubscriptionDto(UserModel user) async {
|
||||||
if (user.id == null) {
|
if (user.id == null) {
|
||||||
|
|
@ -21,15 +22,15 @@ class SubscriptionManager {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final subscription = await _db.subscriptionDao.getActiveUserSubscription(
|
final subscription = await _subscriptionRepository.getActiveSubscription(
|
||||||
user.id!,
|
user.id!,
|
||||||
);
|
);
|
||||||
|
|
||||||
return SubscriptionDto(
|
return SubscriptionDto(
|
||||||
page: null,
|
page: null,
|
||||||
isActive: subscription != null,
|
isActive: subscription != null,
|
||||||
start: subscription?.start.dateTime,
|
start: subscription?.start,
|
||||||
finish: subscription?.finish.dateTime,
|
finish: subscription?.finish,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,7 +45,7 @@ class SubscriptionManager {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final endDate = now.add(Duration(days: plan.durationDays));
|
final endDate = now.add(Duration(days: plan.durationDays));
|
||||||
|
|
||||||
await _db.subscriptionDao.createUserSubscription(
|
await _subscriptionRepository.createSubscription(
|
||||||
UserSubscriptionsCompanion.insert(
|
UserSubscriptionsCompanion.insert(
|
||||||
userId: user.id!,
|
userId: user.id!,
|
||||||
start: PgDateTime(now),
|
start: PgDateTime(now),
|
||||||
|
|
@ -55,25 +56,7 @@ class SubscriptionManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<SubscriptionPlanModel>> getAllPlans() async {
|
Future<List<SubscriptionPlanModel>> getAllPlans() async {
|
||||||
final plans = await _db.subscriptionDao.getAllPlans();
|
return await _subscriptionRepository.getAllPlans();
|
||||||
return plans.map((plan) {
|
|
||||||
final uiMap = plan.ui as Map<String, dynamic>?;
|
|
||||||
final ui = uiMap != null ? SubscriptionPlanUI.fromJson(uiMap) : null;
|
|
||||||
|
|
||||||
return SubscriptionPlanModel(
|
|
||||||
id: plan.id,
|
|
||||||
ui: ui,
|
|
||||||
price: plan.price,
|
|
||||||
currency: plan.currency,
|
|
||||||
durationDays: plan.durationDays,
|
|
||||||
features: [],
|
|
||||||
paymentSystem: PaymentSystem.values.firstWhere(
|
|
||||||
(ps) => ps.name == plan.paymentSystem,
|
|
||||||
orElse: () => PaymentSystem.unknown,
|
|
||||||
),
|
|
||||||
paymentId: plan.paymentId,
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<SubscriptionPlanModel>> getAllSubscriptionPlans() async {
|
Future<List<SubscriptionPlanModel>> getAllSubscriptionPlans() async {
|
||||||
|
|
@ -81,7 +64,7 @@ class SubscriptionManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> purchaseSubscription(String userId, String planId) async {
|
Future<void> purchaseSubscription(String userId, String planId) async {
|
||||||
final plan = await _db.subscriptionDao.getPlanById(planId);
|
final plan = await _subscriptionRepository.getPlanById(planId);
|
||||||
if (plan == null) {
|
if (plan == null) {
|
||||||
throw StateError('Subscription plan not found');
|
throw StateError('Subscription plan not found');
|
||||||
}
|
}
|
||||||
|
|
@ -89,17 +72,17 @@ class SubscriptionManager {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final endDate = now.add(Duration(days: plan.durationDays));
|
final endDate = now.add(Duration(days: plan.durationDays));
|
||||||
|
|
||||||
await _db.subscriptionDao.createUserSubscription(
|
await _subscriptionRepository.createSubscription(
|
||||||
UserSubscriptionsCompanion.insert(
|
UserSubscriptionsCompanion.insert(
|
||||||
userId: userId,
|
userId: userId,
|
||||||
start: PgDateTime(now),
|
start: PgDateTime(now),
|
||||||
finish: PgDateTime(endDate),
|
finish: PgDateTime(endDate),
|
||||||
features: Value(plan.features as List<dynamic>),
|
features: Value(plan.features.map((f) => f.name).toList()),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> cancelSubscription(String userId) async {
|
Future<void> cancelSubscription(String userId) async {
|
||||||
await _db.subscriptionDao.cancelUserSubscription(userId);
|
await _subscriptionRepository.cancelSubscription(userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import 'package:mnemo_cards_backend/api/authorize/acl_types.dart';
|
||||||
import 'package:mnemo_cards_backend/api/authorize/access_service.dart';
|
import 'package:mnemo_cards_backend/api/authorize/access_service.dart';
|
||||||
import 'package:mnemo_cards_backend/api/authorize/helpers.dart';
|
import 'package:mnemo_cards_backend/api/authorize/helpers.dart';
|
||||||
import 'package:mnemo_cards_backend/database/database.dart';
|
import 'package:mnemo_cards_backend/database/database.dart';
|
||||||
import 'package:mnemo_cards_backend/user/user_repository.dart';
|
import 'package:mnemo_cards_backend/repository/export.dart';
|
||||||
import 'package:shelf/shelf.dart';
|
import 'package:shelf/shelf.dart';
|
||||||
import 'package:shelf_router/shelf_router.dart';
|
import 'package:shelf_router/shelf_router.dart';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import 'package:mnemo_cards_backend/api/authorize/access_service.dart';
|
||||||
import 'package:mnemo_cards_backend/api/authorize/helpers.dart';
|
import 'package:mnemo_cards_backend/api/authorize/helpers.dart';
|
||||||
import 'package:mnemo_cards_backend/packs/card_pack_drift_extension.dart';
|
import 'package:mnemo_cards_backend/packs/card_pack_drift_extension.dart';
|
||||||
import 'package:mnemo_cards_backend/packs/card_image_storage.dart';
|
import 'package:mnemo_cards_backend/packs/card_image_storage.dart';
|
||||||
import 'package:mnemo_cards_backend/packs/pack_repository.dart';
|
import 'package:mnemo_cards_backend/repository/export.dart';
|
||||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||||
import 'package:shelf/shelf.dart';
|
import 'package:shelf/shelf.dart';
|
||||||
|
|
@ -22,8 +22,9 @@ part 'admin_packs_api_v2.g.dart';
|
||||||
class AdminPacksApiV2 {
|
class AdminPacksApiV2 {
|
||||||
final AppDatabase _db;
|
final AppDatabase _db;
|
||||||
final PackRepository _packRepository;
|
final PackRepository _packRepository;
|
||||||
|
final TestRepository _testRepository;
|
||||||
|
|
||||||
AdminPacksApiV2(this._db, this._packRepository);
|
AdminPacksApiV2(this._db, this._packRepository, this._testRepository);
|
||||||
|
|
||||||
static final _uuidRegex = RegExp(
|
static final _uuidRegex = RegExp(
|
||||||
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
|
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
|
||||||
|
|
@ -70,8 +71,7 @@ class AdminPacksApiV2 {
|
||||||
mnemo: const drift.Value('test_image'),
|
mnemo: const drift.Value('test_image'),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Note: createCard is not in PackRepository, keeping direct DAO call for now
|
final cardId = await _packRepository.createCard(companion);
|
||||||
final cardId = await _db.packDao.createCard(companion);
|
|
||||||
await _tryLinkCardToPack(packId: packId, cardId: cardId);
|
await _tryLinkCardToPack(packId: packId, cardId: cardId);
|
||||||
|
|
||||||
final stored = await CardImageStorage.persistFromBase64(
|
final stored = await CardImageStorage.persistFromBase64(
|
||||||
|
|
@ -85,12 +85,11 @@ class AdminPacksApiV2 {
|
||||||
final created = await _packRepository.getCardById(cardId);
|
final created = await _packRepository.getCardById(cardId);
|
||||||
if (created == null) return null;
|
if (created == null) return null;
|
||||||
|
|
||||||
// Note: updateCard is not in PackRepository, keeping direct DAO call for now
|
// TODO: Refactor to use PackRepository.updateCard with GameCardModel
|
||||||
// We need to get the Drift GameCard to update it
|
|
||||||
final driftCard = await _db.packDao.getCardById(cardId);
|
final driftCard = await _db.packDao.getCardById(cardId);
|
||||||
if (driftCard == null) return null;
|
if (driftCard == null) return null;
|
||||||
|
|
||||||
await _db.packDao.updateCard(
|
await _packRepository.updateCard(
|
||||||
driftCard.copyWith(
|
driftCard.copyWith(
|
||||||
image: stored.fileName,
|
image: stored.fileName,
|
||||||
updatedAt: PgDateTime(DateTime.now()),
|
updatedAt: PgDateTime(DateTime.now()),
|
||||||
|
|
@ -143,6 +142,7 @@ class AdminPacksApiV2 {
|
||||||
packId: packId,
|
packId: packId,
|
||||||
);
|
);
|
||||||
if ((test.cover ?? '').trim() != (normalizedCover ?? '').trim()) {
|
if ((test.cover ?? '').trim() != (normalizedCover ?? '').trim()) {
|
||||||
|
// TODO: Refactor to use TestRepository.updateTest
|
||||||
await _db.testDao.updateTest(
|
await _db.testDao.updateTest(
|
||||||
test.copyWith(
|
test.copyWith(
|
||||||
cover: drift.Value(normalizedCover),
|
cover: drift.Value(normalizedCover),
|
||||||
|
|
@ -155,7 +155,7 @@ class AdminPacksApiV2 {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure all question/button images are normalized and cards linked to pack.
|
// Ensure all question/button images are normalized and cards linked to pack.
|
||||||
final questions = await _db.testDao.getTestQuestions(testId);
|
final questions = await _testRepository.getTestQuestions(testId);
|
||||||
for (final q in questions) {
|
for (final q in questions) {
|
||||||
// Parse options/buttons
|
// Parse options/buttons
|
||||||
List<dynamic> buttons;
|
List<dynamic> buttons;
|
||||||
|
|
@ -220,6 +220,7 @@ class AdminPacksApiV2 {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mutated) {
|
if (mutated) {
|
||||||
|
// TODO: Refactor to use TestRepository.updateTestQuestion
|
||||||
await _db.testDao.updateTestQuestion(
|
await _db.testDao.updateTestQuestion(
|
||||||
q.copyWith(
|
q.copyWith(
|
||||||
options: jsonEncode(normalizedButtons),
|
options: jsonEncode(normalizedButtons),
|
||||||
|
|
@ -402,7 +403,7 @@ class AdminPacksApiV2 {
|
||||||
final previewCardIds = previewCards.map((c) => c.id).toList();
|
final previewCardIds = previewCards.map((c) => c.id).toList();
|
||||||
|
|
||||||
// Get tests for this pack
|
// Get tests for this pack
|
||||||
final packTests = await _db.testDao.getTestsByPackId(packId);
|
final packTests = await _testRepository.getTestsByPackId(packId);
|
||||||
final testIds = packTests.map((t) => t.id).toList();
|
final testIds = packTests.map((t) => t.id).toList();
|
||||||
|
|
||||||
// Create EditCardPackDto
|
// Create EditCardPackDto
|
||||||
|
|
@ -596,7 +597,7 @@ class AdminPacksApiV2 {
|
||||||
try {
|
try {
|
||||||
for (final testId in editDto.addTestIds!) {
|
for (final testId in editDto.addTestIds!) {
|
||||||
// Verify test exists before adding
|
// Verify test exists before adding
|
||||||
final test = await _db.testDao.getTestById(testId);
|
final test = await _testRepository.getTestById(testId);
|
||||||
if (test == null) {
|
if (test == null) {
|
||||||
return _json({
|
return _json({
|
||||||
'error': 'Test not found',
|
'error': 'Test not found',
|
||||||
|
|
@ -607,7 +608,7 @@ class AdminPacksApiV2 {
|
||||||
'Test with ID "$testId" was not found. Please verify all test IDs before adding them to the pack.',
|
'Test with ID "$testId" was not found. Please verify all test IDs before adding them to the pack.',
|
||||||
}, statusCode: 404);
|
}, statusCode: 404);
|
||||||
}
|
}
|
||||||
await _db.testDao.linkTestToPack(testId, packId);
|
await _testRepository.linkTestToPack(testId, packId);
|
||||||
// Important: tests may reference cardIds (or even base64) in
|
// Important: tests may reference cardIds (or even base64) in
|
||||||
// question/button images. Ensure those cards are linked to this pack
|
// question/button images. Ensure those cards are linked to this pack
|
||||||
// so `/api/v2/packs/<packId>/cards/<cardId>/image` works.
|
// so `/api/v2/packs/<packId>/cards/<cardId>/image` works.
|
||||||
|
|
@ -631,7 +632,7 @@ class AdminPacksApiV2 {
|
||||||
|
|
||||||
if (editDto.removeTestIds != null && editDto.removeTestIds!.isNotEmpty) {
|
if (editDto.removeTestIds != null && editDto.removeTestIds!.isNotEmpty) {
|
||||||
for (final testId in editDto.removeTestIds!) {
|
for (final testId in editDto.removeTestIds!) {
|
||||||
await _db.testDao.unlinkTestFromPack(testId, packId);
|
await _testRepository.unlinkTestFromPack(testId, packId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -661,7 +662,7 @@ class AdminPacksApiV2 {
|
||||||
final previewCardIds = previewCards.map((c) => c.id).toList();
|
final previewCardIds = previewCards.map((c) => c.id).toList();
|
||||||
|
|
||||||
// Get updated tests for this pack
|
// Get updated tests for this pack
|
||||||
final updatedPackTests = await _db.testDao.getTestsByPackId(packId);
|
final updatedPackTests = await _testRepository.getTestsByPackId(packId);
|
||||||
final updatedTestIds = updatedPackTests.map((t) => t.id).toList();
|
final updatedTestIds = updatedPackTests.map((t) => t.id).toList();
|
||||||
|
|
||||||
final updatedDto = EditCardPackDto(
|
final updatedDto = EditCardPackDto(
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@ import 'package:mnemo_cards_backend/api/authorize/helpers.dart';
|
||||||
import 'package:mnemo_cards_backend/database/database.dart';
|
import 'package:mnemo_cards_backend/database/database.dart';
|
||||||
import 'package:mnemo_cards_backend/user/user_model.dart'
|
import 'package:mnemo_cards_backend/user/user_model.dart'
|
||||||
show UserModelExtension;
|
show UserModelExtension;
|
||||||
import 'package:mnemo_cards_backend/user/user_repository.dart';
|
import 'package:mnemo_cards_backend/user/user_data_model.dart';
|
||||||
|
import 'package:mnemo_cards_backend/repository/export.dart';
|
||||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||||
import 'package:shelf/shelf.dart';
|
import 'package:shelf/shelf.dart';
|
||||||
|
|
@ -25,6 +26,30 @@ class AdminUsersApiV2 {
|
||||||
|
|
||||||
AdminUsersApiV2(this._db, this._userRepository);
|
AdminUsersApiV2(this._db, this._userRepository);
|
||||||
|
|
||||||
|
/// Вспомогательный метод для загрузки userData и subscription
|
||||||
|
Future<(UserDataModel?, UserSubscriptionModel?)> _loadUserDataAndSubscription(
|
||||||
|
String? userId,
|
||||||
|
) async {
|
||||||
|
if (userId == null) return (null, null);
|
||||||
|
|
||||||
|
final userData = await _userRepository.getUserDataById(userId);
|
||||||
|
final subscription = await _userRepository.getUserSubscription(userId);
|
||||||
|
|
||||||
|
final userDataModel = userData != null
|
||||||
|
? UserDataModel(
|
||||||
|
id: userData.id,
|
||||||
|
lastTestSessionToken: userData.lastTestSessionToken,
|
||||||
|
lastTimeOnline: userData.lastTimeOnline?.toDateTime(),
|
||||||
|
tags: userData.tags,
|
||||||
|
totalStudyTimeMinutes: userData.totalStudyTimeMinutes,
|
||||||
|
currentStreak: userData.currentStreak,
|
||||||
|
longestStreak: userData.longestStreak,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (userDataModel, subscription);
|
||||||
|
}
|
||||||
|
|
||||||
Response _json(
|
Response _json(
|
||||||
Object? data, {
|
Object? data, {
|
||||||
int statusCode = 200,
|
int statusCode = 200,
|
||||||
|
|
@ -96,7 +121,13 @@ class AdminUsersApiV2 {
|
||||||
// Convert to DTOs
|
// Convert to DTOs
|
||||||
final userDtos = <Map<String, dynamic>>[];
|
final userDtos = <Map<String, dynamic>>[];
|
||||||
for (final userModel in userModels) {
|
for (final userModel in userModels) {
|
||||||
final dto = await userModel.toDto();
|
final (userDataModel, subscriptionModel) =
|
||||||
|
await _loadUserDataAndSubscription(userModel.id);
|
||||||
|
|
||||||
|
final dto = await userModel.toDto(
|
||||||
|
userData: userDataModel,
|
||||||
|
subscriptionModel: subscriptionModel,
|
||||||
|
);
|
||||||
userDtos.add(dto.toJson());
|
userDtos.add(dto.toJson());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -173,7 +204,13 @@ class AdminUsersApiV2 {
|
||||||
}, statusCode: 404);
|
}, statusCode: 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
final dto = await userModel.toDto();
|
final (userData, subscription) = await _loadUserDataAndSubscription(
|
||||||
|
userModel.id,
|
||||||
|
);
|
||||||
|
final dto = await userModel.toDto(
|
||||||
|
userData: userData,
|
||||||
|
subscriptionModel: subscription,
|
||||||
|
);
|
||||||
|
|
||||||
return _json(dto.toJson());
|
return _json(dto.toJson());
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
|
|
@ -272,7 +309,13 @@ class AdminUsersApiV2 {
|
||||||
}, statusCode: 500);
|
}, statusCode: 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
final dto = await updatedUserModel.toDto();
|
final (userData, subscription) = await _loadUserDataAndSubscription(
|
||||||
|
updatedUserModel.id,
|
||||||
|
);
|
||||||
|
final dto = await updatedUserModel.toDto(
|
||||||
|
userData: userData,
|
||||||
|
subscriptionModel: subscription,
|
||||||
|
);
|
||||||
|
|
||||||
return _json({'result': true, 'user': dto.toJson()});
|
return _json({'result': true, 'user': dto.toJson()});
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -330,7 +373,13 @@ class AdminUsersApiV2 {
|
||||||
}, statusCode: 500);
|
}, statusCode: 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
final dto = await createdUserModel.toDto();
|
final (userData, subscription) = await _loadUserDataAndSubscription(
|
||||||
|
createdUserModel.id,
|
||||||
|
);
|
||||||
|
final dto = await createdUserModel.toDto(
|
||||||
|
userData: userData,
|
||||||
|
subscriptionModel: subscription,
|
||||||
|
);
|
||||||
|
|
||||||
return _json({'result': true, 'user': dto.toJson()});
|
return _json({'result': true, 'user': dto.toJson()});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,10 @@ import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
/// Extension для конвертации GameCard в GameCardDto для admin API
|
/// Extension для конвертации GameCard в GameCardDto для admin API
|
||||||
extension GameCardAdminExtension on GameCard {
|
extension GameCardAdminExtension on GameCard {
|
||||||
/// Конвертация GameCard в GameCardDto с учетом packId и конвертации изображений
|
/// Конвертация GameCard в GameCardDto с учетом packId и конвертации изображений
|
||||||
///
|
///
|
||||||
/// DEPRECATED: Use toGameCardDtoWithPresignedUrls instead
|
/// DEPRECATED: Use toGameCardDtoWithPresignedUrls instead
|
||||||
/// This method is kept for backwards compatibility
|
/// This method is kept for backwards compatibility
|
||||||
///
|
///
|
||||||
/// image/imageBack always contains UUID (objectId in MinIO)
|
/// image/imageBack always contains UUID (objectId in MinIO)
|
||||||
/// imageUrl/imageBackUrl will contain URLs from convertImageToUrl
|
/// imageUrl/imageBackUrl will contain URLs from convertImageToUrl
|
||||||
GameCardDto toGameCardDtoWithPack(
|
GameCardDto toGameCardDtoWithPack(
|
||||||
|
|
@ -35,7 +35,7 @@ extension GameCardAdminExtension on GameCard {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Асинхронная конвертация GameCard в GameCardDto с генерацией presigned URLs
|
/// Асинхронная конвертация GameCard в GameCardDto с генерацией presigned URLs
|
||||||
///
|
///
|
||||||
/// image/imageBack always contains UUID (objectId in MinIO)
|
/// image/imageBack always contains UUID (objectId in MinIO)
|
||||||
/// imageUrl/imageBackUrl will contain presigned URLs
|
/// imageUrl/imageBackUrl will contain presigned URLs
|
||||||
Future<GameCardDto> toGameCardDtoWithPresignedUrls(
|
Future<GameCardDto> toGameCardDtoWithPresignedUrls(
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import 'package:mnemo_cards_backend/database/database.dart'
|
||||||
import 'package:mnemo_cards_backend/packs/card_image_storage.dart';
|
import 'package:mnemo_cards_backend/packs/card_image_storage.dart';
|
||||||
import 'package:mnemo_cards_backend/packs/card_model_extension.dart';
|
import 'package:mnemo_cards_backend/packs/card_model_extension.dart';
|
||||||
import 'package:mnemo_cards_backend/packs/pack_manager.dart' show PackManager;
|
import 'package:mnemo_cards_backend/packs/pack_manager.dart' show PackManager;
|
||||||
import 'package:mnemo_cards_backend/packs/pack_repository.dart';
|
import 'package:mnemo_cards_backend/repository/export.dart';
|
||||||
import 'package:mnemo_cards_backend/packs/product_availability_manager.dart';
|
import 'package:mnemo_cards_backend/packs/product_availability_manager.dart';
|
||||||
import 'package:mnemo_cards_backend/packs/voice_storage.dart';
|
import 'package:mnemo_cards_backend/packs/voice_storage.dart';
|
||||||
import 'package:mnemo_cards_backend/storage/minio_config.dart';
|
import 'package:mnemo_cards_backend/storage/minio_config.dart';
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import 'package:mnemo_cards_backend/api/purchase/payment_manager.dart';
|
||||||
import 'package:mnemo_cards_backend/database/database.dart';
|
import 'package:mnemo_cards_backend/database/database.dart';
|
||||||
import 'package:mnemo_cards_backend/packs/pack_manager.dart';
|
import 'package:mnemo_cards_backend/packs/pack_manager.dart';
|
||||||
import 'package:mnemo_cards_backend/packs/product_availability_manager.dart';
|
import 'package:mnemo_cards_backend/packs/product_availability_manager.dart';
|
||||||
import 'package:mnemo_cards_backend/user/user_repository.dart';
|
import 'package:mnemo_cards_backend/repository/export.dart';
|
||||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
import 'package:shelf/shelf.dart';
|
import 'package:shelf/shelf.dart';
|
||||||
|
|
@ -23,6 +23,8 @@ class PurchasesApiV2 {
|
||||||
final PaymentManager _paymentManager;
|
final PaymentManager _paymentManager;
|
||||||
final PackManager _packManager;
|
final PackManager _packManager;
|
||||||
final UserRepository _userRepository;
|
final UserRepository _userRepository;
|
||||||
|
final SubscriptionRepository _subscriptionRepository;
|
||||||
|
final PaymentRepository _paymentRepository;
|
||||||
final ProductAvailabilityManager _productAvailabilityManager;
|
final ProductAvailabilityManager _productAvailabilityManager;
|
||||||
final AppDatabase _db;
|
final AppDatabase _db;
|
||||||
|
|
||||||
|
|
@ -30,6 +32,8 @@ class PurchasesApiV2 {
|
||||||
this._paymentManager,
|
this._paymentManager,
|
||||||
this._packManager,
|
this._packManager,
|
||||||
this._userRepository,
|
this._userRepository,
|
||||||
|
this._subscriptionRepository,
|
||||||
|
this._paymentRepository,
|
||||||
this._productAvailabilityManager,
|
this._productAvailabilityManager,
|
||||||
this._db,
|
this._db,
|
||||||
);
|
);
|
||||||
|
|
@ -65,10 +69,7 @@ class PurchasesApiV2 {
|
||||||
|
|
||||||
Response _forbidden(String message) => Response(
|
Response _forbidden(String message) => Response(
|
||||||
403,
|
403,
|
||||||
body: jsonEncode({
|
body: jsonEncode({'error': 'Forbidden', 'message': message}),
|
||||||
'error': 'Forbidden',
|
|
||||||
'message': message,
|
|
||||||
}),
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -250,15 +251,15 @@ class PurchasesApiV2 {
|
||||||
];
|
];
|
||||||
} else if (productType == MnemoCardsProductType.subscription) {
|
} else if (productType == MnemoCardsProductType.subscription) {
|
||||||
// Get subscription plan
|
// Get subscription plan
|
||||||
final planDrift = await _db.subscriptionDao.getPlanById(productId);
|
final planDrift = await _subscriptionRepository.getPlanById(productId);
|
||||||
if (planDrift == null) {
|
if (planDrift == null) {
|
||||||
return _notFound('Subscription plan not found');
|
return _notFound('Subscription plan not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to SubscriptionPlanModel
|
// Convert to SubscriptionPlanModel
|
||||||
final uiMap = planDrift.ui;
|
final uiMap = planDrift.ui;
|
||||||
final ui = uiMap is Map<String, dynamic>
|
final ui = uiMap != null
|
||||||
? SubscriptionPlanUI.fromJson(uiMap)
|
? SubscriptionPlanUI.fromJson(uiMap as Map<String, dynamic>)
|
||||||
: null;
|
: null;
|
||||||
final plan = SubscriptionPlanModel(
|
final plan = SubscriptionPlanModel(
|
||||||
id: planDrift.id,
|
id: planDrift.id,
|
||||||
|
|
@ -344,9 +345,9 @@ class PurchasesApiV2 {
|
||||||
if (paymentId == 'latest' && user.id != null) {
|
if (paymentId == 'latest' && user.id != null) {
|
||||||
final latestPayment = await _paymentManager
|
final latestPayment = await _paymentManager
|
||||||
.getLatestPaymentForUserAndPack(
|
.getLatestPaymentForUserAndPack(
|
||||||
userId: user.id!,
|
userId: user.id!,
|
||||||
packId: productId,
|
packId: productId,
|
||||||
);
|
);
|
||||||
if (latestPayment == null || latestPayment.externalToken == null) {
|
if (latestPayment == null || latestPayment.externalToken == null) {
|
||||||
return _notFound('No payment found for this pack');
|
return _notFound('No payment found for this pack');
|
||||||
}
|
}
|
||||||
|
|
@ -354,8 +355,9 @@ class PurchasesApiV2 {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check payment status
|
// Check payment status
|
||||||
final isSuccess =
|
final isSuccess = await _paymentManager.checkYookassaPayment(
|
||||||
await _paymentManager.checkYookassaPayment(actualPaymentId);
|
actualPaymentId,
|
||||||
|
);
|
||||||
|
|
||||||
// Get product information
|
// Get product information
|
||||||
MnemoCardsProductDto? product;
|
MnemoCardsProductDto? product;
|
||||||
|
|
@ -411,7 +413,10 @@ class PurchasesApiV2 {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check direct purchase access separately
|
// Check direct purchase access separately
|
||||||
final hasDirectAccess = await _db.userDao.hasPackAccess(user.id!, packId);
|
final hasDirectAccess = await _userRepository.hasPackAccess(
|
||||||
|
user.id!,
|
||||||
|
packId,
|
||||||
|
);
|
||||||
|
|
||||||
// Check subscription access separately
|
// Check subscription access separately
|
||||||
final activeSubscription = await _db.subscriptionDao
|
final activeSubscription = await _db.subscriptionDao
|
||||||
|
|
@ -458,20 +463,27 @@ class PurchasesApiV2 {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Find payment by external token (YooKassa payment ID)
|
// Find payment by external token (YooKassa payment ID)
|
||||||
final payment = await _db.paymentDao.getPaymentByExternalToken(paymentId);
|
final payment = await _paymentRepository.getPaymentByExternalToken(
|
||||||
|
paymentId,
|
||||||
|
);
|
||||||
|
|
||||||
if (payment == null) {
|
if (payment == null) {
|
||||||
developer.log(
|
developer.log('Payment not found: $paymentId', name: 'PurchasesApiV2');
|
||||||
'Payment not found: $paymentId',
|
return _notFound('Payment not found');
|
||||||
name: 'PurchasesApiV2',
|
}
|
||||||
);
|
|
||||||
|
// Get Payment from DB to check userId
|
||||||
|
final paymentDrift = await _db.paymentDao.getPaymentByExternalToken(
|
||||||
|
paymentId,
|
||||||
|
);
|
||||||
|
if (paymentDrift == null) {
|
||||||
return _notFound('Payment not found');
|
return _notFound('Payment not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify payment belongs to current user
|
// Verify payment belongs to current user
|
||||||
if (payment.userId != user.id) {
|
if (paymentDrift.userId != user.id) {
|
||||||
developer.log(
|
developer.log(
|
||||||
'Payment access denied: paymentId=$paymentId, ownerId=${payment.userId}, requesterId=${user.id}',
|
'Payment access denied: paymentId=$paymentId, ownerId=${paymentDrift.userId}, requesterId=${user.id}',
|
||||||
name: 'PurchasesApiV2',
|
name: 'PurchasesApiV2',
|
||||||
);
|
);
|
||||||
return _forbidden('Access denied');
|
return _forbidden('Access denied');
|
||||||
|
|
@ -483,14 +495,15 @@ class PurchasesApiV2 {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Force check and process payment
|
// Force check and process payment
|
||||||
await _paymentManager.checkAndProcessPayment(payment);
|
await _paymentManager.checkAndProcessPayment(paymentDrift);
|
||||||
|
|
||||||
// Get updated payment status
|
// Get updated payment status
|
||||||
final updatedPayment =
|
final updatedPayment = await _paymentRepository.getPaymentByExternalToken(
|
||||||
await _db.paymentDao.getPaymentByExternalToken(paymentId);
|
paymentId,
|
||||||
|
);
|
||||||
|
|
||||||
final finalStatus = updatedPayment?.status ?? payment.status;
|
final finalStatus = updatedPayment?.status ?? payment.status;
|
||||||
|
|
||||||
developer.log(
|
developer.log(
|
||||||
'Payment check completed: paymentId=$paymentId, finalStatus=$finalStatus',
|
'Payment check completed: paymentId=$paymentId, finalStatus=$finalStatus',
|
||||||
name: 'PurchasesApiV2',
|
name: 'PurchasesApiV2',
|
||||||
|
|
|
||||||
|
|
@ -20,5 +20,10 @@ Router _$PurchasesApiV2Router(PurchasesApiV2 service) {
|
||||||
r'/purchases/packs/<packId>/status',
|
r'/purchases/packs/<packId>/status',
|
||||||
service.getPackPurchaseStatus,
|
service.getPackPurchaseStatus,
|
||||||
);
|
);
|
||||||
|
router.add(
|
||||||
|
'POST',
|
||||||
|
r'/purchases/check-payment/<paymentId>',
|
||||||
|
service.checkPayment,
|
||||||
|
);
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import 'package:drift/drift.dart';
|
||||||
import 'package:drift_postgres/drift_postgres.dart';
|
import 'package:drift_postgres/drift_postgres.dart';
|
||||||
import 'package:injectable/injectable.dart';
|
import 'package:injectable/injectable.dart';
|
||||||
import 'package:mnemo_cards_backend/database/database.dart';
|
import 'package:mnemo_cards_backend/database/database.dart';
|
||||||
import 'package:mnemo_cards_backend/user/user_repository.dart';
|
import 'package:mnemo_cards_backend/repository/export.dart';
|
||||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||||
import 'package:shelf/shelf.dart';
|
import 'package:shelf/shelf.dart';
|
||||||
import 'package:shelf_open_api/shelf_open_api.dart';
|
import 'package:shelf_open_api/shelf_open_api.dart';
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,9 @@ import 'package:mnemo_cards_backend/database/database.dart';
|
||||||
import 'package:mnemo_cards_backend/statistics/statistics_calculator.dart';
|
import 'package:mnemo_cards_backend/statistics/statistics_calculator.dart';
|
||||||
import 'package:mnemo_cards_backend/user/user_manager.dart';
|
import 'package:mnemo_cards_backend/user/user_manager.dart';
|
||||||
import 'package:mnemo_cards_backend/user/user_model.dart';
|
import 'package:mnemo_cards_backend/user/user_model.dart';
|
||||||
import 'package:mnemo_cards_backend/user/user_repository.dart';
|
import 'package:mnemo_cards_backend/repository/export.dart';
|
||||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
|
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||||
import 'package:shelf/shelf.dart';
|
import 'package:shelf/shelf.dart';
|
||||||
import 'package:shelf_router/shelf_router.dart';
|
import 'package:shelf_router/shelf_router.dart';
|
||||||
|
|
||||||
|
|
@ -99,11 +100,30 @@ class UsersApiV2 {
|
||||||
|
|
||||||
final wordsDto = AllWordsStatisticsDto(words: wordsList);
|
final wordsDto = AllWordsStatisticsDto(words: wordsList);
|
||||||
|
|
||||||
|
// Load userData and subscription
|
||||||
|
final userData = await _userRepository.getUserDataById(user.id!);
|
||||||
|
final subscription = await _userRepository.getUserSubscription(user.id!);
|
||||||
|
|
||||||
|
// Convert UserData to UserDataModel if exists
|
||||||
|
final userDataModel = userData != null
|
||||||
|
? UserDataModel(
|
||||||
|
id: userData.id,
|
||||||
|
lastTestSessionToken: userData.lastTestSessionToken,
|
||||||
|
lastTimeOnline: userData.lastTimeOnline?.toDateTime(),
|
||||||
|
tags: userData.tags,
|
||||||
|
totalStudyTimeMinutes: userData.totalStudyTimeMinutes,
|
||||||
|
currentStreak: userData.currentStreak,
|
||||||
|
longestStreak: userData.longestStreak,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
final dto = await user.toDtoWithCalculatedData(
|
final dto = await user.toDtoWithCalculatedData(
|
||||||
packProgress: packProgress,
|
packProgress: packProgress,
|
||||||
studyDates: studyDates,
|
studyDates: studyDates,
|
||||||
categoryMinutes: categoryMinutes,
|
categoryMinutes: categoryMinutes,
|
||||||
wordsStatistics: wordsDto,
|
wordsStatistics: wordsDto,
|
||||||
|
userData: userDataModel,
|
||||||
|
subscriptionModel: subscription,
|
||||||
);
|
);
|
||||||
return _json(dto.toJson());
|
return _json(dto.toJson());
|
||||||
}
|
}
|
||||||
|
|
@ -155,7 +175,28 @@ class UsersApiV2 {
|
||||||
);
|
);
|
||||||
|
|
||||||
final refreshedUser = (await _userManager.fetchUser(user.id!))!;
|
final refreshedUser = (await _userManager.fetchUser(user.id!))!;
|
||||||
final dto = await refreshedUser.toDto();
|
|
||||||
|
// Load userData and subscription
|
||||||
|
final userData = await _userRepository.getUserDataById(user.id!);
|
||||||
|
final subscription = await _userRepository.getUserSubscription(user.id!);
|
||||||
|
|
||||||
|
// Convert UserData to UserDataModel if exists
|
||||||
|
final userDataModel = userData != null
|
||||||
|
? UserDataModel(
|
||||||
|
id: userData.id,
|
||||||
|
lastTestSessionToken: userData.lastTestSessionToken,
|
||||||
|
lastTimeOnline: userData.lastTimeOnline?.toDateTime(),
|
||||||
|
tags: userData.tags,
|
||||||
|
totalStudyTimeMinutes: userData.totalStudyTimeMinutes,
|
||||||
|
currentStreak: userData.currentStreak,
|
||||||
|
longestStreak: userData.longestStreak,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
final dto = await refreshedUser.toDto(
|
||||||
|
userData: userDataModel,
|
||||||
|
subscriptionModel: subscription,
|
||||||
|
);
|
||||||
return _json(dto.toJson());
|
return _json(dto.toJson());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -252,20 +293,11 @@ class UsersApiV2 {
|
||||||
return _json({'error': 'user_id_not_found'}, statusCode: 400);
|
return _json({'error': 'user_id_not_found'}, statusCode: 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
final userData = await _userRepository.getUserData(user.id!);
|
final userData = await _userRepository.getUserDataById(user.id!);
|
||||||
if (userData == null) {
|
if (userData == null) {
|
||||||
return _json({'error': 'user_data_not_found'}, statusCode: 404);
|
return _json({'error': 'user_data_not_found'}, statusCode: 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert UserData to UserDataDto
|
|
||||||
final userModel = await _userRepository.getUserById(
|
|
||||||
user.id!,
|
|
||||||
withData: true,
|
|
||||||
);
|
|
||||||
if (userModel?.userData == null) {
|
|
||||||
return _json({'error': 'user_data_not_found'}, statusCode: 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Рассчитать studyDates на лету из StudySessions
|
// Рассчитать studyDates на лету из StudySessions
|
||||||
final studyDates = await _statisticsCalculator.calculateStudyDates(
|
final studyDates = await _statisticsCalculator.calculateStudyDates(
|
||||||
user.id!,
|
user.id!,
|
||||||
|
|
@ -297,7 +329,7 @@ class UsersApiV2 {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final packId = request.url.queryParameters['packId'];
|
final packId = request.url.queryParameters['packId'];
|
||||||
|
|
||||||
// Рассчитать packProgress на лету из WordStatistics
|
// Рассчитать packProgress на лету из WordStatistics
|
||||||
final allPackProgress = await _statisticsCalculator
|
final allPackProgress = await _statisticsCalculator
|
||||||
.calculateAllPackProgress(user.id!);
|
.calculateAllPackProgress(user.id!);
|
||||||
|
|
@ -345,27 +377,27 @@ class UsersApiV2 {
|
||||||
final validatedLimit = limit.clamp(1, 100);
|
final validatedLimit = limit.clamp(1, 100);
|
||||||
final validatedOffset = offset < 0 ? 0 : offset;
|
final validatedOffset = offset < 0 ? 0 : offset;
|
||||||
|
|
||||||
final userData = user.userData;
|
// Get words statistics from database
|
||||||
if (userData == null) {
|
final wordStats = await _db.wordStatisticsDao.getUserStatistics(user.id!);
|
||||||
return _json({
|
final wordsList = <DetailedWordStatisticsDto>[];
|
||||||
'words': [],
|
|
||||||
'totalCount': 0,
|
// For each statistic, get the card and extract word data
|
||||||
'page': 0,
|
for (final stat in wordStats) {
|
||||||
'pageSize': validatedLimit,
|
final card = await _db.packDao.getCardById(stat.cardId);
|
||||||
'hasMore': false,
|
if (card != null) {
|
||||||
});
|
wordsList.add(
|
||||||
|
DetailedWordStatisticsDto(
|
||||||
|
word: card.original,
|
||||||
|
correct: stat.correctAnswers.toDouble(),
|
||||||
|
incorrect: stat.incorrectAnswers.toDouble(),
|
||||||
|
skipped: 0,
|
||||||
|
questionTypes: {},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get words with filtering
|
var words = wordsList;
|
||||||
var words = userData.words.map((model) {
|
|
||||||
return DetailedWordStatisticsDto(
|
|
||||||
word: model.word,
|
|
||||||
correct: model.correct.toDouble(),
|
|
||||||
incorrect: model.incorrect.toDouble(),
|
|
||||||
skipped: model.skipped.toDouble(),
|
|
||||||
questionTypes: model.questionTypes.toSet(),
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
// Apply pack filter if specified
|
// Apply pack filter if specified
|
||||||
if (packId != null) {
|
if (packId != null) {
|
||||||
|
|
@ -552,13 +584,26 @@ class UsersApiV2 {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final userData = user.userData;
|
final userData = await _userRepository.getUserDataById(user.id!);
|
||||||
if (userData == null) {
|
if (userData == null) {
|
||||||
return _json([]);
|
return _json([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
final achievements = _statisticsCalculator.calculateAchievemegntProgress(
|
// Convert UserData to UserDataModel
|
||||||
userData,
|
final userDataModel = UserDataModel(
|
||||||
|
id: userData.id,
|
||||||
|
lastTestSessionToken: userData.lastTestSessionToken,
|
||||||
|
lastTimeOnline: userData.lastTimeOnline?.toDateTime(),
|
||||||
|
tags: userData.tags,
|
||||||
|
totalStudyTimeMinutes: userData.totalStudyTimeMinutes,
|
||||||
|
currentStreak: userData.currentStreak,
|
||||||
|
longestStreak: userData.longestStreak,
|
||||||
|
// words, packProgress, studyDates, categoryMinutes, achievements
|
||||||
|
// are loaded separately from database when needed
|
||||||
|
);
|
||||||
|
|
||||||
|
final achievements = _statisticsCalculator.calculateAchievementProgress(
|
||||||
|
userDataModel,
|
||||||
);
|
);
|
||||||
return _json(achievements.map((a) => a.toJson()).toList());
|
return _json(achievements.map((a) => a.toJson()).toList());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import 'package:mnemo_cards_backend/packs/free_packs_distributor.dart';
|
import 'package:mnemo_cards_backend/packs/free_packs_distributor.dart';
|
||||||
import 'package:mnemo_cards_backend/packs/product_availability_manager.dart';
|
import 'package:mnemo_cards_backend/packs/product_availability_manager.dart';
|
||||||
import 'package:mnemo_cards_backend/user/user_repository.dart';
|
import 'package:mnemo_cards_backend/repository/export.dart';
|
||||||
|
|
||||||
import 'task.dart' as task;
|
import 'task.dart' as task;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
import 'package:drift_postgres/drift_postgres.dart';
|
import 'package:drift_postgres/drift_postgres.dart';
|
||||||
import 'package:mnemo_cards_backend/user/admin_ids_service.dart';
|
import 'package:mnemo_cards_backend/user/admin_ids_service.dart';
|
||||||
import 'package:mnemo_cards_backend/user/user_repository.dart';
|
import 'package:mnemo_cards_backend/repository/export.dart';
|
||||||
|
|
||||||
import '../database/database.dart';
|
import '../database/database.dart';
|
||||||
import 'task.dart' as task;
|
import 'task.dart' as task;
|
||||||
|
|
|
||||||
|
|
@ -213,7 +213,9 @@ class PaymentDao extends DatabaseAccessor<AppDatabase>
|
||||||
required String packId,
|
required String packId,
|
||||||
}) async {
|
}) async {
|
||||||
final query = selectActive()
|
final query = selectActive()
|
||||||
..where((p) => p.userId.equals(userId) & p.products.like('%"id":"$packId"%'))
|
..where(
|
||||||
|
(p) => p.userId.equals(userId) & p.products.like('%"id":"$packId"%'),
|
||||||
|
)
|
||||||
..orderBy([(p) => OrderingTerm.desc(p.createdAt)])
|
..orderBy([(p) => OrderingTerm.desc(p.createdAt)])
|
||||||
..limit(1);
|
..limit(1);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,14 @@ class TaskDao extends DatabaseAccessor<AppDatabase> with _$TaskDaoMixin {
|
||||||
return await query.map((row) => row.read(countExpr)!).getSingle();
|
return await query.map((row) => row.read(countExpr)!).getSingle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Удалить задачу пользователя
|
||||||
|
Future<bool> deleteUserTask(String taskId) async {
|
||||||
|
final deleted = await (delete(
|
||||||
|
db.userTasks,
|
||||||
|
)..where((ut) => ut.id.equals(taskId))).go();
|
||||||
|
return deleted > 0;
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== UserTaskProgresses ====================
|
// ==================== UserTaskProgresses ====================
|
||||||
|
|
||||||
/// Получить прогресс задачи пользователя
|
/// Получить прогресс задачи пользователя
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
|
import 'package:mnemo_cards_backend/discounts/discount_drift_extension.dart';
|
||||||
import 'package:mnemo_cards_backend/extensions.dart';
|
import 'package:mnemo_cards_backend/extensions.dart';
|
||||||
|
import 'package:mnemo_cards_backend/repository/export.dart';
|
||||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
|
|
||||||
extension DiscountCampaignModelExt on DiscountCampaignModel {
|
extension DiscountCampaignModelExt on DiscountCampaignModel {
|
||||||
Future<DiscountCampaignDto> toDto() async {
|
Future<DiscountCampaignDto> toDto(
|
||||||
|
DiscountRepository discountRepository,
|
||||||
|
) async {
|
||||||
|
final discounts = await discountRepository.getDiscountsByCampaignId(id!);
|
||||||
return DiscountCampaignDto(
|
return DiscountCampaignDto(
|
||||||
id: id,
|
id: id,
|
||||||
start: this.start,
|
start: this.start,
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
import 'package:drift/drift.dart' as drift;
|
import 'package:drift/drift.dart' as drift;
|
||||||
import 'package:drift_postgres/drift_postgres.dart';
|
import 'package:drift_postgres/drift_postgres.dart';
|
||||||
import 'package:mnemo_cards_backend/database/daos/discount_dao.dart';
|
|
||||||
import 'package:mnemo_cards_backend/database/database.dart';
|
import 'package:mnemo_cards_backend/database/database.dart';
|
||||||
|
import 'package:mnemo_cards_backend/repository/export.dart';
|
||||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||||
|
|
||||||
/// Extension для конвертации DiscountCampaign (Drift) в DTO
|
/// Extension для конвертации DiscountCampaign (Drift) в DTO
|
||||||
extension DiscountCampaignToDto on DiscountCampaign {
|
extension DiscountCampaignToDto on DiscountCampaign {
|
||||||
Future<DiscountCampaignDto> toDto(DiscountDao discountDao) async {
|
Future<DiscountCampaignDto> toDto(
|
||||||
final discounts = await discountDao.getDiscountsByCampaignId(id);
|
DiscountRepository discountRepository,
|
||||||
|
) async {
|
||||||
|
final discounts = await discountRepository.getDiscountsByCampaignId(id);
|
||||||
final discountsDto = discounts.map((d) => d.toDto()).toList();
|
final discountsDto = discounts.map((d) => d.toDto()).toList();
|
||||||
|
|
||||||
DiscountCampaignStatus statusEnum;
|
DiscountCampaignStatus statusEnum;
|
||||||
|
|
@ -44,7 +46,7 @@ extension DiscountCampaignToDto on DiscountCampaign {
|
||||||
/// Extension для конвертации Discount (Drift) в DTO
|
/// Extension для конвертации Discount (Drift) в DTO
|
||||||
extension DiscountToDto on Discount {
|
extension DiscountToDto on Discount {
|
||||||
DiscountDto toDto() {
|
DiscountDto toDto() {
|
||||||
final productsList = (products ?? [])
|
final productsList = products
|
||||||
.map((p) {
|
.map((p) {
|
||||||
if (p is Map<String, dynamic>) {
|
if (p is Map<String, dynamic>) {
|
||||||
return MnemoCardsProductDto.fromJson(p);
|
return MnemoCardsProductDto.fromJson(p);
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,19 @@
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
|
|
||||||
import 'package:injectable/injectable.dart';
|
import 'package:injectable/injectable.dart';
|
||||||
import 'package:mnemo_cards_backend/database/daos/discount_dao.dart';
|
|
||||||
import 'package:mnemo_cards_backend/database/database.dart';
|
import 'package:mnemo_cards_backend/database/database.dart';
|
||||||
import 'package:mnemo_cards_backend/discounts/discount_drift_extension.dart';
|
import 'package:mnemo_cards_backend/discounts/discount_drift_extension.dart';
|
||||||
import 'package:mnemo_cards_backend/user/user_repository.dart';
|
import 'package:mnemo_cards_backend/repository/export.dart';
|
||||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart' hide DateTimeExt;
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart' hide DateTimeExt;
|
||||||
import 'package:mnemo_cards_common/src/utils/utils.dart';
|
import 'package:mnemo_cards_common/src/utils/utils.dart';
|
||||||
|
|
||||||
@lazySingleton
|
@lazySingleton
|
||||||
class DiscountsManager {
|
class DiscountsManager {
|
||||||
final AppDatabase _db;
|
final DiscountRepository _discountRepository;
|
||||||
final DiscountDao _discountDao;
|
|
||||||
final UserRepository _userRepository;
|
final UserRepository _userRepository;
|
||||||
|
|
||||||
DiscountsManager(this._db, this._userRepository)
|
DiscountsManager(this._discountRepository, this._userRepository);
|
||||||
: _discountDao = _db.discountDao;
|
|
||||||
|
|
||||||
Future<void> applyDiscount({
|
Future<void> applyDiscount({
|
||||||
required Iterable<DiscountModel> discounts,
|
required Iterable<DiscountModel> discounts,
|
||||||
|
|
@ -33,7 +30,7 @@ class DiscountsManager {
|
||||||
if (discountIds.isEmpty) return;
|
if (discountIds.isEmpty) return;
|
||||||
|
|
||||||
// Добавляем скидки пользователю через junction таблицу
|
// Добавляем скидки пользователю через junction таблицу
|
||||||
await _discountDao.grantDiscountsToUser(user.id!, discountIds);
|
await _discountRepository.grantDiscountsToUser(user.id!, discountIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> changeCampaignStatus(DiscountCampaign campaign) async {
|
Future<void> changeCampaignStatus(DiscountCampaign campaign) async {
|
||||||
|
|
@ -52,7 +49,7 @@ class DiscountsManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetStatus != null && targetStatus != campaign.status) {
|
if (targetStatus != null && targetStatus != campaign.status) {
|
||||||
await _discountDao.updateCampaignStatus(campaign.id, targetStatus);
|
await _discountRepository.updateCampaignStatus(campaign.id, targetStatus);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,13 +73,15 @@ class DiscountsManager {
|
||||||
if (user.id == null) return;
|
if (user.id == null) return;
|
||||||
|
|
||||||
// Получаем скидки кампании
|
// Получаем скидки кампании
|
||||||
final discounts = await _discountDao.getDiscountsByCampaignId(campaign.id);
|
final discounts = await _discountRepository.getDiscountsByCampaignId(
|
||||||
|
campaign.id,
|
||||||
|
);
|
||||||
final discountIds = discounts.map((d) => d.id).toList();
|
final discountIds = discounts.map((d) => d.id).toList();
|
||||||
|
|
||||||
if (discountIds.isEmpty) return;
|
if (discountIds.isEmpty) return;
|
||||||
|
|
||||||
// Удаляем скидки пользователя
|
// Удаляем скидки пользователя
|
||||||
await _discountDao.revokeDiscountsFromUser(user.id!, discountIds);
|
await _discountRepository.revokeDiscountsFromUser(user.id!, discountIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<double> getProductDiscount(
|
Future<double> getProductDiscount(
|
||||||
|
|
@ -92,7 +91,9 @@ class DiscountsManager {
|
||||||
double maxDiscount = 0;
|
double maxDiscount = 0;
|
||||||
|
|
||||||
// Получаем активные скидки пользователя из junction таблицы
|
// Получаем активные скидки пользователя из junction таблицы
|
||||||
final userDiscounts = await _discountDao.getUserDiscounts(userData.userId);
|
final userDiscounts = await _discountRepository.getUserDiscounts(
|
||||||
|
userData.userId,
|
||||||
|
);
|
||||||
for (final discount in userDiscounts) {
|
for (final discount in userDiscounts) {
|
||||||
final discountDto = discount.toDto();
|
final discountDto = discount.toDto();
|
||||||
if (discountDto.discountPercent > maxDiscount &&
|
if (discountDto.discountPercent > maxDiscount &&
|
||||||
|
|
@ -108,14 +109,14 @@ class DiscountsManager {
|
||||||
final userDataModel = await _userRepository.getUserData(userData.userId);
|
final userDataModel = await _userRepository.getUserData(userData.userId);
|
||||||
final userTags = userDataModel?.tags ?? [];
|
final userTags = userDataModel?.tags ?? [];
|
||||||
|
|
||||||
final activeCampaigns = await _discountDao.getActiveCampaignsForUser(
|
final activeCampaigns = await _discountRepository.getActiveCampaignsForUser(
|
||||||
userTags: userTags,
|
userTags: userTags,
|
||||||
productType: model.type.name,
|
productType: model.type.name,
|
||||||
productId: model.id.toString(),
|
productId: model.id.toString(),
|
||||||
);
|
);
|
||||||
|
|
||||||
for (final campaign in activeCampaigns) {
|
for (final campaign in activeCampaigns) {
|
||||||
final discounts = await _discountDao.getDiscountsByCampaignId(
|
final discounts = await _discountRepository.getDiscountsByCampaignId(
|
||||||
campaign.id,
|
campaign.id,
|
||||||
);
|
);
|
||||||
for (final discount in discounts) {
|
for (final discount in discounts) {
|
||||||
|
|
@ -135,7 +136,7 @@ class DiscountsManager {
|
||||||
MnemoCardsProductModel? product,
|
MnemoCardsProductModel? product,
|
||||||
}) async {
|
}) async {
|
||||||
final userTags = userData.tags;
|
final userTags = userData.tags;
|
||||||
return await _discountDao.getActiveCampaignsForUser(
|
return await _discountRepository.getActiveCampaignsForUser(
|
||||||
userTags: userTags,
|
userTags: userTags,
|
||||||
productType: product?.type.name,
|
productType: product?.type.name,
|
||||||
productId: product?.id.toString(),
|
productId: product?.id.toString(),
|
||||||
|
|
@ -143,23 +144,23 @@ class DiscountsManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<DiscountCampaignDto>> discountCampaigns() async {
|
Future<List<DiscountCampaignDto>> discountCampaigns() async {
|
||||||
final campaigns = await _discountDao.getAllCampaigns();
|
final campaigns = await _discountRepository.getAllCampaigns();
|
||||||
final dtos = await Future.wait(
|
final dtos = await Future.wait(
|
||||||
campaigns.map((c) async => await c.toDto(_discountDao)),
|
campaigns.map((c) async => await c.toDto(_discountRepository)),
|
||||||
);
|
);
|
||||||
return dtos;
|
return dtos;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> deleteDiscountCampaign(String id) async {
|
Future<String?> deleteDiscountCampaign(String id) async {
|
||||||
try {
|
try {
|
||||||
final campaign = await _discountDao.getCampaignById(id);
|
final campaign = await _discountRepository.getCampaignById(id);
|
||||||
if (campaign == null) {
|
if (campaign == null) {
|
||||||
return 'Campaign $id not found';
|
return 'Campaign $id not found';
|
||||||
}
|
}
|
||||||
if (campaign.status != 'disabled') {
|
if (campaign.status != 'disabled') {
|
||||||
return 'Disable campaign before deleting';
|
return 'Disable campaign before deleting';
|
||||||
}
|
}
|
||||||
await _discountDao.deleteCampaign(id);
|
await _discountRepository.deleteCampaign(id);
|
||||||
return null;
|
return null;
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
log('Error when deleting discount campaign', error: e, stackTrace: s);
|
log('Error when deleting discount campaign', error: e, stackTrace: s);
|
||||||
|
|
@ -169,22 +170,22 @@ class DiscountsManager {
|
||||||
|
|
||||||
Future<bool> addDiscount(DiscountCampaignDto dto) async {
|
Future<bool> addDiscount(DiscountCampaignDto dto) async {
|
||||||
try {
|
try {
|
||||||
return await _db.transaction(() async {
|
// Создаем кампанию
|
||||||
// Создаем кампанию
|
final campaignCompanion = dto.toCompanion();
|
||||||
final campaignCompanion = dto.toCompanion();
|
final campaignId = await _discountRepository.createCampaign(
|
||||||
final campaignId = await _discountDao.createCampaign(campaignCompanion);
|
campaignCompanion,
|
||||||
|
);
|
||||||
|
|
||||||
// Создаем скидки
|
// Создаем скидки
|
||||||
final discountCompanions = dto.discounts
|
final discountCompanions = dto.discounts
|
||||||
.map((discountDto) => discountDto.toCompanion(campaignId))
|
.map((discountDto) => discountDto.toCompanion(campaignId))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
for (final discountCompanion in discountCompanions) {
|
for (final discountCompanion in discountCompanions) {
|
||||||
await _discountDao.createDiscount(discountCompanion);
|
await _discountRepository.createDiscount(discountCompanion);
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
print('error when adding campaign');
|
print('error when adding campaign');
|
||||||
log('error when adding campaign', error: e, stackTrace: s);
|
log('error when adding campaign', error: e, stackTrace: s);
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import 'package:mnemo_cards_backend/discounts/discounts_manager.dart';
|
||||||
import 'package:mnemo_cards_backend/packs/product_availability_manager.dart';
|
import 'package:mnemo_cards_backend/packs/product_availability_manager.dart';
|
||||||
import 'package:mnemo_cards_backend/user/user_manager.dart';
|
import 'package:mnemo_cards_backend/user/user_manager.dart';
|
||||||
import 'package:mnemo_cards_backend/tests/test_manager.dart';
|
import 'package:mnemo_cards_backend/tests/test_manager.dart';
|
||||||
import 'package:mnemo_cards_backend/user/user_repository.dart';
|
import 'package:mnemo_cards_backend/repository/export.dart';
|
||||||
|
|
||||||
import 'api/di/injector.dart';
|
import 'api/di/injector.dart';
|
||||||
import 'cron/check_admins.dart';
|
import 'cron/check_admins.dart';
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import 'package:injectable/injectable.dart';
|
import 'package:injectable/injectable.dart';
|
||||||
import 'package:mnemo_cards_backend/packs/product_availability_manager.dart';
|
import 'package:mnemo_cards_backend/packs/product_availability_manager.dart';
|
||||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||||
import 'pack_repository.dart';
|
import '../repository/export.dart';
|
||||||
|
|
||||||
@lazySingleton
|
@lazySingleton
|
||||||
class FreePacksDistributor {
|
class FreePacksDistributor {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:injectable/injectable.dart';
|
import 'package:injectable/injectable.dart';
|
||||||
import 'package:mnemo_cards_backend/api/ads/ads_manager.dart';
|
import 'package:mnemo_cards_backend/api/ads/ads_manager.dart';
|
||||||
import 'package:mnemo_cards_backend/extensions.dart';
|
import 'package:mnemo_cards_backend/extensions.dart';
|
||||||
|
import 'package:mnemo_cards_backend/repository/export.dart';
|
||||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||||
import 'package:mnemo_cards_backend/packs/card_model_extension.dart';
|
import 'package:mnemo_cards_backend/packs/card_model_extension.dart';
|
||||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
|
|
@ -13,8 +14,13 @@ bool canOpenForAd(String? id) => id != null;
|
||||||
class PackDtoConverter {
|
class PackDtoConverter {
|
||||||
final ProductsPriceResolver _productsPriceResolver;
|
final ProductsPriceResolver _productsPriceResolver;
|
||||||
final AdsManager _adsManager;
|
final AdsManager _adsManager;
|
||||||
|
final UserRepository _userRepository;
|
||||||
|
|
||||||
const PackDtoConverter(this._productsPriceResolver, this._adsManager);
|
PackDtoConverter(
|
||||||
|
this._productsPriceResolver,
|
||||||
|
this._adsManager,
|
||||||
|
this._userRepository,
|
||||||
|
);
|
||||||
|
|
||||||
Future<CardPackPreviewDto> toCardPackPreviewDto(
|
Future<CardPackPreviewDto> toCardPackPreviewDto(
|
||||||
CardPackModel model,
|
CardPackModel model,
|
||||||
|
|
@ -23,12 +29,14 @@ class PackDtoConverter {
|
||||||
bool available =
|
bool available =
|
||||||
userModel != null &&
|
userModel != null &&
|
||||||
(userModel.packs.contains(model.id?.toString()) || userModel.admin);
|
(userModel.packs.contains(model.id?.toString()) || userModel.admin);
|
||||||
if (userModel != null && !available) {
|
if (userModel != null && !available && userModel.id != null) {
|
||||||
|
final subscription = await _userRepository.getUserSubscription(
|
||||||
|
userModel.id!,
|
||||||
|
);
|
||||||
available =
|
available =
|
||||||
userModel.subscriptionModel?.features.contains(
|
subscription != null &&
|
||||||
SubscriptionFeatureEnum.packs,
|
subscription.isActive &&
|
||||||
) ==
|
subscription.features.contains(SubscriptionFeatureEnum.packs);
|
||||||
true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String? price = model.price;
|
String? price = model.price;
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,15 @@ import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'
|
||||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
import 'pack_dto_converter.dart';
|
import 'pack_dto_converter.dart';
|
||||||
import 'card_pack_drift_extension.dart';
|
import 'card_pack_drift_extension.dart';
|
||||||
import 'pack_repository.dart';
|
import '../repository/export.dart';
|
||||||
|
|
||||||
@lazySingleton
|
@lazySingleton
|
||||||
class PackManager {
|
class PackManager {
|
||||||
final AppDatabase _db;
|
|
||||||
final PackRepository _packRepository;
|
final PackRepository _packRepository;
|
||||||
final PackDtoConverter packDtoConverter;
|
final PackDtoConverter packDtoConverter;
|
||||||
|
final AppDatabase _db;
|
||||||
|
|
||||||
PackManager(this._db, this._packRepository, this.packDtoConverter);
|
PackManager(this._packRepository, this.packDtoConverter, this._db);
|
||||||
|
|
||||||
Future<List<CardPackPreviewDto>> listPacksPreviews(
|
Future<List<CardPackPreviewDto>> listPacksPreviews(
|
||||||
UserModel? userModel,
|
UserModel? userModel,
|
||||||
|
|
@ -29,6 +29,7 @@ class PackManager {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Convert to Drift models for toPreviewDto (temporary, until we refactor DTO conversion)
|
// Convert to Drift models for toPreviewDto (temporary, until we refactor DTO conversion)
|
||||||
|
// TODO: Refactor to use PackRepository and remove direct DAO access
|
||||||
final packs = await Future.wait(
|
final packs = await Future.wait(
|
||||||
packModels.map((packModel) async {
|
packModels.map((packModel) async {
|
||||||
final pack = await _db.packDao.getPackById(packModel.id!);
|
final pack = await _db.packDao.getPackById(packModel.id!);
|
||||||
|
|
@ -63,11 +64,11 @@ class PackManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<VoiceModel?> getVoice(String id) async {
|
Future<VoiceModel?> getVoice(String id) async {
|
||||||
return await _db.packDao.getVoiceById(id);
|
return await _packRepository.getVoiceById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<VoiceModel>> getVoices(String cardId) async {
|
Future<List<VoiceModel>> getVoices(String cardId) async {
|
||||||
return await _db.packDao.getCardVoices(cardId);
|
return await _packRepository.getCardVoices(cardId);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<CardPackDto> getPackDto(String id, UserModel? userModel) async {
|
Future<CardPackDto> getPackDto(String id, UserModel? userModel) async {
|
||||||
|
|
@ -77,19 +78,21 @@ class PackManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get pack from DAO for DTO conversion (temporary, until we refactor)
|
// Get pack from DAO for DTO conversion (temporary, until we refactor)
|
||||||
|
// TODO: Refactor to use PackRepository and remove direct DAO access
|
||||||
final pack = await _db.packDao.getPackById(id);
|
final pack = await _db.packDao.getPackById(id);
|
||||||
if (pack == null) {
|
if (pack == null) {
|
||||||
throw StateError('Pack not found');
|
throw StateError('Pack not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
final cards = await _db.packDao.getPackCards(id);
|
// TODO: Refactor to use PackRepository and remove direct DAO access
|
||||||
|
final cardsDrift = await _db.packDao.getPackCards(id);
|
||||||
final voices = <VoiceModel>[];
|
final voices = <VoiceModel>[];
|
||||||
|
|
||||||
for (final card in cards) {
|
for (final card in cardsDrift) {
|
||||||
voices.addAll(await getVoices(card.id));
|
voices.addAll(await getVoices(card.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
return await pack.toDto(cards, voices, userModel);
|
return await pack.toDto(cardsDrift, voices, userModel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 'dart:developer';
|
||||||
import 'package:injectable/injectable.dart';
|
import 'package:injectable/injectable.dart';
|
||||||
import 'package:mnemo_cards_backend/database/database.dart';
|
|
||||||
import 'package:mnemo_cards_backend/packs/pack_manager.dart';
|
import 'package:mnemo_cards_backend/packs/pack_manager.dart';
|
||||||
|
import 'package:mnemo_cards_backend/repository/export.dart';
|
||||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
|
|
||||||
|
|
@ -9,10 +9,10 @@ import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
@lazySingleton
|
@lazySingleton
|
||||||
class ProductAvailabilityManager {
|
class ProductAvailabilityManager {
|
||||||
final PackManager _packManager;
|
final PackManager _packManager;
|
||||||
final AppDatabase _db;
|
final UserRepository _userRepository;
|
||||||
final Set<String> _publicPackIds;
|
final Set<String> _publicPackIds;
|
||||||
|
|
||||||
ProductAvailabilityManager(this._packManager, this._db)
|
ProductAvailabilityManager(this._packManager, this._userRepository)
|
||||||
: _publicPackIds = const {'10'};
|
: _publicPackIds = const {'10'};
|
||||||
|
|
||||||
/// Проверить доступность пакета для пользователя
|
/// Проверить доступность пакета для пользователя
|
||||||
|
|
@ -56,14 +56,18 @@ class ProductAvailabilityManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверить прямой доступ через user_packs
|
// Проверить прямой доступ через user_packs
|
||||||
final hasDirectAccess = await _db.userDao.hasPackAccess(user.id!, packId);
|
final hasDirectAccess = await _userRepository.hasPackAccess(
|
||||||
|
user.id!,
|
||||||
|
packId,
|
||||||
|
);
|
||||||
if (hasDirectAccess) {
|
if (hasDirectAccess) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверить доступ через подписку
|
// Проверить доступ через подписку
|
||||||
if (user.subscriptionModel != null && user.subscriptionModel!.isActive) {
|
final subscription = await _userRepository.getUserSubscription(user.id!);
|
||||||
final hasSubscriptionAccess = user.subscriptionModel!.features.contains(
|
if (subscription != null && subscription.isActive) {
|
||||||
|
final hasSubscriptionAccess = subscription.features.contains(
|
||||||
SubscriptionFeatureEnum.packs,
|
SubscriptionFeatureEnum.packs,
|
||||||
);
|
);
|
||||||
if (hasSubscriptionAccess) {
|
if (hasSubscriptionAccess) {
|
||||||
|
|
@ -97,11 +101,15 @@ class ProductAvailabilityManager {
|
||||||
case MnemoCardsProductType.subscription:
|
case MnemoCardsProductType.subscription:
|
||||||
// Подписки не проверяются на доступность, они являются продуктами для покупки
|
// Подписки не проверяются на доступность, они являются продуктами для покупки
|
||||||
// Но можно проверить, есть ли у пользователя уже активная подписка
|
// Но можно проверить, есть ли у пользователя уже активная подписка
|
||||||
if (user?.subscriptionModel != null &&
|
if (user?.id != null) {
|
||||||
user!.subscriptionModel!.isActive) {
|
final subscription = await _userRepository.getUserSubscription(
|
||||||
// Пользователь уже имеет активную подписку
|
user!.id!,
|
||||||
// В зависимости от бизнес-логики, можно вернуть true или false
|
);
|
||||||
return false; // По умолчанию, если есть активная подписка, покупка новой недоступна
|
if (subscription != null && subscription.isActive) {
|
||||||
|
// Пользователь уже имеет активную подписку
|
||||||
|
// В зависимости от бизнес-логики, можно вернуть true или false
|
||||||
|
return false; // По умолчанию, если есть активная подписка, покупка новой недоступна
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true; // Подписка доступна для покупки
|
return true; // Подписка доступна для покупки
|
||||||
case MnemoCardsProductType.discount:
|
case MnemoCardsProductType.discount:
|
||||||
|
|
@ -144,7 +152,7 @@ class ProductAvailabilityManager {
|
||||||
throw ArgumentError('Pack is disabled: $packId');
|
throw ArgumentError('Pack is disabled: $packId');
|
||||||
}
|
}
|
||||||
|
|
||||||
await _db.userDao.grantPackAccess(
|
await _userRepository.grantPackAccess(
|
||||||
userId: userId,
|
userId: userId,
|
||||||
packId: packId,
|
packId: packId,
|
||||||
grantType: grantType,
|
grantType: grantType,
|
||||||
|
|
@ -161,7 +169,7 @@ class ProductAvailabilityManager {
|
||||||
required String userId,
|
required String userId,
|
||||||
required String packId,
|
required String packId,
|
||||||
}) async {
|
}) async {
|
||||||
await _db.userDao.revokePackAccess(userId, packId);
|
await _userRepository.revokePackAccess(userId, packId);
|
||||||
log('Revoked access to pack $packId for user $userId');
|
log('Revoked access to pack $packId for user $userId');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import 'package:injectable/injectable.dart';
|
import 'package:injectable/injectable.dart';
|
||||||
import 'package:mnemo_cards_backend/discounts/discounts_manager.dart';
|
import 'package:mnemo_cards_backend/discounts/discounts_manager.dart';
|
||||||
import 'package:mnemo_cards_backend/user/user_repository.dart';
|
import 'package:mnemo_cards_backend/repository/export.dart';
|
||||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||||
|
|
||||||
@LazySingleton()
|
@LazySingleton()
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,17 @@ import 'package:drift_postgres/drift_postgres.dart';
|
||||||
import 'package:injectable/injectable.dart';
|
import 'package:injectable/injectable.dart';
|
||||||
import 'package:mnemo_cards_backend/api/purchase/payment_manager.dart';
|
import 'package:mnemo_cards_backend/api/purchase/payment_manager.dart';
|
||||||
import 'package:mnemo_cards_backend/database/database.dart';
|
import 'package:mnemo_cards_backend/database/database.dart';
|
||||||
|
import 'package:mnemo_cards_backend/repository/export.dart';
|
||||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
import 'package:drift/drift.dart' as drift;
|
import 'package:drift/drift.dart' as drift;
|
||||||
|
|
||||||
@lazySingleton
|
@lazySingleton
|
||||||
class PromoCodesManager {
|
class PromoCodesManager {
|
||||||
final AppDatabase _db;
|
final PromoCodeRepository _promoCodeRepository;
|
||||||
final PaymentManager _paymentManager;
|
final PaymentManager _paymentManager;
|
||||||
|
final AppDatabase _db;
|
||||||
|
|
||||||
PromoCodesManager(this._db, this._paymentManager);
|
PromoCodesManager(this._promoCodeRepository, this._paymentManager, this._db);
|
||||||
|
|
||||||
PromoCodeCampaignStatus _parseCampaignStatus(String status) {
|
PromoCodeCampaignStatus _parseCampaignStatus(String status) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
|
|
@ -32,13 +34,13 @@ class PromoCodesManager {
|
||||||
Future<List<PromoCodesCampaignDto>> promoCodeCampaigns({
|
Future<List<PromoCodesCampaignDto>> promoCodeCampaigns({
|
||||||
List<String> withCodes = const [],
|
List<String> withCodes = const [],
|
||||||
}) async {
|
}) async {
|
||||||
final campaigns = await _db.promoCodeDao.getActiveCampaigns();
|
final campaigns = await _promoCodeRepository.getActiveCampaigns();
|
||||||
|
|
||||||
final campaignDtos = <PromoCodesCampaignDto>[];
|
final campaignDtos = <PromoCodesCampaignDto>[];
|
||||||
for (final campaign in campaigns) {
|
for (final campaign in campaigns) {
|
||||||
List<String>? promoCodes;
|
List<String>? promoCodes;
|
||||||
if (withCodes.isNotEmpty) {
|
if (withCodes.isNotEmpty) {
|
||||||
final codes = await _db.promoCodeDao.getPromoCodesByCampaignId(
|
final codes = await _promoCodeRepository.getPromoCodesByCampaignId(
|
||||||
campaign.id,
|
campaign.id,
|
||||||
);
|
);
|
||||||
promoCodes = codes.map((code) => code.code).toList();
|
promoCodes = codes.map((code) => code.code).toList();
|
||||||
|
|
@ -70,10 +72,10 @@ class PromoCodesManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<PromoCodesCampaignDto?> promoCodeCampaign(String id) async {
|
Future<PromoCodesCampaignDto?> promoCodeCampaign(String id) async {
|
||||||
final campaign = await _db.promoCodeDao.getCampaignById(id);
|
final campaign = await _promoCodeRepository.getCampaignById(id);
|
||||||
if (campaign == null) return null;
|
if (campaign == null) return null;
|
||||||
|
|
||||||
final codes = await _db.promoCodeDao.getPromoCodesByCampaignId(id);
|
final codes = await _promoCodeRepository.getPromoCodesByCampaignId(id);
|
||||||
final promoCodes = codes.map((code) => code.code).toList();
|
final promoCodes = codes.map((code) => code.code).toList();
|
||||||
|
|
||||||
final products = (campaign.products ?? [])
|
final products = (campaign.products ?? [])
|
||||||
|
|
@ -112,14 +114,14 @@ class PromoCodesManager {
|
||||||
tags: drift.Value(dto.tags),
|
tags: drift.Value(dto.tags),
|
||||||
);
|
);
|
||||||
|
|
||||||
await _db.promoCodeDao.createCampaign(companion);
|
await _promoCodeRepository.createCampaign(companion);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updatePromoCodeCampaign(
|
Future<void> updatePromoCodeCampaign(
|
||||||
String id,
|
String id,
|
||||||
PromoCodesCampaignDto dto,
|
PromoCodesCampaignDto dto,
|
||||||
) async {
|
) async {
|
||||||
final existing = await _db.promoCodeDao.getCampaignById(id);
|
final existing = await _promoCodeRepository.getCampaignById(id);
|
||||||
if (existing == null) {
|
if (existing == null) {
|
||||||
throw StateError('Campaign not found: $id');
|
throw StateError('Campaign not found: $id');
|
||||||
}
|
}
|
||||||
|
|
@ -140,15 +142,15 @@ class PromoCodesManager {
|
||||||
updatedAt: PgDateTime(DateTime.now()),
|
updatedAt: PgDateTime(DateTime.now()),
|
||||||
);
|
);
|
||||||
|
|
||||||
await _db.promoCodeDao.updateCampaign(updated);
|
await _promoCodeRepository.updateCampaign(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> deletePromoCodeCampaign(String id) async {
|
Future<String?> deletePromoCodeCampaign(String id) async {
|
||||||
final existing = await _db.promoCodeDao.getCampaignById(id);
|
final existing = await _promoCodeRepository.getCampaignById(id);
|
||||||
if (existing == null) return 'Campaign not found';
|
if (existing == null) return 'Campaign not found';
|
||||||
|
|
||||||
// Soft delete - mark as deleted
|
// Soft delete - mark as deleted
|
||||||
await _db.promoCodeDao.updateCampaign(
|
await _promoCodeRepository.updateCampaign(
|
||||||
existing.copyWith(isDeleted: true, updatedAt: PgDateTime(DateTime.now())),
|
existing.copyWith(isDeleted: true, updatedAt: PgDateTime(DateTime.now())),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -159,7 +161,7 @@ class PromoCodesManager {
|
||||||
// Get user ID
|
// Get user ID
|
||||||
if (userId.isEmpty) return [];
|
if (userId.isEmpty) return [];
|
||||||
|
|
||||||
final userCodes = await _db.promoCodeDao.getUserPromoCodes(userId);
|
final userCodes = await _promoCodeRepository.getUserPromoCodes(userId);
|
||||||
return userCodes
|
return userCodes
|
||||||
.map(
|
.map(
|
||||||
(code) => {
|
(code) => {
|
||||||
|
|
@ -175,14 +177,14 @@ class PromoCodesManager {
|
||||||
String code,
|
String code,
|
||||||
dynamic user,
|
dynamic user,
|
||||||
) async {
|
) async {
|
||||||
final promoCode = await _db.promoCodeDao.getPromoCodeByCode(
|
final promoCode = await _promoCodeRepository.getPromoCodeByCode(
|
||||||
code.toUpperCase(),
|
code.toUpperCase(),
|
||||||
);
|
);
|
||||||
if (promoCode == null) {
|
if (promoCode == null) {
|
||||||
return {'valid': false, 'message': 'Promo code not found'};
|
return {'valid': false, 'message': 'Promo code not found'};
|
||||||
}
|
}
|
||||||
|
|
||||||
final campaign = await _db.promoCodeDao.getCampaignById(
|
final campaign = await _promoCodeRepository.getCampaignById(
|
||||||
promoCode.campaignId,
|
promoCode.campaignId,
|
||||||
);
|
);
|
||||||
if (campaign == null) {
|
if (campaign == null) {
|
||||||
|
|
@ -222,7 +224,7 @@ class PromoCodesManager {
|
||||||
throw StateError(validation['message']);
|
throw StateError(validation['message']);
|
||||||
}
|
}
|
||||||
|
|
||||||
final promoCode = await _db.promoCodeDao.getPromoCodeByCode(code);
|
final promoCode = await _promoCodeRepository.getPromoCodeByCode(code);
|
||||||
if (promoCode == null) return null;
|
if (promoCode == null) return null;
|
||||||
|
|
||||||
// Get user ID
|
// Get user ID
|
||||||
|
|
@ -231,7 +233,7 @@ class PromoCodesManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Increment activations
|
// Increment activations
|
||||||
await _db.promoCodeDao.incrementActivations(promoCode.id);
|
await _promoCodeRepository.incrementActivations(promoCode.id);
|
||||||
|
|
||||||
// Apply the promo code benefits to user
|
// Apply the promo code benefits to user
|
||||||
final campaign = validation['campaign'] as Map<String, dynamic>;
|
final campaign = validation['campaign'] as Map<String, dynamic>;
|
||||||
|
|
@ -250,7 +252,7 @@ class PromoCodesManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> launchPromoCodesCampaign(String campaignId) async {
|
Future<bool> launchPromoCodesCampaign(String campaignId) async {
|
||||||
final campaign = await _db.promoCodeDao.getCampaignById(campaignId);
|
final campaign = await _promoCodeRepository.getCampaignById(campaignId);
|
||||||
if (campaign == null) return false;
|
if (campaign == null) return false;
|
||||||
|
|
||||||
if (campaign.status != 'ready') {
|
if (campaign.status != 'ready') {
|
||||||
|
|
@ -267,13 +269,13 @@ class PromoCodesManager {
|
||||||
// Insert codes into database
|
// Insert codes into database
|
||||||
await _db.transaction(() async {
|
await _db.transaction(() async {
|
||||||
for (final code in codes) {
|
for (final code in codes) {
|
||||||
await _db.promoCodeDao.createPromoCode(
|
await _promoCodeRepository.createPromoCode(
|
||||||
PromoCodesCompanion.insert(campaignId: campaignId, code: code),
|
PromoCodesCompanion.insert(campaignId: campaignId, code: code),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update campaign status to active
|
// Update campaign status to active
|
||||||
await _db.promoCodeDao.updateCampaign(
|
await _promoCodeRepository.updateCampaign(
|
||||||
campaign.copyWith(
|
campaign.copyWith(
|
||||||
status: 'active',
|
status: 'active',
|
||||||
updatedAt: PgDateTime(DateTime.now()),
|
updatedAt: PgDateTime(DateTime.now()),
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,16 @@ import 'package:drift/drift.dart';
|
||||||
import 'package:drift_postgres/drift_postgres.dart';
|
import 'package:drift_postgres/drift_postgres.dart';
|
||||||
import 'package:injectable/injectable.dart';
|
import 'package:injectable/injectable.dart';
|
||||||
import 'package:mnemo_cards_backend/database/database.dart';
|
import 'package:mnemo_cards_backend/database/database.dart';
|
||||||
import 'package:mnemo_cards_backend/user/user_repository.dart';
|
import 'package:mnemo_cards_backend/repository/export.dart';
|
||||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||||
|
|
||||||
@lazySingleton
|
@lazySingleton
|
||||||
class AchievementManager {
|
class AchievementManager {
|
||||||
final AppDatabase _db;
|
final AchievementRepository _achievementRepository;
|
||||||
final UserRepository _userRepository;
|
final UserRepository _userRepository;
|
||||||
|
|
||||||
AchievementManager(this._db, this._userRepository);
|
AchievementManager(this._achievementRepository, this._userRepository);
|
||||||
|
|
||||||
/// Get all available achievements with their definitions
|
/// Get all available achievements with their definitions
|
||||||
List<AchievementDto> get allAchievementDefinitions =>
|
List<AchievementDto> get allAchievementDefinitions =>
|
||||||
|
|
@ -50,7 +50,7 @@ class AchievementManager {
|
||||||
achievement,
|
achievement,
|
||||||
);
|
);
|
||||||
if (progress > 0.0) {
|
if (progress > 0.0) {
|
||||||
await _db.achievementDao.updateAchievementProgress(
|
await _achievementRepository.updateAchievementProgress(
|
||||||
userId,
|
userId,
|
||||||
achievement.id,
|
achievement.id,
|
||||||
progress,
|
progress,
|
||||||
|
|
@ -64,10 +64,12 @@ class AchievementManager {
|
||||||
|
|
||||||
/// Get achievements for a user
|
/// Get achievements for a user
|
||||||
Future<List<AchievementDto>> getUserAchievements(String userId) async {
|
Future<List<AchievementDto>> getUserAchievements(String userId) async {
|
||||||
final userAchievements = await _db.achievementDao.getUserAchievements(
|
final userAchievements = await _achievementRepository.getUserAchievements(
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
final progressMap = await _achievementRepository.getAchievementProgress(
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
final progressMap = await _db.achievementDao.getAchievementProgress(userId);
|
|
||||||
|
|
||||||
final achievements = <AchievementDto>[];
|
final achievements = <AchievementDto>[];
|
||||||
|
|
||||||
|
|
@ -98,7 +100,7 @@ class AchievementManager {
|
||||||
|
|
||||||
/// Check if user has specific achievement
|
/// Check if user has specific achievement
|
||||||
Future<bool> hasAchievement(String userId, String achievementId) async {
|
Future<bool> hasAchievement(String userId, String achievementId) async {
|
||||||
return await _db.achievementDao.hasAchievement(userId, achievementId);
|
return await _achievementRepository.hasAchievement(userId, achievementId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unlock achievement for user
|
/// Unlock achievement for user
|
||||||
|
|
@ -117,11 +119,11 @@ class AchievementManager {
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await _db.achievementDao.unlockAchievement(companion);
|
await _achievementRepository.unlockAchievement(companion);
|
||||||
return definition.unlock();
|
return definition.unlock();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Achievement might already exist, return existing
|
// Achievement might already exist, return existing
|
||||||
final existing = await _db.achievementDao.getUserAchievement(
|
final existing = await _achievementRepository.getUserAchievement(
|
||||||
userId,
|
userId,
|
||||||
achievementId,
|
achievementId,
|
||||||
);
|
);
|
||||||
|
|
@ -131,7 +133,7 @@ class AchievementManager {
|
||||||
|
|
||||||
/// Get achievement progress for user
|
/// Get achievement progress for user
|
||||||
Future<Map<String, double>> getAchievementProgress(String userId) async {
|
Future<Map<String, double>> getAchievementProgress(String userId) async {
|
||||||
return await _db.achievementDao.getAchievementProgress(userId);
|
return await _achievementRepository.getAchievementProgress(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if achievement condition is met
|
/// Check if achievement condition is met
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:collection';
|
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:drift_postgres/drift_postgres.dart';
|
import 'package:drift_postgres/drift_postgres.dart';
|
||||||
import 'package:injectable/injectable.dart';
|
import 'package:injectable/injectable.dart';
|
||||||
import 'package:mnemo_cards_backend/database/database.dart';
|
import 'package:mnemo_cards_backend/database/database.dart';
|
||||||
|
import 'package:mnemo_cards_backend/repository/export.dart';
|
||||||
import 'package:drift/drift.dart' as drift;
|
import 'package:drift/drift.dart' as drift;
|
||||||
|
|
||||||
@lazySingleton
|
@lazySingleton
|
||||||
class SessionTracker {
|
class SessionTracker {
|
||||||
final AppDatabase _db;
|
final StatisticsRepository _statisticsRepository;
|
||||||
|
|
||||||
/// Active sessions cache: userId -> sessionId
|
/// Active sessions cache: userId -> sessionId
|
||||||
final Map<String, String> _activeSessions = {};
|
final Map<String, String> _activeSessions = {};
|
||||||
|
|
@ -20,7 +20,7 @@ class SessionTracker {
|
||||||
/// Session timeout duration (30 minutes by default)
|
/// Session timeout duration (30 minutes by default)
|
||||||
static const Duration _sessionTimeout = Duration(minutes: 30);
|
static const Duration _sessionTimeout = Duration(minutes: 30);
|
||||||
|
|
||||||
SessionTracker(this._db);
|
SessionTracker(this._statisticsRepository);
|
||||||
|
|
||||||
/// Get or create an active session for a user
|
/// Get or create an active session for a user
|
||||||
///
|
///
|
||||||
|
|
@ -48,7 +48,7 @@ class SessionTracker {
|
||||||
final sessionId = _generateSessionId();
|
final sessionId = _generateSessionId();
|
||||||
final now = PgDateTime(DateTime.now());
|
final now = PgDateTime(DateTime.now());
|
||||||
|
|
||||||
await _db.statisticsDao.createSession(
|
await _statisticsRepository.createSession(
|
||||||
StudySessionsCompanion.insert(
|
StudySessionsCompanion.insert(
|
||||||
userId: userId,
|
userId: userId,
|
||||||
sessionId: drift.Value(sessionId),
|
sessionId: drift.Value(sessionId),
|
||||||
|
|
@ -91,11 +91,13 @@ class SessionTracker {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get session by sessionId to find its database ID
|
// Get session by sessionId to find its database ID
|
||||||
final session = await _db.statisticsDao.getSessionBySessionId(sessionId);
|
final session = await _statisticsRepository.getSessionBySessionId(
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
if (session == null) return;
|
if (session == null) return;
|
||||||
|
|
||||||
// Update session in database using database ID
|
// Update session in database using database ID
|
||||||
await _db.statisticsDao.endSession(
|
await _statisticsRepository.endSession(
|
||||||
session.id,
|
session.id,
|
||||||
wordsLearned: wordsLearned,
|
wordsLearned: wordsLearned,
|
||||||
testsCompleted: testsCompleted,
|
testsCompleted: testsCompleted,
|
||||||
|
|
@ -111,7 +113,9 @@ class SessionTracker {
|
||||||
double? accuracy,
|
double? accuracy,
|
||||||
}) async {
|
}) async {
|
||||||
// Find session by sessionId
|
// Find session by sessionId
|
||||||
final session = await _db.statisticsDao.getSessionBySessionId(sessionId);
|
final session = await _statisticsRepository.getSessionBySessionId(
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
if (session == null) return;
|
if (session == null) return;
|
||||||
|
|
||||||
// Update session
|
// Update session
|
||||||
|
|
@ -122,12 +126,14 @@ class SessionTracker {
|
||||||
updatedAt: PgDateTime(DateTime.now()),
|
updatedAt: PgDateTime(DateTime.now()),
|
||||||
);
|
);
|
||||||
|
|
||||||
await _db.statisticsDao.updateSession(updatedSession);
|
await _statisticsRepository.updateSession(updatedSession);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get active session for user
|
/// Get active session for user
|
||||||
Future<StudySession?> getActiveSession(String userId) async {
|
Future<StudySession?> getActiveSession(String userId) async {
|
||||||
final activeSessions = await _db.statisticsDao.getActiveSessions(userId);
|
final activeSessions = await _statisticsRepository.getActiveSessions(
|
||||||
|
userId,
|
||||||
|
);
|
||||||
return activeSessions.isNotEmpty ? activeSessions.first : null;
|
return activeSessions.isNotEmpty ? activeSessions.first : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -138,7 +144,7 @@ class SessionTracker {
|
||||||
DateTime? fromDate,
|
DateTime? fromDate,
|
||||||
DateTime? toDate,
|
DateTime? toDate,
|
||||||
}) async {
|
}) async {
|
||||||
return await _db.statisticsDao.getSessionsByUserId(
|
return await _statisticsRepository.getSessionsByUserId(
|
||||||
userId,
|
userId,
|
||||||
limit: limit,
|
limit: limit,
|
||||||
fromDate: fromDate,
|
fromDate: fromDate,
|
||||||
|
|
@ -154,7 +160,9 @@ class SessionTracker {
|
||||||
|
|
||||||
for (final entry in _activeSessions.entries) {
|
for (final entry in _activeSessions.entries) {
|
||||||
final sessionId = entry.value;
|
final sessionId = entry.value;
|
||||||
final session = await _db.statisticsDao.getSessionBySessionId(sessionId);
|
final session = await _statisticsRepository.getSessionBySessionId(
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
|
||||||
if (session != null &&
|
if (session != null &&
|
||||||
session.endTime == null &&
|
session.endTime == null &&
|
||||||
|
|
@ -191,9 +199,11 @@ class SessionTracker {
|
||||||
|
|
||||||
/// Update session activity timestamp
|
/// Update session activity timestamp
|
||||||
Future<void> _updateSessionActivity(String sessionId) async {
|
Future<void> _updateSessionActivity(String sessionId) async {
|
||||||
final session = await _db.statisticsDao.getSessionBySessionId(sessionId);
|
final session = await _statisticsRepository.getSessionBySessionId(
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
if (session != null) {
|
if (session != null) {
|
||||||
await _db.statisticsDao.updateSession(
|
await _statisticsRepository.updateSession(
|
||||||
session.copyWith(updatedAt: PgDateTime(DateTime.now())),
|
session.copyWith(updatedAt: PgDateTime(DateTime.now())),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:injectable/injectable.dart';
|
import 'package:injectable/injectable.dart';
|
||||||
import 'package:mnemo_cards_backend/user/user_repository.dart';
|
import 'package:mnemo_cards_backend/repository/export.dart';
|
||||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||||
import 'package:mnemo_cards_backend/database/database.dart';
|
import 'package:mnemo_cards_backend/database/database.dart';
|
||||||
|
|
@ -11,8 +11,17 @@ import 'package:mnemo_cards_backend/database/database.dart';
|
||||||
class StatisticsCalculator {
|
class StatisticsCalculator {
|
||||||
final AppDatabase _db;
|
final AppDatabase _db;
|
||||||
final UserRepository _userRepository;
|
final UserRepository _userRepository;
|
||||||
|
final PackRepository _packRepository;
|
||||||
|
final WordStatisticsRepository _wordStatisticsRepository;
|
||||||
|
final StatisticsRepository _statisticsRepository;
|
||||||
|
|
||||||
StatisticsCalculator(this._db, this._userRepository);
|
StatisticsCalculator(
|
||||||
|
this._db,
|
||||||
|
this._userRepository,
|
||||||
|
this._packRepository,
|
||||||
|
this._wordStatisticsRepository,
|
||||||
|
this._statisticsRepository,
|
||||||
|
);
|
||||||
|
|
||||||
/// Calculate pack progress for a specific user and pack
|
/// Calculate pack progress for a specific user and pack
|
||||||
///
|
///
|
||||||
|
|
@ -23,19 +32,19 @@ class StatisticsCalculator {
|
||||||
String packId,
|
String packId,
|
||||||
) async {
|
) async {
|
||||||
// Получить информацию о паке
|
// Получить информацию о паке
|
||||||
final pack = await _db.packDao.getPackById(packId);
|
final pack = await _packRepository.getPackById(packId);
|
||||||
if (pack == null) {
|
if (pack == null) {
|
||||||
return PackProgressDto.empty(packId, 0);
|
return PackProgressDto.empty(packId, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получить статистику по словам пака из WordStatistics
|
// Получить статистику по словам пака из WordStatistics
|
||||||
final wordStats = await _db.wordStatisticsDao.getPackStatistics(
|
final wordStats = await _wordStatisticsRepository.getPackStatistics(
|
||||||
userId,
|
userId,
|
||||||
packId,
|
packId,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Получить сессии изучения этого пака
|
// Получить сессии изучения этого пака
|
||||||
final sessions = await _db.statisticsDao.getSessionsByUserId(
|
final sessions = await _statisticsRepository.getSessionsByUserId(
|
||||||
userId,
|
userId,
|
||||||
limit: null,
|
limit: null,
|
||||||
);
|
);
|
||||||
|
|
@ -123,7 +132,7 @@ class StatisticsCalculator {
|
||||||
///
|
///
|
||||||
/// Данные теперь берутся из StudySessions вместо UserDatas.studyDates
|
/// Данные теперь берутся из StudySessions вместо UserDatas.studyDates
|
||||||
Future<List<DateTime>> calculateStudyDates(String userId) async {
|
Future<List<DateTime>> calculateStudyDates(String userId) async {
|
||||||
final sessions = await _db.statisticsDao.getSessionsByUserId(userId);
|
final sessions = await _statisticsRepository.getSessionsByUserId(userId);
|
||||||
|
|
||||||
// Получить уникальные даты (без времени)
|
// Получить уникальные даты (без времени)
|
||||||
final uniqueDates = <DateTime>{};
|
final uniqueDates = <DateTime>{};
|
||||||
|
|
@ -147,7 +156,7 @@ class StatisticsCalculator {
|
||||||
/// Данные теперь берутся из StudySessions + CardPacks.category
|
/// Данные теперь берутся из StudySessions + CardPacks.category
|
||||||
/// вместо UserDatas.categoryMinutes
|
/// вместо UserDatas.categoryMinutes
|
||||||
Future<Map<String, int>> calculateCategoryMinutes(String userId) async {
|
Future<Map<String, int>> calculateCategoryMinutes(String userId) async {
|
||||||
final sessions = await _db.statisticsDao.getSessionsByUserId(userId);
|
final sessions = await _statisticsRepository.getSessionsByUserId(userId);
|
||||||
|
|
||||||
final categoryMinutes = <String, int>{};
|
final categoryMinutes = <String, int>{};
|
||||||
|
|
||||||
|
|
@ -157,7 +166,7 @@ class StatisticsCalculator {
|
||||||
// Получить категорию пака
|
// Получить категорию пака
|
||||||
// Примечание: в CardPacks нет поля category, используем 'unknown'
|
// Примечание: в CardPacks нет поля category, используем 'unknown'
|
||||||
// В будущем можно добавить поле category в CardPacks или использовать другую логику
|
// В будущем можно добавить поле category в CardPacks или использовать другую логику
|
||||||
final pack = await _db.packDao.getPackById(session.packId!);
|
final pack = await _packRepository.getPackById(session.packId!);
|
||||||
final category = pack != null
|
final category = pack != null
|
||||||
? 'unknown'
|
? 'unknown'
|
||||||
: 'unknown'; // TODO: добавить category в CardPacks
|
: 'unknown'; // TODO: добавить category в CardPacks
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:injectable/injectable.dart';
|
import 'package:injectable/injectable.dart';
|
||||||
import 'package:mnemo_cards_backend/database/database.dart';
|
import 'package:mnemo_cards_backend/database/database.dart';
|
||||||
|
import 'package:mnemo_cards_backend/repository/export.dart';
|
||||||
|
|
||||||
/// Менеджер для работы со статистикой ответов пользователей на карточки
|
/// Менеджер для работы со статистикой ответов пользователей на карточки
|
||||||
///
|
///
|
||||||
|
|
@ -8,9 +9,9 @@ import 'package:mnemo_cards_backend/database/database.dart';
|
||||||
/// Обновляет существующую запись при каждом последующем ответе.
|
/// Обновляет существующую запись при каждом последующем ответе.
|
||||||
@lazySingleton
|
@lazySingleton
|
||||||
class WordStatisticsManager {
|
class WordStatisticsManager {
|
||||||
final AppDatabase _db;
|
final WordStatisticsRepository _wordStatisticsRepository;
|
||||||
|
|
||||||
WordStatisticsManager(this._db);
|
WordStatisticsManager(this._wordStatisticsRepository);
|
||||||
|
|
||||||
/// Записать ответ пользователя на карточку
|
/// Записать ответ пользователя на карточку
|
||||||
///
|
///
|
||||||
|
|
@ -26,14 +27,14 @@ class WordStatisticsManager {
|
||||||
required bool isCorrect,
|
required bool isCorrect,
|
||||||
}) async {
|
}) async {
|
||||||
// Получить существующую статистику или создать новую
|
// Получить существующую статистику или создать новую
|
||||||
final existing = await _db.wordStatisticsDao.getByUserAndCard(
|
final existing = await _wordStatisticsRepository.getByUserAndCard(
|
||||||
userId,
|
userId,
|
||||||
cardId,
|
cardId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existing == null) {
|
if (existing == null) {
|
||||||
// Создать новую запись при первом ответе
|
// Создать новую запись при первом ответе
|
||||||
await _db.wordStatisticsDao.create(
|
await _wordStatisticsRepository.create(
|
||||||
userId: userId,
|
userId: userId,
|
||||||
cardId: cardId,
|
cardId: cardId,
|
||||||
correctAnswers: isCorrect ? 1 : 0,
|
correctAnswers: isCorrect ? 1 : 0,
|
||||||
|
|
@ -44,7 +45,7 @@ class WordStatisticsManager {
|
||||||
final newCorrect = existing.correctAnswers + (isCorrect ? 1 : 0);
|
final newCorrect = existing.correctAnswers + (isCorrect ? 1 : 0);
|
||||||
final newIncorrect = existing.incorrectAnswers + (isCorrect ? 0 : 1);
|
final newIncorrect = existing.incorrectAnswers + (isCorrect ? 0 : 1);
|
||||||
|
|
||||||
await _db.wordStatisticsDao.updateStatistics(
|
await _wordStatisticsRepository.updateStatistics(
|
||||||
id: existing.id,
|
id: existing.id,
|
||||||
correctAnswers: newCorrect,
|
correctAnswers: newCorrect,
|
||||||
incorrectAnswers: newIncorrect,
|
incorrectAnswers: newIncorrect,
|
||||||
|
|
@ -74,11 +75,11 @@ class WordStatisticsManager {
|
||||||
String userId,
|
String userId,
|
||||||
String packId,
|
String packId,
|
||||||
) async {
|
) async {
|
||||||
return _db.wordStatisticsDao.getPackStatistics(userId, packId);
|
return _wordStatisticsRepository.getPackStatistics(userId, packId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Получить всю статистику пользователя
|
/// Получить всю статистику пользователя
|
||||||
Future<List<WordStatistic>> getUserStatistics(String userId) async {
|
Future<List<WordStatistic>> getUserStatistics(String userId) async {
|
||||||
return _db.wordStatisticsDao.getUserStatistics(userId);
|
return _wordStatisticsRepository.getUserStatistics(userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,14 @@ import 'package:drift_postgres/drift_postgres.dart';
|
||||||
import 'package:injectable/injectable.dart';
|
import 'package:injectable/injectable.dart';
|
||||||
import 'package:drift/drift.dart' as drift;
|
import 'package:drift/drift.dart' as drift;
|
||||||
import 'package:mnemo_cards_backend/database/database.dart';
|
import 'package:mnemo_cards_backend/database/database.dart';
|
||||||
|
import 'package:mnemo_cards_backend/repository/export.dart';
|
||||||
|
|
||||||
@lazySingleton
|
@lazySingleton
|
||||||
class TaskManager {
|
class TaskManager {
|
||||||
|
final TaskRepository _taskRepository;
|
||||||
final AppDatabase _db;
|
final AppDatabase _db;
|
||||||
|
|
||||||
TaskManager(this._db);
|
TaskManager(this._taskRepository, this._db);
|
||||||
|
|
||||||
/// Получить все доступные задачи пользователя
|
/// Получить все доступные задачи пользователя
|
||||||
Future<List<UserTask>> getUserTasks(
|
Future<List<UserTask>> getUserTasks(
|
||||||
|
|
@ -19,7 +21,7 @@ class TaskManager {
|
||||||
int? limit,
|
int? limit,
|
||||||
int? offset,
|
int? offset,
|
||||||
}) async {
|
}) async {
|
||||||
return await _db.taskDao.getUserTasks(
|
return await _taskRepository.getUserTasks(
|
||||||
userId,
|
userId,
|
||||||
status: status,
|
status: status,
|
||||||
activeOnly: status == 'available',
|
activeOnly: status == 'available',
|
||||||
|
|
@ -28,7 +30,7 @@ class TaskManager {
|
||||||
|
|
||||||
/// Получить задачу по ID
|
/// Получить задачу по ID
|
||||||
Future<UserTask?> getUserTask(String userId, String taskId) async {
|
Future<UserTask?> getUserTask(String userId, String taskId) async {
|
||||||
final tasks = await _db.taskDao.getUserTasks(userId);
|
final tasks = await _taskRepository.getUserTasks(userId);
|
||||||
return tasks.where((task) => task.id == taskId).firstOrNull;
|
return tasks.where((task) => task.id == taskId).firstOrNull;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,10 +44,12 @@ class TaskManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновить статус задачи
|
// Обновить статус задачи
|
||||||
await _db.taskDao.updateUserTask(task.copyWith(status: 'in_progress'));
|
await _taskRepository.updateUserTask(
|
||||||
|
task.copyWith(status: 'in_progress'),
|
||||||
|
);
|
||||||
|
|
||||||
// Создать запись прогресса
|
// Создать запись прогресса
|
||||||
await _db.taskDao.createTaskProgress(
|
await _taskRepository.createTaskProgress(
|
||||||
UserTaskProgressesCompanion.insert(
|
UserTaskProgressesCompanion.insert(
|
||||||
userId: userId,
|
userId: userId,
|
||||||
taskId: taskId,
|
taskId: taskId,
|
||||||
|
|
@ -66,12 +70,12 @@ class TaskManager {
|
||||||
|
|
||||||
// Обновить статус задачи
|
// Обновить статус задачи
|
||||||
final now = PgDateTime(DateTime.now());
|
final now = PgDateTime(DateTime.now());
|
||||||
await _db.taskDao.updateUserTask(
|
await _taskRepository.updateUserTask(
|
||||||
task.copyWith(status: 'completed', completedAt: drift.Value(now)),
|
task.copyWith(status: 'completed', completedAt: drift.Value(now)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Создать запись результата
|
// Создать запись результата
|
||||||
await _db.taskDao.createTaskResult(
|
await _taskRepository.createTaskResult(
|
||||||
UserTaskResultsCompanion.insert(
|
UserTaskResultsCompanion.insert(
|
||||||
userId: userId,
|
userId: userId,
|
||||||
taskId: taskId,
|
taskId: taskId,
|
||||||
|
|
@ -88,11 +92,11 @@ class TaskManager {
|
||||||
Future<List<UserTaskProgressesData>> getUserTaskProgress(
|
Future<List<UserTaskProgressesData>> getUserTaskProgress(
|
||||||
String userId,
|
String userId,
|
||||||
) async {
|
) async {
|
||||||
final tasks = await _db.taskDao.getUserTasks(userId);
|
final tasks = await _taskRepository.getUserTasks(userId);
|
||||||
final progresses = <UserTaskProgressesData>[];
|
final progresses = <UserTaskProgressesData>[];
|
||||||
|
|
||||||
for (final task in tasks) {
|
for (final task in tasks) {
|
||||||
final progress = await _db.taskDao.getTaskProgress(userId, task.id);
|
final progress = await _taskRepository.getTaskProgress(userId, task.id);
|
||||||
if (progress != null) {
|
if (progress != null) {
|
||||||
progresses.add(progress);
|
progresses.add(progress);
|
||||||
}
|
}
|
||||||
|
|
@ -103,7 +107,7 @@ class TaskManager {
|
||||||
|
|
||||||
/// Получить категории задач
|
/// Получить категории задач
|
||||||
Future<List<String>> getTaskCategories() async {
|
Future<List<String>> getTaskCategories() async {
|
||||||
final tasks = await _db.taskDao.getAllUserTasks();
|
final tasks = await _taskRepository.getAllUserTasks();
|
||||||
final categories = <String>{};
|
final categories = <String>{};
|
||||||
|
|
||||||
for (final task in tasks) {
|
for (final task in tasks) {
|
||||||
|
|
@ -117,16 +121,16 @@ class TaskManager {
|
||||||
|
|
||||||
/// Создать новую задачу для пользователя
|
/// Создать новую задачу для пользователя
|
||||||
Future<String> createUserTask(UserTasksCompanion task) async {
|
Future<String> createUserTask(UserTasksCompanion task) async {
|
||||||
return await _db.taskDao.createUserTask(task);
|
return await _taskRepository.createUserTask(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Обновить задачу пользователя
|
/// Обновить задачу пользователя
|
||||||
Future<bool> updateUserTask(UserTask task) async {
|
Future<bool> updateUserTask(UserTask task) async {
|
||||||
return await _db.taskDao.updateUserTask(task);
|
return await _taskRepository.updateUserTask(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Подсчитать задачи пользователя
|
/// Подсчитать задачи пользователя
|
||||||
Future<int> countUserTasks(String userId, {String? status}) async {
|
Future<int> countUserTasks(String userId, {String? status}) async {
|
||||||
return await _db.taskDao.countUserTasks(userId, status: status);
|
return await _taskRepository.countUserTasks(userId, status: status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:drift_postgres/drift_postgres.dart';
|
||||||
import 'package:mnemo_cards_backend/database/database.dart';
|
import 'package:mnemo_cards_backend/database/database.dart';
|
||||||
import 'package:mnemo_cards_backend/packs/card_image_storage.dart';
|
import 'package:mnemo_cards_backend/packs/card_image_storage.dart';
|
||||||
import 'package:mnemo_cards_backend/packs/voice_storage.dart';
|
import 'package:mnemo_cards_backend/packs/voice_storage.dart';
|
||||||
|
import 'package:mnemo_cards_backend/repository/export.dart';
|
||||||
import 'package:mnemo_cards_backend/storage/minio_config.dart';
|
import 'package:mnemo_cards_backend/storage/minio_config.dart';
|
||||||
import 'package:mnemo_cards_backend/storage/minio_service.dart';
|
import 'package:mnemo_cards_backend/storage/minio_service.dart';
|
||||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||||
|
|
@ -18,9 +19,16 @@ import 'generators/pack_test_generator.dart';
|
||||||
@lazySingleton
|
@lazySingleton
|
||||||
class TestManager {
|
class TestManager {
|
||||||
final AppDatabase _db;
|
final AppDatabase _db;
|
||||||
|
final PackRepository _packRepository;
|
||||||
|
final TestRepository _testRepository;
|
||||||
final MinioService _minioService;
|
final MinioService _minioService;
|
||||||
|
|
||||||
TestManager(this._db, this._minioService);
|
TestManager(
|
||||||
|
this._db,
|
||||||
|
this._packRepository,
|
||||||
|
this._testRepository,
|
||||||
|
this._minioService,
|
||||||
|
);
|
||||||
|
|
||||||
static final _uuidRegex = RegExp(
|
static final _uuidRegex = RegExp(
|
||||||
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
|
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
|
||||||
|
|
@ -47,9 +55,9 @@ class TestManager {
|
||||||
required String cardId,
|
required String cardId,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final card = await _db.packDao.getCardById(cardId);
|
final card = await _packRepository.getCardById(cardId);
|
||||||
if (card == null) return;
|
if (card == null) return;
|
||||||
await _db.packDao.addCardToPack(packId: packId, cardId: cardId);
|
await _packRepository.addCardToPack(packId: packId, cardId: cardId);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Best-effort only.
|
// Best-effort only.
|
||||||
}
|
}
|
||||||
|
|
@ -67,7 +75,7 @@ class TestManager {
|
||||||
mnemo: const drift.Value('test_image'),
|
mnemo: const drift.Value('test_image'),
|
||||||
);
|
);
|
||||||
|
|
||||||
final cardId = await _db.packDao.createCard(companion);
|
final cardId = await _packRepository.createCard(companion);
|
||||||
if (packId != null) {
|
if (packId != null) {
|
||||||
await _tryLinkCardToPack(packId: packId, cardId: cardId);
|
await _tryLinkCardToPack(packId: packId, cardId: cardId);
|
||||||
}
|
}
|
||||||
|
|
@ -80,15 +88,19 @@ class TestManager {
|
||||||
);
|
);
|
||||||
if (stored == null) return null;
|
if (stored == null) return null;
|
||||||
|
|
||||||
final created = await _db.packDao.getCardById(cardId);
|
final created = await _packRepository.getCardById(cardId);
|
||||||
if (created == null) return null;
|
if (created == null) return null;
|
||||||
|
|
||||||
await _db.packDao.updateCard(
|
// TODO: Refactor to use PackRepository.updateCard with GameCardModel
|
||||||
created.copyWith(
|
final card = await _db.packDao.getCardById(cardId);
|
||||||
image: stored.fileName,
|
if (card != null) {
|
||||||
updatedAt: PgDateTime(DateTime.now()),
|
await _packRepository.updateCard(
|
||||||
),
|
card.copyWith(
|
||||||
);
|
image: stored.fileName,
|
||||||
|
updatedAt: PgDateTime(DateTime.now()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return cardId;
|
return cardId;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
|
@ -276,7 +288,7 @@ class TestManager {
|
||||||
String userId,
|
String userId,
|
||||||
String testId,
|
String testId,
|
||||||
) async {
|
) async {
|
||||||
final statistics = await _db.testDao.getTestStatistics(userId, testId);
|
final statistics = await _testRepository.getTestStatistics(userId, testId);
|
||||||
if (statistics == null) return null;
|
if (statistics == null) return null;
|
||||||
|
|
||||||
// Convert TestStatistic to TestStatisticsDto
|
// Convert TestStatistic to TestStatisticsDto
|
||||||
|
|
@ -411,6 +423,7 @@ class TestManager {
|
||||||
questionJson['buttons'] = normalizedButtons;
|
questionJson['buttons'] = normalizedButtons;
|
||||||
|
|
||||||
if (mutated) {
|
if (mutated) {
|
||||||
|
// TODO: Refactor to use TestRepository.updateTestQuestion
|
||||||
await _db.testDao.updateTestQuestion(
|
await _db.testDao.updateTestQuestion(
|
||||||
q.copyWith(
|
q.copyWith(
|
||||||
options: jsonEncode(normalizedButtons),
|
options: jsonEncode(normalizedButtons),
|
||||||
|
|
@ -455,7 +468,7 @@ class TestManager {
|
||||||
final currentButtons =
|
final currentButtons =
|
||||||
(questionJson['buttons'] as List<dynamic>?) ?? [];
|
(questionJson['buttons'] as List<dynamic>?) ?? [];
|
||||||
if (currentButtons.isEmpty && packId != null) {
|
if (currentButtons.isEmpty && packId != null) {
|
||||||
final pool = await _db.packDao.getPackCards(packId);
|
final pool = await _packRepository.getPackCards(packId);
|
||||||
final maxSize = sqrt(pool.length).floor().clamp(1, 4);
|
final maxSize = sqrt(pool.length).floor().clamp(1, 4);
|
||||||
final resolvedSize = matrixSize.clamp(1, maxSize);
|
final resolvedSize = matrixSize.clamp(1, maxSize);
|
||||||
final total = resolvedSize * resolvedSize;
|
final total = resolvedSize * resolvedSize;
|
||||||
|
|
@ -554,11 +567,11 @@ class TestManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<TestDto>> availableTests(UserModel userModel) async {
|
Future<List<TestDto>> availableTests(UserModel userModel) async {
|
||||||
final tests = await _db.testDao.getAllTests();
|
final tests = await _testRepository.getAllTests();
|
||||||
|
|
||||||
final testDtos = <TestDto>[];
|
final testDtos = <TestDto>[];
|
||||||
for (final test in tests) {
|
for (final test in tests) {
|
||||||
final questions = await _db.testDao.getTestQuestions(test.id);
|
final questions = await _testRepository.getTestQuestions(test.id);
|
||||||
final statistics = await _testStatisticsDto(userModel.id!, test.id);
|
final statistics = await _testStatisticsDto(userModel.id!, test.id);
|
||||||
|
|
||||||
final questionsList = questions.map((q) {
|
final questionsList = questions.map((q) {
|
||||||
|
|
@ -616,11 +629,11 @@ class TestManager {
|
||||||
final packId = model.id;
|
final packId = model.id;
|
||||||
if (packId == null) return [];
|
if (packId == null) return [];
|
||||||
|
|
||||||
final tests = await _db.testDao.getTestsByPackId(packId);
|
final tests = await _testRepository.getTestsByPackId(packId);
|
||||||
|
|
||||||
final testDtos = <TestDto>[];
|
final testDtos = <TestDto>[];
|
||||||
for (final test in tests) {
|
for (final test in tests) {
|
||||||
final questions = await _db.testDao.getTestQuestions(test.id);
|
final questions = await _testRepository.getTestQuestions(test.id);
|
||||||
final statistics = await _testStatisticsDto(user.id!, test.id);
|
final statistics = await _testStatisticsDto(user.id!, test.id);
|
||||||
|
|
||||||
final questionsList = await Future.wait(
|
final questionsList = await Future.wait(
|
||||||
|
|
@ -750,7 +763,7 @@ class TestManager {
|
||||||
final testDataItems = await Future.wait(
|
final testDataItems = await Future.wait(
|
||||||
cards.map((card) async {
|
cards.map((card) async {
|
||||||
// Get voices for this card
|
// Get voices for this card
|
||||||
final voices = await _db.packDao.getCardVoices(card.id.toString());
|
final voices = await _packRepository.getCardVoices(card.id.toString());
|
||||||
|
|
||||||
// Use first voice's voiceUrl if it's a valid UUID, otherwise null
|
// Use first voice's voiceUrl if it's a valid UUID, otherwise null
|
||||||
String? audioUuid;
|
String? audioUuid;
|
||||||
|
|
@ -849,7 +862,7 @@ class TestManager {
|
||||||
uiData: drift.Value(json.encode(uiData)),
|
uiData: drift.Value(json.encode(uiData)),
|
||||||
);
|
);
|
||||||
|
|
||||||
await _db.testDao.createTestQuestion(questionCompanion);
|
await _testRepository.createTestQuestion(questionCompanion);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return createdTestId!;
|
return createdTestId!;
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,7 @@ extension UserDataModelExtension on UserDataModel {
|
||||||
words: words.map((model) => model.toDto()).toList(),
|
words: words.map((model) => model.toDto()).toList(),
|
||||||
),
|
),
|
||||||
allTestsStatistics: AllTestsStatisticsDto(
|
allTestsStatistics: AllTestsStatisticsDto(
|
||||||
tests: Map.fromEntries(
|
tests: {}, // testsStatistics removed - always empty
|
||||||
testsStatistics.map((model) => MapEntry(model.id!, model.toDto())),
|
|
||||||
),
|
|
||||||
lastSessionToken: this.lastTestSessionToken,
|
lastSessionToken: this.lastTestSessionToken,
|
||||||
),
|
),
|
||||||
// NEW STATISTICS FIELDS
|
// NEW STATISTICS FIELDS
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:drift/drift.dart' as drift;
|
import 'package:drift/drift.dart' as drift;
|
||||||
|
import 'package:mnemo_cards_backend/database/daos/user_dao.dart';
|
||||||
import 'package:mnemo_cards_backend/database/database.dart';
|
import 'package:mnemo_cards_backend/database/database.dart';
|
||||||
import 'package:mnemo_cards_backend/packs/card_pack_drift_extension.dart';
|
import 'package:mnemo_cards_backend/packs/card_pack_drift_extension.dart';
|
||||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||||
|
|
@ -24,6 +25,26 @@ extension UserToUserModel on User {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension UserWithDataToUserDataModel on UserWithData {
|
||||||
|
Future<UserDataModel> toUserDataModel() async {
|
||||||
|
if (userData == null) {
|
||||||
|
return UserDataModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
return UserDataModel(
|
||||||
|
id: userData!.id,
|
||||||
|
lastTestSessionToken: userData!.lastTestSessionToken,
|
||||||
|
lastTimeOnline: userData!.lastTimeOnline?.toDateTime(),
|
||||||
|
tags: userData!.tags,
|
||||||
|
totalStudyTimeMinutes: userData!.totalStudyTimeMinutes,
|
||||||
|
currentStreak: userData!.currentStreak,
|
||||||
|
longestStreak: userData!.longestStreak,
|
||||||
|
// words, packProgress, studyDates, categoryMinutes, achievements
|
||||||
|
// are loaded separately from database when needed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Extension для конвертации UserModel в User (Drift)
|
/// Extension для конвертации UserModel в User (Drift)
|
||||||
extension UserModelToUser on UserModel {
|
extension UserModelToUser on UserModel {
|
||||||
UsersCompanion toUsersCompanion() {
|
UsersCompanion toUsersCompanion() {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import '../statistics/statistics_calculator.dart';
|
||||||
import '../statistics/achievement_manager.dart';
|
import '../statistics/achievement_manager.dart';
|
||||||
import '../statistics/word_statistics_manager.dart';
|
import '../statistics/word_statistics_manager.dart';
|
||||||
import 'secure.dart';
|
import 'secure.dart';
|
||||||
import 'user_repository.dart';
|
import '../repository/export.dart';
|
||||||
|
|
||||||
Map<String?, DateTime> _onlineUsers = {};
|
Map<String?, DateTime> _onlineUsers = {};
|
||||||
|
|
||||||
|
|
@ -23,6 +23,7 @@ Map<String?, DateTime> _onlineUsers = {};
|
||||||
class UserManager {
|
class UserManager {
|
||||||
final AppDatabase _db;
|
final AppDatabase _db;
|
||||||
final UserRepository _userRepository;
|
final UserRepository _userRepository;
|
||||||
|
final PackRepository _packRepository;
|
||||||
final FreePacksDistributor _freePacksDistributor;
|
final FreePacksDistributor _freePacksDistributor;
|
||||||
final SessionTracker _sessionTracker;
|
final SessionTracker _sessionTracker;
|
||||||
final StatisticsCalculator _statisticsCalculator;
|
final StatisticsCalculator _statisticsCalculator;
|
||||||
|
|
@ -32,6 +33,7 @@ class UserManager {
|
||||||
UserManager(
|
UserManager(
|
||||||
this._db,
|
this._db,
|
||||||
this._userRepository,
|
this._userRepository,
|
||||||
|
this._packRepository,
|
||||||
this._freePacksDistributor,
|
this._freePacksDistributor,
|
||||||
this._sessionTracker,
|
this._sessionTracker,
|
||||||
this._statisticsCalculator,
|
this._statisticsCalculator,
|
||||||
|
|
@ -50,15 +52,15 @@ class UserManager {
|
||||||
throw Exception('Cant create token for empty id');
|
throw Exception('Cant create token for empty id');
|
||||||
}
|
}
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final token = await _db.userDao.getTokenByUserId(user.id!);
|
final token = await _userRepository.getTokenByUserId(user.id!);
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
if (token.expires.dateTime.isAfter(now)) {
|
if (token.expires.dateTime.isAfter(now)) {
|
||||||
return token.token;
|
return token.token;
|
||||||
}
|
}
|
||||||
await _db.userDao.softDeleteToken(token.id);
|
await _userRepository.softDeleteToken(token.id);
|
||||||
}
|
}
|
||||||
final userToken = Secure.token();
|
final userToken = Secure.token();
|
||||||
await _db.userDao.createToken(
|
await _userRepository.createToken(
|
||||||
TokensCompanion.insert(
|
TokensCompanion.insert(
|
||||||
token: userToken,
|
token: userToken,
|
||||||
externalUserId: externalId,
|
externalUserId: externalId,
|
||||||
|
|
@ -70,13 +72,13 @@ class UserManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<UserModel?> getUserByToken(String authToken) async {
|
Future<UserModel?> getUserByToken(String authToken) async {
|
||||||
final token = await _db.userDao.getTokenByValue(authToken);
|
final token = await _userRepository.getTokenByValue(authToken);
|
||||||
if (token == null) {
|
if (token == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
if (token.expires.dateTime.isBefore(now)) {
|
if (token.expires.dateTime.isBefore(now)) {
|
||||||
await _db.userDao.softDeleteToken(token.id);
|
await _userRepository.softDeleteToken(token.id);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
final user = await fetchUser(token.userId);
|
final user = await fetchUser(token.userId);
|
||||||
|
|
@ -99,7 +101,7 @@ class UserManager {
|
||||||
for (final userId in userIds) {
|
for (final userId in userIds) {
|
||||||
final lastOnline = _onlineUsers[userId];
|
final lastOnline = _onlineUsers[userId];
|
||||||
if (lastOnline != null) {
|
if (lastOnline != null) {
|
||||||
await _db.userDao.updateUserDataPartial(
|
await _userRepository.updateUserDataPartial(
|
||||||
UserDatasCompanion(
|
UserDatasCompanion(
|
||||||
userId: drift.Value(userId),
|
userId: drift.Value(userId),
|
||||||
lastTimeOnline: drift.Value(PgDateTime(lastOnline)),
|
lastTimeOnline: drift.Value(PgDateTime(lastOnline)),
|
||||||
|
|
@ -217,7 +219,7 @@ class UserManager {
|
||||||
// Получить или создать UserData
|
// Получить или создать UserData
|
||||||
var userData = await _userRepository.getUserData(user.id!);
|
var userData = await _userRepository.getUserData(user.id!);
|
||||||
if (userData == null) {
|
if (userData == null) {
|
||||||
await _db.userDao.createUserData(
|
await _userRepository.createUserData(
|
||||||
UserDatasCompanion.insert(userId: user.id!),
|
UserDatasCompanion.insert(userId: user.id!),
|
||||||
);
|
);
|
||||||
userData = await _userRepository.getUserData(user.id!);
|
userData = await _userRepository.getUserData(user.id!);
|
||||||
|
|
@ -282,9 +284,13 @@ class UserManager {
|
||||||
for (final wordStat in testStat.words.words) {
|
for (final wordStat in testStat.words.words) {
|
||||||
// Найти карточку по слову (original)
|
// Найти карточку по слову (original)
|
||||||
// Примечание: если несколько карточек с одинаковым original, берем первую
|
// Примечание: если несколько карточек с одинаковым original, берем первую
|
||||||
final cards = await _db.packDao.searchCardsByOriginal(wordStat.word);
|
final cardModels = await _packRepository.searchCardsByOriginal(
|
||||||
if (cards.isNotEmpty) {
|
wordStat.word,
|
||||||
final card = cards.first;
|
);
|
||||||
|
if (cardModels.isNotEmpty) {
|
||||||
|
final cardModel = cardModels.first;
|
||||||
|
final card = await _db.packDao.getCardById(cardModel.id!);
|
||||||
|
if (card == null) continue;
|
||||||
|
|
||||||
// Записать ответы
|
// Записать ответы
|
||||||
// correct и incorrect в WordStatisticsDto - это уже агрегированные значения
|
// correct и incorrect в WordStatisticsDto - это уже агрегированные значения
|
||||||
|
|
@ -324,7 +330,7 @@ class UserManager {
|
||||||
final longestStreak = math.max(userData.longestStreak, currentStreak);
|
final longestStreak = math.max(userData.longestStreak, currentStreak);
|
||||||
|
|
||||||
// Обновить UserData (без studyDates - это поле удалено)
|
// Обновить UserData (без studyDates - это поле удалено)
|
||||||
await _db.userDao.updateUserDataPartial(
|
await _userRepository.updateUserDataPartial(
|
||||||
UserDatasCompanion(
|
UserDatasCompanion(
|
||||||
userId: drift.Value(user.id!),
|
userId: drift.Value(user.id!),
|
||||||
lastTestSessionToken: drift.Value(testStat.sessionToken),
|
lastTestSessionToken: drift.Value(testStat.sessionToken),
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,14 @@ extension UserModelExtension on UserModel {
|
||||||
///
|
///
|
||||||
/// Примечание: для расчета packProgress, studyDates, categoryMinutes на лету
|
/// Примечание: для расчета packProgress, studyDates, categoryMinutes на лету
|
||||||
/// используйте toDtoWithCalculatedData()
|
/// используйте toDtoWithCalculatedData()
|
||||||
Future<UserDto> toDto() async {
|
/// [userData] - опциональные данные пользователя
|
||||||
|
/// [subscriptionModel] - опциональная подписка пользователя
|
||||||
|
Future<UserDto> toDto({
|
||||||
|
UserDataModel? userData,
|
||||||
|
UserSubscriptionModel? subscriptionModel,
|
||||||
|
}) async {
|
||||||
final activeSubscription =
|
final activeSubscription =
|
||||||
subscriptionModel != null && subscriptionModel!.isActive;
|
subscriptionModel != null && subscriptionModel.isActive;
|
||||||
return UserDto(
|
return UserDto(
|
||||||
id: id,
|
id: id,
|
||||||
name: name,
|
name: name,
|
||||||
|
|
@ -27,14 +32,18 @@ extension UserModelExtension on UserModel {
|
||||||
///
|
///
|
||||||
/// Используется когда нужно рассчитать packProgress, studyDates, categoryMinutes
|
/// Используется когда нужно рассчитать packProgress, studyDates, categoryMinutes
|
||||||
/// из WordStatistics и StudySessions вместо использования удаленных полей UserDatas
|
/// из WordStatistics и StudySessions вместо использования удаленных полей UserDatas
|
||||||
|
/// [userData] - опциональные данные пользователя
|
||||||
|
/// [subscriptionModel] - опциональная подписка пользователя
|
||||||
Future<UserDto> toDtoWithCalculatedData({
|
Future<UserDto> toDtoWithCalculatedData({
|
||||||
required List<PackProgressDto> packProgress,
|
required List<PackProgressDto> packProgress,
|
||||||
required List<DateTime> studyDates,
|
required List<DateTime> studyDates,
|
||||||
required Map<String, int> categoryMinutes,
|
required Map<String, int> categoryMinutes,
|
||||||
AllWordsStatisticsDto? wordsStatistics,
|
AllWordsStatisticsDto? wordsStatistics,
|
||||||
|
UserDataModel? userData,
|
||||||
|
UserSubscriptionModel? subscriptionModel,
|
||||||
}) async {
|
}) async {
|
||||||
final activeSubscription =
|
final activeSubscription =
|
||||||
subscriptionModel != null && subscriptionModel!.isActive;
|
subscriptionModel != null && subscriptionModel.isActive;
|
||||||
return UserDto(
|
return UserDto(
|
||||||
id: id,
|
id: id,
|
||||||
name: name,
|
name: name,
|
||||||
|
|
|
||||||
|
|
@ -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:json_annotation/json_annotation.dart';
|
||||||
import 'package:copy_with_extension/copy_with_extension.dart';
|
import 'package:copy_with_extension/copy_with_extension.dart';
|
||||||
import 'discount_model.dart';
|
|
||||||
|
|
||||||
part 'discount_campaign_model.g.dart';
|
part 'discount_campaign_model.g.dart';
|
||||||
|
|
||||||
|
|
@ -14,8 +13,6 @@ class DiscountCampaignModel {
|
||||||
final DiscountCampaignModelStatus status;
|
final DiscountCampaignModelStatus status;
|
||||||
final String? name;
|
final String? name;
|
||||||
final List<String> tags;
|
final List<String> tags;
|
||||||
// Relations - loaded separately from database
|
|
||||||
final List<DiscountModel> discounts = [];
|
|
||||||
|
|
||||||
DiscountCampaignModel({
|
DiscountCampaignModel({
|
||||||
required this.start,
|
required this.start,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
import 'package:json_annotation/json_annotation.dart';
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
import 'package:copy_with_extension/copy_with_extension.dart';
|
import 'package:copy_with_extension/copy_with_extension.dart';
|
||||||
import 'package:mnemo_cards_common_backend/src/models/product_model.dart';
|
import 'package:mnemo_cards_common_backend/src/models/product_model.dart';
|
||||||
import 'package:mnemo_cards_common_backend/src/models/user/user_data_model.dart';
|
|
||||||
|
|
||||||
import 'discount_campaign_model.dart';
|
|
||||||
|
|
||||||
part 'discount_model.g.dart';
|
part 'discount_model.g.dart';
|
||||||
|
|
||||||
|
|
@ -13,9 +10,6 @@ class DiscountModel implements MnemoCardsProductModel {
|
||||||
String? id;
|
String? id;
|
||||||
final double discountPercent; // 0 - 100
|
final double discountPercent; // 0 - 100
|
||||||
final List<MnemoCardsProductModelBase> products;
|
final List<MnemoCardsProductModelBase> products;
|
||||||
// Relations - loaded separately from database
|
|
||||||
final List<UserDataModel> usersData = [];
|
|
||||||
final List<DiscountCampaignModel> campaigns = [];
|
|
||||||
|
|
||||||
DiscountModel({
|
DiscountModel({
|
||||||
required this.discountPercent,
|
required this.discountPercent,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import 'package:json_annotation/json_annotation.dart';
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
import 'package:copy_with_extension/copy_with_extension.dart';
|
import 'package:copy_with_extension/copy_with_extension.dart';
|
||||||
import 'package:mnemo_cards_common_backend/src/models/card_pack_model.dart';
|
|
||||||
import 'package:mnemo_cards_common_backend/src/models/voice_model.dart';
|
|
||||||
|
|
||||||
part 'game_card_model.g.dart';
|
part 'game_card_model.g.dart';
|
||||||
|
|
||||||
|
|
@ -25,9 +23,6 @@ class GameCardModel {
|
||||||
final String? imageBack;
|
final String? imageBack;
|
||||||
@JsonKey(name: 'back')
|
@JsonKey(name: 'back')
|
||||||
final String? back;
|
final String? back;
|
||||||
// Relations - loaded separately from database
|
|
||||||
final List<CardPackModel> packs = [];
|
|
||||||
final List<VoiceModel> voices = [];
|
|
||||||
|
|
||||||
GameCardModel({
|
GameCardModel({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import 'package:copy_with_extension/copy_with_extension.dart';
|
import 'package:copy_with_extension/copy_with_extension.dart';
|
||||||
import 'package:json_annotation/json_annotation.dart';
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
import 'package:mnemo_cards_common_backend/src/models/card_pack_model.dart';
|
|
||||||
|
|
||||||
import 'test_question_model.dart';
|
import 'test_question_model.dart';
|
||||||
|
|
||||||
|
|
@ -11,7 +10,6 @@ part 'test_model.g.dart';
|
||||||
class TestModel {
|
class TestModel {
|
||||||
// Relations - loaded separately from database
|
// Relations - loaded separately from database
|
||||||
final List<TestQuestionModel> questions = [];
|
final List<TestQuestionModel> questions = [];
|
||||||
final List<CardPackModel> packs = [];
|
|
||||||
|
|
||||||
final String name;
|
final String name;
|
||||||
final String? color;
|
final String? color;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import 'package:copy_with_extension/copy_with_extension.dart';
|
import 'package:copy_with_extension/copy_with_extension.dart';
|
||||||
import 'package:json_annotation/json_annotation.dart';
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
import 'package:mnemo_cards_common_backend/src/models/game_tests/test_model.dart';
|
|
||||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
|
|
||||||
part 'test_question_model.g.dart';
|
part 'test_question_model.g.dart';
|
||||||
|
|
@ -12,9 +11,6 @@ class TestQuestionModel {
|
||||||
final TestQuestionType questionType;
|
final TestQuestionType questionType;
|
||||||
final String body;
|
final String body;
|
||||||
|
|
||||||
// Relations - loaded separately from database
|
|
||||||
final List<TestModel> tests = [];
|
|
||||||
|
|
||||||
String? id;
|
String? id;
|
||||||
|
|
||||||
TestQuestionModel({
|
TestQuestionModel({
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import 'package:copy_with_extension/copy_with_extension.dart';
|
import 'package:copy_with_extension/copy_with_extension.dart';
|
||||||
import 'package:json_annotation/json_annotation.dart';
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
import 'package:mnemo_cards_common_backend/src/models/game_tests/test_model.dart';
|
|
||||||
import 'package:mnemo_cards_common_backend/src/models/user/user_data_model.dart';
|
|
||||||
|
|
||||||
import '../statistics/word_statistics_model.dart';
|
import '../statistics/word_statistics_model.dart';
|
||||||
|
|
||||||
|
|
@ -10,9 +8,6 @@ part 'test_statistics_model.g.dart';
|
||||||
@CopyWith()
|
@CopyWith()
|
||||||
class TestStatisticsModel {
|
class TestStatisticsModel {
|
||||||
final String id;
|
final String id;
|
||||||
// Relations - loaded separately from database
|
|
||||||
String? testId;
|
|
||||||
String? userId;
|
|
||||||
final List<TestAttempt> attempts;
|
final List<TestAttempt> attempts;
|
||||||
|
|
||||||
TestStatisticsModel({
|
TestStatisticsModel({
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ extension PromoCodesCampaignModelExt on PromoCodesCampaignModel {
|
||||||
Future<PromoCodesCampaignDto> toDto({bool withCodes = false}) async {
|
Future<PromoCodesCampaignDto> toDto({bool withCodes = false}) async {
|
||||||
List<String>? codes;
|
List<String>? codes;
|
||||||
if (withCodes) {
|
if (withCodes) {
|
||||||
codes = promoCodes.map((c) => c.code).toList();
|
// promoCodes should be loaded separately from database if needed
|
||||||
|
codes = null; // TODO: Load promoCodes from database if withCodes is true
|
||||||
}
|
}
|
||||||
return PromoCodesCampaignDto(
|
return PromoCodesCampaignDto(
|
||||||
id: id,
|
id: id,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,4 @@
|
||||||
import 'package:json_annotation/json_annotation.dart';
|
|
||||||
import 'package:copy_with_extension/copy_with_extension.dart';
|
import 'package:copy_with_extension/copy_with_extension.dart';
|
||||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
|
||||||
|
|
||||||
import 'promo_codes_campaign_model.dart';
|
|
||||||
|
|
||||||
part 'promo_code_model.g.dart';
|
part 'promo_code_model.g.dart';
|
||||||
|
|
||||||
|
|
@ -11,8 +7,6 @@ class PromoCodeModel {
|
||||||
String? id;
|
String? id;
|
||||||
final String code;
|
final String code;
|
||||||
final int activations;
|
final int activations;
|
||||||
// Relations - loaded separately from database
|
|
||||||
PromoCodesCampaignModel? campaign;
|
|
||||||
// Individual promo code
|
// Individual promo code
|
||||||
String? userId;
|
String? userId;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import 'package:json_annotation/json_annotation.dart';
|
||||||
import 'package:copy_with_extension/copy_with_extension.dart';
|
import 'package:copy_with_extension/copy_with_extension.dart';
|
||||||
|
|
||||||
import '../product_model.dart';
|
import '../product_model.dart';
|
||||||
import 'promo_code_model.dart';
|
|
||||||
|
|
||||||
part 'promo_codes_campaign_model.g.dart';
|
part 'promo_codes_campaign_model.g.dart';
|
||||||
|
|
||||||
|
|
@ -15,8 +14,6 @@ class PromoCodesCampaignModel {
|
||||||
final int activationsPerCode;
|
final int activationsPerCode;
|
||||||
final int activationsPerUser;
|
final int activationsPerUser;
|
||||||
final int generationSize;
|
final int generationSize;
|
||||||
// Relations - loaded separately from database
|
|
||||||
final List<PromoCodeModel> promoCodes = [];
|
|
||||||
final DateTime start;
|
final DateTime start;
|
||||||
final DateTime finish;
|
final DateTime finish;
|
||||||
@JsonKey(name: 'status')
|
@JsonKey(name: 'status')
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import 'package:json_annotation/json_annotation.dart';
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
import 'package:copy_with_extension/copy_with_extension.dart';
|
import 'package:copy_with_extension/copy_with_extension.dart';
|
||||||
import 'package:mnemo_cards_common_backend/src/models/user/user_model.dart';
|
|
||||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
|
|
||||||
part 'user_subscription_model.g.dart';
|
part 'user_subscription_model.g.dart';
|
||||||
|
|
@ -8,8 +7,6 @@ part 'user_subscription_model.g.dart';
|
||||||
@CopyWith()
|
@CopyWith()
|
||||||
class UserSubscriptionModel {
|
class UserSubscriptionModel {
|
||||||
String? id;
|
String? id;
|
||||||
// Relations - loaded separately from database
|
|
||||||
UserModel? user;
|
|
||||||
|
|
||||||
final DateTime start;
|
final DateTime start;
|
||||||
final DateTime finish;
|
final DateTime finish;
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,6 @@ import 'package:json_annotation/json_annotation.dart';
|
||||||
import 'package:copy_with_extension/copy_with_extension.dart';
|
import 'package:copy_with_extension/copy_with_extension.dart';
|
||||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
|
|
||||||
import '../discount/discount_model.dart';
|
|
||||||
import '../game_tests/test_statistics_model.dart';
|
|
||||||
import '../promo_code/promo_code_model.dart';
|
|
||||||
import '../statistics/word_statistics_model.dart';
|
import '../statistics/word_statistics_model.dart';
|
||||||
import '../statistics/pack_progress_model.dart';
|
import '../statistics/pack_progress_model.dart';
|
||||||
import '../statistics/achievement_model.dart';
|
import '../statistics/achievement_model.dart';
|
||||||
|
|
@ -14,11 +11,6 @@ part 'user_data_model.g.dart';
|
||||||
@CopyWith()
|
@CopyWith()
|
||||||
class UserDataModel {
|
class UserDataModel {
|
||||||
String? id;
|
String? id;
|
||||||
// Relations - loaded separately from database
|
|
||||||
final List<TestStatisticsModel> testsStatistics = [];
|
|
||||||
final List<PromoCodeModel> activatedPromoCodes = [];
|
|
||||||
final List<PromoCodeModel> individualPromoCodes = [];
|
|
||||||
final List<DiscountModel> activeDiscounts = [];
|
|
||||||
|
|
||||||
final List<WordStatisticsModel> words;
|
final List<WordStatisticsModel> words;
|
||||||
final String? lastTestSessionToken;
|
final String? lastTestSessionToken;
|
||||||
|
|
@ -36,18 +28,15 @@ class UserDataModel {
|
||||||
final int longestStreak;
|
final int longestStreak;
|
||||||
|
|
||||||
/// Progress tracking for each pack
|
/// Progress tracking for each pack
|
||||||
@JsonKey(fromJson: _packProgressFromJson, toJson: _packProgressToJson)
|
|
||||||
final List<PackProgressModel> packProgress;
|
final List<PackProgressModel> packProgress;
|
||||||
|
|
||||||
/// List of dates when user studied (for streak calculation)
|
/// List of dates when user studied (for streak calculation)
|
||||||
final List<DateTime> studyDates;
|
final List<DateTime> studyDates;
|
||||||
|
|
||||||
/// Study time by category/language in minutes
|
/// Study time by category/language in minutes
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
|
||||||
final Map<String, int> categoryMinutes;
|
final Map<String, int> categoryMinutes;
|
||||||
|
|
||||||
/// User's achievements
|
/// User's achievements
|
||||||
@JsonKey(fromJson: _achievementsFromJson, toJson: _achievementsToJson)
|
|
||||||
final List<AchievementModel> achievements;
|
final List<AchievementModel> achievements;
|
||||||
|
|
||||||
UserDataModel({
|
UserDataModel({
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ abstract class _$UserModelCWProxy {
|
||||||
|
|
||||||
UserModel purchases(List<String> purchases);
|
UserModel purchases(List<String> purchases);
|
||||||
|
|
||||||
|
UserModel packs(List<CardPackModel> packs);
|
||||||
|
|
||||||
UserModel userSettings(String? userSettings);
|
UserModel userSettings(String? userSettings);
|
||||||
|
|
||||||
/// Creates a new instance with the provided field values.
|
/// Creates a new instance with the provided field values.
|
||||||
|
|
@ -35,6 +37,7 @@ abstract class _$UserModelCWProxy {
|
||||||
String? telegram,
|
String? telegram,
|
||||||
bool admin,
|
bool admin,
|
||||||
List<String> purchases,
|
List<String> purchases,
|
||||||
|
List<CardPackModel> packs,
|
||||||
String? userSettings,
|
String? userSettings,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -64,6 +67,9 @@ class _$UserModelCWProxyImpl implements _$UserModelCWProxy {
|
||||||
@override
|
@override
|
||||||
UserModel purchases(List<String> purchases) => call(purchases: purchases);
|
UserModel purchases(List<String> purchases) => call(purchases: purchases);
|
||||||
|
|
||||||
|
@override
|
||||||
|
UserModel packs(List<CardPackModel> packs) => call(packs: packs);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
UserModel userSettings(String? userSettings) =>
|
UserModel userSettings(String? userSettings) =>
|
||||||
call(userSettings: userSettings);
|
call(userSettings: userSettings);
|
||||||
|
|
@ -83,6 +89,7 @@ class _$UserModelCWProxyImpl implements _$UserModelCWProxy {
|
||||||
Object? telegram = const $CopyWithPlaceholder(),
|
Object? telegram = const $CopyWithPlaceholder(),
|
||||||
Object? admin = const $CopyWithPlaceholder(),
|
Object? admin = const $CopyWithPlaceholder(),
|
||||||
Object? purchases = const $CopyWithPlaceholder(),
|
Object? purchases = const $CopyWithPlaceholder(),
|
||||||
|
Object? packs = const $CopyWithPlaceholder(),
|
||||||
Object? userSettings = const $CopyWithPlaceholder(),
|
Object? userSettings = const $CopyWithPlaceholder(),
|
||||||
}) {
|
}) {
|
||||||
return UserModel(
|
return UserModel(
|
||||||
|
|
@ -110,6 +117,10 @@ class _$UserModelCWProxyImpl implements _$UserModelCWProxy {
|
||||||
? _value.purchases
|
? _value.purchases
|
||||||
// ignore: cast_nullable_to_non_nullable
|
// ignore: cast_nullable_to_non_nullable
|
||||||
: purchases as List<String>,
|
: purchases as List<String>,
|
||||||
|
packs: packs == const $CopyWithPlaceholder() || packs == null
|
||||||
|
? _value.packs
|
||||||
|
// ignore: cast_nullable_to_non_nullable
|
||||||
|
: packs as List<CardPackModel>,
|
||||||
userSettings: userSettings == const $CopyWithPlaceholder()
|
userSettings: userSettings == const $CopyWithPlaceholder()
|
||||||
? _value.userSettings
|
? _value.userSettings
|
||||||
// ignore: cast_nullable_to_non_nullable
|
// ignore: cast_nullable_to_non_nullable
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import 'package:copy_with_extension/copy_with_extension.dart';
|
import 'package:copy_with_extension/copy_with_extension.dart';
|
||||||
import 'package:json_annotation/json_annotation.dart';
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
import 'game_card_model.dart';
|
|
||||||
|
|
||||||
part 'voice_model.g.dart';
|
part 'voice_model.g.dart';
|
||||||
|
|
||||||
@JsonSerializable(fieldRename: FieldRename.snake)
|
@JsonSerializable(fieldRename: FieldRename.snake)
|
||||||
|
|
@ -18,8 +16,6 @@ class VoiceModel {
|
||||||
final DateTime? updatedAt;
|
final DateTime? updatedAt;
|
||||||
@JsonKey(defaultValue: false)
|
@JsonKey(defaultValue: false)
|
||||||
final bool isDeleted;
|
final bool isDeleted;
|
||||||
// Relations - loaded separately from database
|
|
||||||
final List<GameCardModel> cards = [];
|
|
||||||
|
|
||||||
VoiceModel({
|
VoiceModel({
|
||||||
required this.phrase,
|
required this.phrase,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue