backaend + admin
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Deploy Admin Panel / Deploy Admin Panel (push) Waiting to run
Deploy Admin Panel / Admin Panel Verification (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Deploy Admin Panel / Deploy Admin Panel (push) Waiting to run
Deploy Admin Panel / Admin Panel Verification (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
This commit is contained in:
parent
c7b01e0dd0
commit
e4e6a259c7
12 changed files with 396 additions and 139 deletions
|
|
@ -2,18 +2,38 @@ import { adminApiClient } from './client'
|
||||||
import type { GameCardDto, PaginatedResponse } from '@/types/models'
|
import type { GameCardDto, PaginatedResponse } from '@/types/models'
|
||||||
import type { AxiosError } from 'axios'
|
import type { AxiosError } from 'axios'
|
||||||
|
|
||||||
export class CardsApiError extends Error {
|
export interface CardsApiError {
|
||||||
constructor(
|
message: string
|
||||||
message: string,
|
statusCode?: number
|
||||||
public statusCode?: number,
|
field?: string
|
||||||
public field?: string,
|
originalError?: unknown
|
||||||
public originalError?: unknown
|
name: 'CardsApiError'
|
||||||
) {
|
}
|
||||||
super(message)
|
|
||||||
this.name = 'CardsApiError'
|
export function createCardsApiError(
|
||||||
|
message: string,
|
||||||
|
statusCode?: number,
|
||||||
|
field?: string,
|
||||||
|
originalError?: unknown
|
||||||
|
): CardsApiError {
|
||||||
|
return {
|
||||||
|
message,
|
||||||
|
statusCode,
|
||||||
|
field,
|
||||||
|
originalError,
|
||||||
|
name: 'CardsApiError',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isCardsApiError(error: unknown): error is CardsApiError {
|
||||||
|
return (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'name' in error &&
|
||||||
|
error.name === 'CardsApiError'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const cardsApi = {
|
export const cardsApi = {
|
||||||
// Get all cards with pagination and search
|
// Get all cards with pagination and search
|
||||||
getCards: async (params?: {
|
getCards: async (params?: {
|
||||||
|
|
@ -32,7 +52,7 @@ export const cardsApi = {
|
||||||
return response.data
|
return response.data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const axiosError = error as AxiosError<{ error?: string; message?: string; field?: string }>
|
const axiosError = error as AxiosError<{ error?: string; message?: string; field?: string }>
|
||||||
throw new CardsApiError(
|
throw createCardsApiError(
|
||||||
axiosError.response?.data?.message ||
|
axiosError.response?.data?.message ||
|
||||||
axiosError.response?.data?.error ||
|
axiosError.response?.data?.error ||
|
||||||
'Failed to load cards',
|
'Failed to load cards',
|
||||||
|
|
@ -50,7 +70,7 @@ export const cardsApi = {
|
||||||
return response.data
|
return response.data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const axiosError = error as AxiosError<{ error?: string; message?: string }>
|
const axiosError = error as AxiosError<{ error?: string; message?: string }>
|
||||||
throw new CardsApiError(
|
throw createCardsApiError(
|
||||||
axiosError.response?.data?.message ||
|
axiosError.response?.data?.message ||
|
||||||
axiosError.response?.data?.error ||
|
axiosError.response?.data?.error ||
|
||||||
`Failed to load card ${cardId}`,
|
`Failed to load card ${cardId}`,
|
||||||
|
|
@ -82,7 +102,7 @@ export const cardsApi = {
|
||||||
fullMessage += ` (Field: ${axiosError.response.data.field})`
|
fullMessage += ` (Field: ${axiosError.response.data.field})`
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new CardsApiError(
|
throw createCardsApiError(
|
||||||
fullMessage,
|
fullMessage,
|
||||||
axiosError.response?.status,
|
axiosError.response?.status,
|
||||||
axiosError.response?.data?.field,
|
axiosError.response?.data?.field,
|
||||||
|
|
@ -98,7 +118,7 @@ export const cardsApi = {
|
||||||
return response.data
|
return response.data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const axiosError = error as AxiosError<{ error?: string; message?: string }>
|
const axiosError = error as AxiosError<{ error?: string; message?: string }>
|
||||||
throw new CardsApiError(
|
throw createCardsApiError(
|
||||||
axiosError.response?.data?.message ||
|
axiosError.response?.data?.message ||
|
||||||
axiosError.response?.data?.error ||
|
axiosError.response?.data?.error ||
|
||||||
`Failed to delete card ${cardId}`,
|
`Failed to delete card ${cardId}`,
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,38 @@ import { adminApiClient } from './client'
|
||||||
import type { EditCardPackDto, CardPackPreviewDto, PaginatedResponse } from '@/types/models'
|
import type { EditCardPackDto, CardPackPreviewDto, PaginatedResponse } from '@/types/models'
|
||||||
import type { AxiosError } from 'axios'
|
import type { AxiosError } from 'axios'
|
||||||
|
|
||||||
export class PacksApiError extends Error {
|
export interface PacksApiError {
|
||||||
constructor(
|
message: string
|
||||||
message: string,
|
statusCode?: number
|
||||||
public statusCode?: number,
|
field?: string
|
||||||
public field?: string,
|
originalError?: unknown
|
||||||
public originalError?: unknown
|
name: 'PacksApiError'
|
||||||
) {
|
}
|
||||||
super(message)
|
|
||||||
this.name = 'PacksApiError'
|
export function createPacksApiError(
|
||||||
|
message: string,
|
||||||
|
statusCode?: number,
|
||||||
|
field?: string,
|
||||||
|
originalError?: unknown
|
||||||
|
): PacksApiError {
|
||||||
|
return {
|
||||||
|
message,
|
||||||
|
statusCode,
|
||||||
|
field,
|
||||||
|
originalError,
|
||||||
|
name: 'PacksApiError',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isPacksApiError(error: unknown): error is PacksApiError {
|
||||||
|
return (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'name' in error &&
|
||||||
|
error.name === 'PacksApiError'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const packsApi = {
|
export const packsApi = {
|
||||||
// Get all packs with pagination and search
|
// Get all packs with pagination and search
|
||||||
getPacks: async (params?: {
|
getPacks: async (params?: {
|
||||||
|
|
@ -39,7 +59,7 @@ export const packsApi = {
|
||||||
if (axiosError.response?.status === 400) {
|
if (axiosError.response?.status === 400) {
|
||||||
const errorData = axiosError.response.data
|
const errorData = axiosError.response.data
|
||||||
if (errorData?.message?.includes('Limit')) {
|
if (errorData?.message?.includes('Limit')) {
|
||||||
throw new PacksApiError(
|
throw createPacksApiError(
|
||||||
`Invalid limit: ${errorData.message}. Maximum allowed limit is 100.`,
|
`Invalid limit: ${errorData.message}. Maximum allowed limit is 100.`,
|
||||||
axiosError.response.status,
|
axiosError.response.status,
|
||||||
'limit',
|
'limit',
|
||||||
|
|
@ -48,7 +68,7 @@ export const packsApi = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new PacksApiError(
|
throw createPacksApiError(
|
||||||
axiosError.response?.data?.message ||
|
axiosError.response?.data?.message ||
|
||||||
axiosError.response?.data?.error ||
|
axiosError.response?.data?.error ||
|
||||||
'Failed to load packs',
|
'Failed to load packs',
|
||||||
|
|
@ -68,7 +88,7 @@ export const packsApi = {
|
||||||
const axiosError = error as AxiosError<{ error?: string; message?: string }>
|
const axiosError = error as AxiosError<{ error?: string; message?: string }>
|
||||||
|
|
||||||
if (axiosError.response?.status === 404) {
|
if (axiosError.response?.status === 404) {
|
||||||
throw new PacksApiError(
|
throw createPacksApiError(
|
||||||
`Pack not found: The pack with ID "${packId}" does not exist or has been deleted.`,
|
`Pack not found: The pack with ID "${packId}" does not exist or has been deleted.`,
|
||||||
axiosError.response.status,
|
axiosError.response.status,
|
||||||
undefined,
|
undefined,
|
||||||
|
|
@ -76,7 +96,7 @@ export const packsApi = {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new PacksApiError(
|
throw createPacksApiError(
|
||||||
axiosError.response?.data?.message ||
|
axiosError.response?.data?.message ||
|
||||||
axiosError.response?.data?.error ||
|
axiosError.response?.data?.error ||
|
||||||
`Failed to load pack ${packId}`,
|
`Failed to load pack ${packId}`,
|
||||||
|
|
@ -114,7 +134,7 @@ export const packsApi = {
|
||||||
fullMessage += ` (Field: ${axiosError.response.data.field})`
|
fullMessage += ` (Field: ${axiosError.response.data.field})`
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new PacksApiError(
|
throw createPacksApiError(
|
||||||
fullMessage,
|
fullMessage,
|
||||||
axiosError.response?.status,
|
axiosError.response?.status,
|
||||||
axiosError.response?.data?.field,
|
axiosError.response?.data?.field,
|
||||||
|
|
@ -132,7 +152,7 @@ export const packsApi = {
|
||||||
const axiosError = error as AxiosError<{ error?: string; message?: string }>
|
const axiosError = error as AxiosError<{ error?: string; message?: string }>
|
||||||
|
|
||||||
if (axiosError.response?.status === 404) {
|
if (axiosError.response?.status === 404) {
|
||||||
throw new PacksApiError(
|
throw createPacksApiError(
|
||||||
`Pack not found: The pack with ID "${packId}" does not exist and cannot be deleted.`,
|
`Pack not found: The pack with ID "${packId}" does not exist and cannot be deleted.`,
|
||||||
axiosError.response.status,
|
axiosError.response.status,
|
||||||
undefined,
|
undefined,
|
||||||
|
|
@ -140,7 +160,7 @@ export const packsApi = {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new PacksApiError(
|
throw createPacksApiError(
|
||||||
axiosError.response?.data?.message ||
|
axiosError.response?.data?.message ||
|
||||||
axiosError.response?.data?.error ||
|
axiosError.response?.data?.error ||
|
||||||
`Failed to delete pack ${packId}`,
|
`Failed to delete pack ${packId}`,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { cardsApi, CardsApiError } from '@/api/cards'
|
import { cardsApi, isCardsApiError } from '@/api/cards'
|
||||||
import { packsApi, PacksApiError } from '@/api/packs'
|
import { packsApi, isPacksApiError } from '@/api/packs'
|
||||||
import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils'
|
import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils'
|
||||||
import type { GameCardDto } from '@/types/models'
|
import type { GameCardDto } from '@/types/models'
|
||||||
import { Button } from './ui/button'
|
import { Button } from './ui/button'
|
||||||
|
|
@ -69,7 +69,7 @@ export function BulkCardEditor({ images, onComplete, onCancel }: BulkCardEditorP
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
const errorMessage = error instanceof PacksApiError
|
const errorMessage = isPacksApiError(error)
|
||||||
? error.message
|
? error.message
|
||||||
: formatApiError(error)
|
: formatApiError(error)
|
||||||
console.error('Failed to load packs:', error)
|
console.error('Failed to load packs:', error)
|
||||||
|
|
@ -126,7 +126,7 @@ export function BulkCardEditor({ images, onComplete, onCancel }: BulkCardEditorP
|
||||||
toast.success('Card saved successfully')
|
toast.success('Card saved successfully')
|
||||||
},
|
},
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
const errorMessage = error instanceof CardsApiError
|
const errorMessage = isCardsApiError(error)
|
||||||
? error.message
|
? error.message
|
||||||
: getDetailedErrorMessage(error, 'save', 'card')
|
: getDetailedErrorMessage(error, 'save', 'card')
|
||||||
toast.error(errorMessage)
|
toast.error(errorMessage)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { cardsApi, CardsApiError } from '@/api/cards'
|
import { cardsApi, isCardsApiError } from '@/api/cards'
|
||||||
import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils'
|
import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils'
|
||||||
import type { GameCardDto, PaginatedResponse } from '@/types/models'
|
import type { GameCardDto, PaginatedResponse } from '@/types/models'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
@ -81,7 +81,7 @@ export default function CardsPage() {
|
||||||
queryFn: () => cardsApi.getCards({ page, limit, search }),
|
queryFn: () => cardsApi.getCards({ page, limit, search }),
|
||||||
retry: (failureCount, error) => {
|
retry: (failureCount, error) => {
|
||||||
// Don't retry on client errors (4xx)
|
// Don't retry on client errors (4xx)
|
||||||
if (error instanceof CardsApiError && error.statusCode && error.statusCode >= 400 && error.statusCode < 500) {
|
if (isCardsApiError(error) && error.statusCode && error.statusCode >= 400 && error.statusCode < 500) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return failureCount < 2
|
return failureCount < 2
|
||||||
|
|
@ -97,7 +97,7 @@ export default function CardsPage() {
|
||||||
closeDialog()
|
closeDialog()
|
||||||
},
|
},
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
const errorMessage = error instanceof CardsApiError
|
const errorMessage = isCardsApiError(error)
|
||||||
? error.message
|
? error.message
|
||||||
: getDetailedErrorMessage(error, 'create', 'card')
|
: getDetailedErrorMessage(error, 'create', 'card')
|
||||||
toast.error(errorMessage)
|
toast.error(errorMessage)
|
||||||
|
|
@ -113,7 +113,7 @@ export default function CardsPage() {
|
||||||
closeDialog()
|
closeDialog()
|
||||||
},
|
},
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
const errorMessage = error instanceof CardsApiError
|
const errorMessage = isCardsApiError(error)
|
||||||
? error.message
|
? error.message
|
||||||
: getDetailedErrorMessage(error, 'update', 'card')
|
: getDetailedErrorMessage(error, 'update', 'card')
|
||||||
toast.error(errorMessage)
|
toast.error(errorMessage)
|
||||||
|
|
@ -130,7 +130,7 @@ export default function CardsPage() {
|
||||||
setCardToDelete(null)
|
setCardToDelete(null)
|
||||||
},
|
},
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
const errorMessage = error instanceof CardsApiError
|
const errorMessage = isCardsApiError(error)
|
||||||
? error.message
|
? error.message
|
||||||
: getDetailedErrorMessage(error, 'delete', 'card')
|
: getDetailedErrorMessage(error, 'delete', 'card')
|
||||||
toast.error(errorMessage)
|
toast.error(errorMessage)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { packsApi, PacksApiError } from '@/api/packs'
|
import { packsApi, isPacksApiError } from '@/api/packs'
|
||||||
import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils'
|
import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils'
|
||||||
import type { EditCardPackDto, CardPackPreviewDto, PaginatedResponse } from '@/types/models'
|
import type { EditCardPackDto, CardPackPreviewDto, PaginatedResponse } from '@/types/models'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
@ -80,7 +80,7 @@ export default function PacksPage() {
|
||||||
queryFn: () => packsApi.getPacks({ page, limit, search, showDisabled }),
|
queryFn: () => packsApi.getPacks({ page, limit, search, showDisabled }),
|
||||||
retry: (failureCount, error) => {
|
retry: (failureCount, error) => {
|
||||||
// Don't retry on client errors (4xx)
|
// Don't retry on client errors (4xx)
|
||||||
if (error instanceof PacksApiError && error.statusCode && error.statusCode >= 400 && error.statusCode < 500) {
|
if (isPacksApiError(error) && error.statusCode && error.statusCode >= 400 && error.statusCode < 500) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return failureCount < 2
|
return failureCount < 2
|
||||||
|
|
@ -96,7 +96,7 @@ export default function PacksPage() {
|
||||||
closeDialog()
|
closeDialog()
|
||||||
},
|
},
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
const errorMessage = error instanceof PacksApiError
|
const errorMessage = isPacksApiError(error)
|
||||||
? error.message
|
? error.message
|
||||||
: getDetailedErrorMessage(error, 'create', 'pack')
|
: getDetailedErrorMessage(error, 'create', 'pack')
|
||||||
toast.error(errorMessage)
|
toast.error(errorMessage)
|
||||||
|
|
@ -112,7 +112,7 @@ export default function PacksPage() {
|
||||||
closeDialog()
|
closeDialog()
|
||||||
},
|
},
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
const errorMessage = error instanceof PacksApiError
|
const errorMessage = isPacksApiError(error)
|
||||||
? error.message
|
? error.message
|
||||||
: getDetailedErrorMessage(error, 'update', 'pack')
|
: getDetailedErrorMessage(error, 'update', 'pack')
|
||||||
toast.error(errorMessage)
|
toast.error(errorMessage)
|
||||||
|
|
@ -129,7 +129,7 @@ export default function PacksPage() {
|
||||||
setPackToDelete(null)
|
setPackToDelete(null)
|
||||||
},
|
},
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
const errorMessage = error instanceof PacksApiError
|
const errorMessage = isPacksApiError(error)
|
||||||
? error.message
|
? error.message
|
||||||
: getDetailedErrorMessage(error, 'delete', 'pack')
|
: getDetailedErrorMessage(error, 'delete', 'pack')
|
||||||
toast.error(errorMessage)
|
toast.error(errorMessage)
|
||||||
|
|
@ -186,7 +186,7 @@ export default function PacksPage() {
|
||||||
setCardsToRemove([])
|
setCardsToRemove([])
|
||||||
setIsDialogOpen(true)
|
setIsDialogOpen(true)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof PacksApiError
|
const errorMessage = isPacksApiError(error)
|
||||||
? error.message
|
? error.message
|
||||||
: getDetailedErrorMessage(error, 'load', `pack "${pack.id}"`)
|
: getDetailedErrorMessage(error, 'load', `pack "${pack.id}"`)
|
||||||
toast.error(errorMessage)
|
toast.error(errorMessage)
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,11 @@ class DiscountDao extends DatabaseAccessor<AppDatabase> with _$DiscountDaoMixin
|
||||||
|
|
||||||
// ==================== DiscountCampaigns ====================
|
// ==================== DiscountCampaigns ====================
|
||||||
|
|
||||||
/// Получить кампанию по ID
|
/// Получить кампанию по ID (только активные)
|
||||||
Future<DiscountCampaign?> getCampaignById(String id) {
|
Future<DiscountCampaign?> getCampaignById(String id) {
|
||||||
return (select(db.discountCampaigns)..where((c) => c.id.equals(id))).getSingleOrNull();
|
return (select(db.discountCampaigns)
|
||||||
|
..where((c) => c.id.equals(id) & c.isDeleted.equals(false))
|
||||||
|
).getSingleOrNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Получить все активные кампании
|
/// Получить все активные кампании
|
||||||
|
|
@ -73,23 +75,26 @@ class DiscountDao extends DatabaseAccessor<AppDatabase> with _$DiscountDaoMixin
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Удалить кампанию (soft delete)
|
/// Удалить кампанию (soft delete)
|
||||||
Future<void> deleteCampaign(String campaignId) {
|
Future<void> softDeleteCampaign(String campaignId) {
|
||||||
return (update(db.discountCampaigns)
|
return (update(db.discountCampaigns)
|
||||||
..where((c) => c.id.equals(campaignId))
|
..where((c) => c.id.equals(campaignId))
|
||||||
).write(DiscountCampaignsCompanion(
|
).write(DiscountCampaignsCompanion(
|
||||||
isDeleted: const Value(true),
|
isDeleted: const Value(true),
|
||||||
|
deletedAt: Value(DateTime.now()),
|
||||||
updatedAt: Value(PgDateTime(DateTime.now())),
|
updatedAt: Value(PgDateTime(DateTime.now())),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Discounts ====================
|
// ==================== Discounts ====================
|
||||||
|
|
||||||
/// Получить скидку по ID
|
/// Получить скидку по ID (только активные)
|
||||||
Future<Discount?> getDiscountById(String id) {
|
Future<Discount?> getDiscountById(String id) {
|
||||||
return (select(db.discounts)..where((d) => d.id.equals(id))).getSingleOrNull();
|
return (select(db.discounts)
|
||||||
|
..where((d) => d.id.equals(id) & d.isDeleted.equals(false))
|
||||||
|
).getSingleOrNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Получить скидки кампании
|
/// Получить скидки кампании (только активные)
|
||||||
Future<List<Discount>> getDiscountsByCampaignId(String campaignId) {
|
Future<List<Discount>> getDiscountsByCampaignId(String campaignId) {
|
||||||
return (select(db.discounts)
|
return (select(db.discounts)
|
||||||
..where((d) => d.campaignId.equals(campaignId))
|
..where((d) => d.campaignId.equals(campaignId))
|
||||||
|
|
@ -97,6 +102,16 @@ class DiscountDao extends DatabaseAccessor<AppDatabase> with _$DiscountDaoMixin
|
||||||
).get();
|
).get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Удалить скидку (soft delete)
|
||||||
|
Future<void> softDeleteDiscount(String discountId) {
|
||||||
|
return (update(db.discounts)..where((d) => d.id.equals(discountId)))
|
||||||
|
.write(DiscountsCompanion(
|
||||||
|
isDeleted: const Value(true),
|
||||||
|
deletedAt: Value(DateTime.now()),
|
||||||
|
updatedAt: Value(PgDateTime(DateTime.now())),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
/// Создать скидку
|
/// Создать скидку
|
||||||
Future<String> createDiscount(DiscountsCompanion discount) async {
|
Future<String> createDiscount(DiscountsCompanion discount) async {
|
||||||
final inserted = await into(db.discounts).insertReturning(discount);
|
final inserted = await into(db.discounts).insertReturning(discount);
|
||||||
|
|
@ -110,7 +125,7 @@ class DiscountDao extends DatabaseAccessor<AppDatabase> with _$DiscountDaoMixin
|
||||||
|
|
||||||
// ==================== DiscountUserDatas ====================
|
// ==================== DiscountUserDatas ====================
|
||||||
|
|
||||||
/// Получить скидки пользователя
|
/// Получить скидки пользователя (только активные)
|
||||||
Future<List<Discount>> getUserDiscounts(String userId) async {
|
Future<List<Discount>> getUserDiscounts(String userId) async {
|
||||||
final query = select(db.discounts).join([
|
final query = select(db.discounts).join([
|
||||||
innerJoin(
|
innerJoin(
|
||||||
|
|
@ -118,7 +133,8 @@ class DiscountDao extends DatabaseAccessor<AppDatabase> with _$DiscountDaoMixin
|
||||||
db.discountUserDatas.discountId.equalsExp(db.discounts.id) &
|
db.discountUserDatas.discountId.equalsExp(db.discounts.id) &
|
||||||
db.discountUserDatas.userId.equals(userId),
|
db.discountUserDatas.userId.equals(userId),
|
||||||
),
|
),
|
||||||
]);
|
])
|
||||||
|
..where(db.discounts.isDeleted.equals(false));
|
||||||
|
|
||||||
return query.map((row) => row.readTable(db.discounts)).get();
|
return query.map((row) => row.readTable(db.discounts)).get();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,14 +94,15 @@ class PackDao extends DatabaseAccessor<AppDatabase> with _$PackDaoMixin {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Получить все карточки пака
|
/// Получить все карточки пака
|
||||||
|
/// Связь теперь только через CardPackCards (packId удален из GameCards)
|
||||||
Future<List<GameCard>> getPackCards(String packId) async {
|
Future<List<GameCard>> getPackCards(String packId) async {
|
||||||
final query = select(gameCards).join([
|
final query = select(gameCards).join([
|
||||||
leftOuterJoin(
|
innerJoin(
|
||||||
cardPackCards,
|
cardPackCards,
|
||||||
cardPackCards.cardId.equalsExp(gameCards.id) &
|
cardPackCards.cardId.equalsExp(gameCards.id) &
|
||||||
cardPackCards.packId.equals(packId),
|
cardPackCards.packId.equals(packId),
|
||||||
),
|
),
|
||||||
])..where(gameCards.packId.equals(packId));
|
])..where(gameCards.isDeleted.equals(false));
|
||||||
|
|
||||||
final results = await query.get();
|
final results = await query.get();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,11 @@ class PromoCodeDao extends DatabaseAccessor<AppDatabase> with _$PromoCodeDaoMixi
|
||||||
|
|
||||||
// ==================== PromoCodesCampaigns ====================
|
// ==================== PromoCodesCampaigns ====================
|
||||||
|
|
||||||
/// Получить кампанию по ID
|
/// Получить кампанию по ID (только активные)
|
||||||
Future<PromoCodesCampaign?> getCampaignById(String id) {
|
Future<PromoCodesCampaign?> getCampaignById(String id) {
|
||||||
return (select(db.promoCodesCampaigns)..where((c) => c.id.equals(id))).getSingleOrNull();
|
return (select(db.promoCodesCampaigns)
|
||||||
|
..where((c) => c.id.equals(id) & c.isDeleted.equals(false))
|
||||||
|
).getSingleOrNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Получить все активные кампании
|
/// Получить все активные кампании
|
||||||
|
|
@ -69,24 +71,24 @@ class PromoCodeDao extends DatabaseAccessor<AppDatabase> with _$PromoCodeDaoMixi
|
||||||
|
|
||||||
// ==================== PromoCodes ====================
|
// ==================== PromoCodes ====================
|
||||||
|
|
||||||
/// Получить промокод по коду
|
/// Получить промокод по коду (только активные)
|
||||||
Future<PromoCode?> getPromoCodeByCode(String code) {
|
Future<PromoCode?> getPromoCodeByCode(String code) {
|
||||||
return (select(db.promoCodes)
|
return (select(db.promoCodes)
|
||||||
..where((pc) => pc.code.equals(code))
|
..where((pc) => pc.code.equals(code) & pc.isDeleted.equals(false))
|
||||||
).getSingleOrNull();
|
).getSingleOrNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Получить промокоды кампании
|
/// Получить промокоды кампании (только активные)
|
||||||
Future<List<PromoCode>> getPromoCodesByCampaignId(String campaignId) {
|
Future<List<PromoCode>> getPromoCodesByCampaignId(String campaignId) {
|
||||||
return (select(db.promoCodes)
|
return (select(db.promoCodes)
|
||||||
..where((pc) => pc.campaignId.equals(campaignId))
|
..where((pc) => pc.campaignId.equals(campaignId) & pc.isDeleted.equals(false))
|
||||||
).get();
|
).get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Получить индивидуальные промокоды пользователя
|
/// Получить индивидуальные промокоды пользователя (только активные)
|
||||||
Future<List<PromoCode>> getUserPromoCodes(String userId) {
|
Future<List<PromoCode>> getUserPromoCodes(String userId) {
|
||||||
return (select(db.promoCodes)
|
return (select(db.promoCodes)
|
||||||
..where((pc) => pc.userId.equals(userId))
|
..where((pc) => pc.userId.equals(userId) & pc.isDeleted.equals(false))
|
||||||
).get();
|
).get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,8 +117,23 @@ class PromoCodeDao extends DatabaseAccessor<AppDatabase> with _$PromoCodeDaoMixi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Удалить промокод
|
/// Удалить промокод (soft delete)
|
||||||
Future<void> deletePromoCode(String promoCodeId) {
|
Future<void> softDeletePromoCode(String promoCodeId) {
|
||||||
return (delete(db.promoCodes)..where((pc) => pc.id.equals(promoCodeId))).go();
|
return (update(db.promoCodes)..where((pc) => pc.id.equals(promoCodeId)))
|
||||||
|
.write(PromoCodesCompanion(
|
||||||
|
isDeleted: const Value(true),
|
||||||
|
deletedAt: Value(DateTime.now()),
|
||||||
|
updatedAt: Value(PgDateTime(DateTime.now())),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Удалить кампанию (soft delete)
|
||||||
|
Future<void> softDeleteCampaign(String campaignId) {
|
||||||
|
return (update(db.promoCodesCampaigns)..where((c) => c.id.equals(campaignId)))
|
||||||
|
.write(PromoCodesCampaignsCompanion(
|
||||||
|
isDeleted: const Value(true),
|
||||||
|
deletedAt: Value(DateTime.now()),
|
||||||
|
updatedAt: Value(PgDateTime(DateTime.now())),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,19 +12,21 @@ class TestDao extends DatabaseAccessor<AppDatabase> with _$TestDaoMixin {
|
||||||
|
|
||||||
// ==================== Tests ====================
|
// ==================== Tests ====================
|
||||||
|
|
||||||
/// Получить тест по ID
|
/// Получить тест по ID (только активные)
|
||||||
Future<Test?> getTestById(String id) {
|
Future<Test?> getTestById(String id) {
|
||||||
return (select(tests)..where((t) => t.id.equals(id))).getSingleOrNull();
|
return (select(tests)
|
||||||
|
..where((t) => t.id.equals(id) & t.isDeleted.equals(false))
|
||||||
|
).getSingleOrNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Получить все тесты
|
/// Получить все тесты (только активные)
|
||||||
Future<List<Test>> getAllTests() {
|
Future<List<Test>> getAllTests() {
|
||||||
return (select(tests)
|
return (select(tests)
|
||||||
..where((t) => t.isDeleted.equals(false))
|
..where((t) => t.isDeleted.equals(false))
|
||||||
).get();
|
).get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Получить тесты пака
|
/// Получить тесты пака (только активные)
|
||||||
Future<List<Test>> getTestsByPackId(String packId) async {
|
Future<List<Test>> getTestsByPackId(String packId) async {
|
||||||
final query = select(tests).join([
|
final query = select(tests).join([
|
||||||
innerJoin(
|
innerJoin(
|
||||||
|
|
@ -32,7 +34,8 @@ class TestDao extends DatabaseAccessor<AppDatabase> with _$TestDaoMixin {
|
||||||
testPackRelations.testId.equalsExp(tests.id) &
|
testPackRelations.testId.equalsExp(tests.id) &
|
||||||
testPackRelations.packId.equals(packId),
|
testPackRelations.packId.equals(packId),
|
||||||
),
|
),
|
||||||
]);
|
])
|
||||||
|
..where(tests.isDeleted.equals(false));
|
||||||
|
|
||||||
return query.map((row) => row.readTable(tests)).get();
|
return query.map((row) => row.readTable(tests)).get();
|
||||||
}
|
}
|
||||||
|
|
@ -70,10 +73,10 @@ class TestDao extends DatabaseAccessor<AppDatabase> with _$TestDaoMixin {
|
||||||
|
|
||||||
// ==================== TestQuestions ====================
|
// ==================== TestQuestions ====================
|
||||||
|
|
||||||
/// Получить вопросы теста
|
/// Получить вопросы теста (только активные)
|
||||||
Future<List<TestQuestion>> getTestQuestions(String testId) {
|
Future<List<TestQuestion>> getTestQuestions(String testId) {
|
||||||
return (select(testQuestions)
|
return (select(testQuestions)
|
||||||
..where((tq) => tq.testId.equals(testId))
|
..where((tq) => tq.testId.equals(testId) & tq.isDeleted.equals(false))
|
||||||
).get();
|
).get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,9 +91,14 @@ class TestDao extends DatabaseAccessor<AppDatabase> with _$TestDaoMixin {
|
||||||
return update(testQuestions).replace(question);
|
return update(testQuestions).replace(question);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Удалить вопрос теста
|
/// Удалить вопрос теста (soft delete)
|
||||||
Future<void> deleteTestQuestion(String questionId) {
|
Future<void> softDeleteTestQuestion(String questionId) {
|
||||||
return (delete(testQuestions)..where((tq) => tq.id.equals(questionId))).go();
|
return (update(testQuestions)..where((tq) => tq.id.equals(questionId)))
|
||||||
|
.write(TestQuestionsCompanion(
|
||||||
|
isDeleted: const Value(true),
|
||||||
|
deletedAt: Value(DateTime.now()),
|
||||||
|
updatedAt: Value(PgDateTime(DateTime.now())),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== TestStatistics ====================
|
// ==================== TestStatistics ====================
|
||||||
|
|
|
||||||
|
|
@ -171,25 +171,26 @@ class UserDao extends DatabaseAccessor<AppDatabase> with _$UserDaoMixin {
|
||||||
|
|
||||||
// ==================== Tokens ====================
|
// ==================== Tokens ====================
|
||||||
|
|
||||||
/// Получить токен по значению
|
/// Получить токен по значению (только активные)
|
||||||
Future<Token?> getTokenByValue(String tokenValue) {
|
Future<Token?> getTokenByValue(String tokenValue) {
|
||||||
return (select(tokens)..where((t) => t.token.equals(tokenValue)))
|
return (select(tokens)
|
||||||
.getSingleOrNull();
|
..where((t) => t.token.equals(tokenValue) & t.isDeleted.equals(false))
|
||||||
|
).getSingleOrNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Получить токен по externalUserId
|
/// Получить токен по externalUserId (только активные)
|
||||||
Future<Token?> getTokenByExternalUserId(String externalUserId) {
|
Future<Token?> getTokenByExternalUserId(String externalUserId) {
|
||||||
return (select(tokens)
|
return (select(tokens)
|
||||||
..where((t) => t.externalUserId.equals(externalUserId))
|
..where((t) => t.externalUserId.equals(externalUserId) & t.isDeleted.equals(false))
|
||||||
..orderBy([(t) => OrderingTerm.desc(t.created)])
|
..orderBy([(t) => OrderingTerm.desc(t.created)])
|
||||||
..limit(1)
|
..limit(1)
|
||||||
).getSingleOrNull();
|
).getSingleOrNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Получить токен пользователя
|
/// Получить токен пользователя (только активные)
|
||||||
Future<Token?> getTokenByUserId(String userId) {
|
Future<Token?> getTokenByUserId(String userId) {
|
||||||
return (select(tokens)
|
return (select(tokens)
|
||||||
..where((t) => t.userId.equals(userId))
|
..where((t) => t.userId.equals(userId) & t.isDeleted.equals(false))
|
||||||
..where((t) => t.expires.isBiggerThanValue(PgDateTime(DateTime.now())))
|
..where((t) => t.expires.isBiggerThanValue(PgDateTime(DateTime.now())))
|
||||||
..orderBy([(t) => OrderingTerm.desc(t.created)])
|
..orderBy([(t) => OrderingTerm.desc(t.created)])
|
||||||
).getSingleOrNull();
|
).getSingleOrNull();
|
||||||
|
|
@ -201,35 +202,48 @@ class UserDao extends DatabaseAccessor<AppDatabase> with _$UserDaoMixin {
|
||||||
return inserted.id;
|
return inserted.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Удалить токен
|
/// Удалить токен (soft delete)
|
||||||
Future<int> deleteToken(String tokenId) {
|
Future<int> softDeleteToken(String tokenId) {
|
||||||
return (delete(tokens)..where((t) => t.id.equals(tokenId))).go();
|
return (update(tokens)..where((t) => t.id.equals(tokenId)))
|
||||||
|
.write(TokensCompanion(
|
||||||
|
isDeleted: const Value(true),
|
||||||
|
deletedAt: Value(DateTime.now()),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Удалить токен по значению
|
/// Удалить токен по значению (soft delete)
|
||||||
Future<int> deleteTokenByValue(String tokenValue) {
|
Future<int> softDeleteTokenByValue(String tokenValue) {
|
||||||
return (delete(tokens)..where((t) => t.token.equals(tokenValue))).go();
|
return (update(tokens)..where((t) => t.token.equals(tokenValue)))
|
||||||
|
.write(TokensCompanion(
|
||||||
|
isDeleted: const Value(true),
|
||||||
|
deletedAt: Value(DateTime.now()),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Удалить истекшие токены
|
/// Удалить истекшие токены (soft delete)
|
||||||
Future<int> deleteExpiredTokens() {
|
Future<int> softDeleteExpiredTokens() {
|
||||||
return (delete(tokens)
|
final now = DateTime.now();
|
||||||
..where((t) => t.expires.isSmallerThanValue(PgDateTime(DateTime.now())))
|
return (update(tokens)
|
||||||
).go();
|
..where((t) => t.expires.isSmallerThanValue(PgDateTime(now)) & t.isDeleted.equals(false))
|
||||||
|
).write(TokensCompanion(
|
||||||
|
isDeleted: const Value(true),
|
||||||
|
deletedAt: Value(now),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== RefreshTokens ====================
|
// ==================== RefreshTokens ====================
|
||||||
|
|
||||||
/// Получить refresh token по JTI
|
/// Получить refresh token по JTI (только активные)
|
||||||
Future<RefreshToken?> getRefreshTokenByJti(String jti) {
|
Future<RefreshToken?> getRefreshTokenByJti(String jti) {
|
||||||
return (select(refreshTokens)..where((rt) => rt.jti.equals(jti)))
|
return (select(refreshTokens)
|
||||||
.getSingleOrNull();
|
..where((rt) => rt.jti.equals(jti) & rt.isDeleted.equals(false))
|
||||||
|
).getSingleOrNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Получить активные refresh токены пользователя
|
/// Получить активные refresh токены пользователя (только активные)
|
||||||
Future<List<RefreshToken>> getActiveRefreshTokens(String userId) {
|
Future<List<RefreshToken>> getActiveRefreshTokens(String userId) {
|
||||||
return (select(refreshTokens)
|
return (select(refreshTokens)
|
||||||
..where((rt) => rt.userId.equals(userId))
|
..where((rt) => rt.userId.equals(userId) & rt.isDeleted.equals(false))
|
||||||
..where((rt) => rt.isBlacklisted.equals(false))
|
..where((rt) => rt.isBlacklisted.equals(false))
|
||||||
..where((rt) => rt.expiresAt.isBiggerThanValue(PgDateTime(DateTime.now())))
|
..where((rt) => rt.expiresAt.isBiggerThanValue(PgDateTime(DateTime.now())))
|
||||||
..orderBy([(rt) => OrderingTerm.desc(rt.createdAt)])
|
..orderBy([(rt) => OrderingTerm.desc(rt.createdAt)])
|
||||||
|
|
@ -258,19 +272,33 @@ class UserDao extends DatabaseAccessor<AppDatabase> with _$UserDaoMixin {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Удалить истекшие refresh токены
|
/// Удалить истекшие refresh токены (soft delete)
|
||||||
Future<int> deleteExpiredRefreshTokens() {
|
Future<int> softDeleteExpiredRefreshTokens() {
|
||||||
return (delete(refreshTokens)
|
final now = DateTime.now();
|
||||||
..where((rt) => rt.expiresAt.isSmallerThanValue(PgDateTime(DateTime.now())))
|
return (update(refreshTokens)
|
||||||
).go();
|
..where((rt) => rt.expiresAt.isSmallerThanValue(PgDateTime(now)) & rt.isDeleted.equals(false))
|
||||||
|
).write(RefreshTokensCompanion(
|
||||||
|
isDeleted: const Value(true),
|
||||||
|
deletedAt: Value(now),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== TelegramAuthCodes ====================
|
// ==================== TelegramAuthCodes ====================
|
||||||
|
|
||||||
/// Получить код авторизации
|
/// Получить код авторизации (только активные)
|
||||||
Future<TelegramAuthCode?> getAuthCode(String code) {
|
Future<TelegramAuthCode?> getAuthCode(String code) {
|
||||||
return (select(db.telegramAuthCodes)..where((ac) => ac.code.equals(code)))
|
return (select(db.telegramAuthCodes)
|
||||||
.getSingleOrNull();
|
..where((ac) => ac.code.equals(code) & ac.isDeleted.equals(false))
|
||||||
|
).getSingleOrNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Удалить код авторизации (soft delete)
|
||||||
|
Future<void> softDeleteAuthCode(String codeId) {
|
||||||
|
return (update(db.telegramAuthCodes)..where((ac) => ac.id.equals(codeId)))
|
||||||
|
.write(TelegramAuthCodesCompanion(
|
||||||
|
isDeleted: const Value(true),
|
||||||
|
deletedAt: Value(DateTime.now()),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Создать код авторизации
|
/// Создать код авторизации
|
||||||
|
|
|
||||||
|
|
@ -3,34 +3,158 @@ import 'dart:math';
|
||||||
import 'package:injectable/injectable.dart';
|
import 'package:injectable/injectable.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:drift_postgres/drift_postgres.dart';
|
||||||
|
|
||||||
/// Service for calculating various user statistics from raw data
|
/// Service for calculating various user statistics from raw data
|
||||||
@lazySingleton
|
@lazySingleton
|
||||||
class StatisticsCalculator {
|
class StatisticsCalculator {
|
||||||
|
final AppDatabase _db;
|
||||||
|
|
||||||
|
StatisticsCalculator(this._db);
|
||||||
|
|
||||||
/// Calculate pack progress for a specific user and pack
|
/// Calculate pack progress for a specific user and pack
|
||||||
PackProgressDto calculatePackProgress(
|
///
|
||||||
UserModel user,
|
/// Данные теперь берутся из WordStatistics + UserPacks + CardPacks
|
||||||
|
/// вместо денормализованного поля UserDatas.packProgress
|
||||||
|
Future<PackProgressDto> calculatePackProgress(
|
||||||
|
String userId,
|
||||||
String packId,
|
String packId,
|
||||||
) {
|
) async {
|
||||||
// Load user data
|
// Получить информацию о паке
|
||||||
final userData = user.userData;
|
final pack = await _db.packDao.getPackById(packId);
|
||||||
if (userData == null) {
|
if (pack == null) {
|
||||||
return PackProgressDto.empty(packId, 0);
|
return PackProgressDto.empty(packId, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find existing pack progress or create new
|
// Получить статистику по словам пака из WordStatistics
|
||||||
final existingProgress = userData.packProgress
|
final wordStats = await _db.wordStatisticsDao.getPackStatistics(userId, packId);
|
||||||
.where((progress) => progress.packId == packId)
|
|
||||||
.firstOrNull;
|
|
||||||
|
|
||||||
if (existingProgress != null) {
|
// Получить сессии изучения этого пака
|
||||||
return existingProgress.toDto();
|
final sessions = await _db.statisticsDao.getSessionsByUserId(
|
||||||
|
userId,
|
||||||
|
limit: null,
|
||||||
|
);
|
||||||
|
final packSessions = sessions.where((s) => s.packId == packId).toList();
|
||||||
|
|
||||||
|
// Рассчитать метрики
|
||||||
|
final learnedCards = wordStats.length; // Карточки, на которые был ответ
|
||||||
|
final totalCards = pack.size;
|
||||||
|
|
||||||
|
// Время изучения из сессий
|
||||||
|
final studyTimeMinutes = packSessions.fold<int>(
|
||||||
|
0,
|
||||||
|
(sum, session) {
|
||||||
|
if (session.endTime != null && session.startTime != null) {
|
||||||
|
final duration = session.endTime!.dateTime.difference(session.startTime.dateTime);
|
||||||
|
return sum + duration.inMinutes;
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Даты изучения
|
||||||
|
final lastStudyDate = packSessions.isNotEmpty
|
||||||
|
? packSessions.map((s) => s.startTime.dateTime).reduce((a, b) => a.isAfter(b) ? a : b)
|
||||||
|
: null;
|
||||||
|
final firstStudyDate = packSessions.isNotEmpty
|
||||||
|
? packSessions.map((s) => s.startTime.dateTime).reduce((a, b) => a.isBefore(b) ? a : b)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Попытки по карточкам
|
||||||
|
final cardAttempts = <String, int>{};
|
||||||
|
for (final stat in wordStats) {
|
||||||
|
final attempts = stat.correctAnswers + stat.incorrectAnswers;
|
||||||
|
if (attempts > 0) {
|
||||||
|
cardAttempts[stat.cardId] = attempts;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no existing progress, get pack info and create empty progress
|
// Средняя точность
|
||||||
// This would need to be enhanced with actual pack data lookup
|
final totalCorrect = wordStats.fold<int>(0, (sum, s) => sum + s.correctAnswers);
|
||||||
// For now, return empty progress
|
final totalIncorrect = wordStats.fold<int>(0, (sum, s) => sum + s.incorrectAnswers);
|
||||||
return PackProgressDto.empty(packId, 0);
|
final totalAttempts = totalCorrect + totalIncorrect;
|
||||||
|
final averageAccuracy = totalAttempts > 0 ? totalCorrect / totalAttempts : 0.0;
|
||||||
|
|
||||||
|
return PackProgressDto(
|
||||||
|
packId: packId,
|
||||||
|
totalCards: totalCards,
|
||||||
|
learnedCards: learnedCards,
|
||||||
|
studyTimeMinutes: studyTimeMinutes,
|
||||||
|
lastStudyDate: lastStudyDate,
|
||||||
|
firstStudyDate: firstStudyDate,
|
||||||
|
cardAttempts: cardAttempts,
|
||||||
|
averageAccuracy: averageAccuracy,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate pack progress for all user packs
|
||||||
|
Future<List<PackProgressDto>> calculateAllPackProgress(String userId) async {
|
||||||
|
// Получить все паки пользователя
|
||||||
|
final userPacks = await _db.userDao.getUserPacks(userId);
|
||||||
|
|
||||||
|
// Рассчитать прогресс для каждого пака
|
||||||
|
final progressList = <PackProgressDto>[];
|
||||||
|
for (final userPack in userPacks) {
|
||||||
|
final progress = await calculatePackProgress(userId, userPack.packId);
|
||||||
|
progressList.add(progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
return progressList;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate study dates (unique dates when user studied)
|
||||||
|
///
|
||||||
|
/// Данные теперь берутся из StudySessions вместо UserDatas.studyDates
|
||||||
|
Future<List<DateTime>> calculateStudyDates(String userId) async {
|
||||||
|
final sessions = await _db.statisticsDao.getSessionsByUserId(userId);
|
||||||
|
|
||||||
|
// Получить уникальные даты (без времени)
|
||||||
|
final uniqueDates = <DateTime>{};
|
||||||
|
for (final session in sessions) {
|
||||||
|
final date = DateTime(
|
||||||
|
session.startTime.dateTime.year,
|
||||||
|
session.startTime.dateTime.month,
|
||||||
|
session.startTime.dateTime.day,
|
||||||
|
);
|
||||||
|
uniqueDates.add(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
final datesList = uniqueDates.toList()
|
||||||
|
..sort((a, b) => b.compareTo(a)); // Новые первыми
|
||||||
|
|
||||||
|
return datesList;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate category minutes (study time per pack category)
|
||||||
|
///
|
||||||
|
/// Данные теперь берутся из StudySessions + CardPacks.category
|
||||||
|
/// вместо UserDatas.categoryMinutes
|
||||||
|
Future<Map<String, int>> calculateCategoryMinutes(String userId) async {
|
||||||
|
final sessions = await _db.statisticsDao.getSessionsByUserId(userId);
|
||||||
|
|
||||||
|
final categoryMinutes = <String, int>{};
|
||||||
|
|
||||||
|
for (final session in sessions) {
|
||||||
|
if (session.packId == null) continue;
|
||||||
|
|
||||||
|
// Получить категорию пака
|
||||||
|
// Примечание: если в CardPacks нет поля category, используем 'unknown'
|
||||||
|
// В будущем можно добавить поле category в CardPacks или использовать другую логику
|
||||||
|
final pack = await _db.packDao.getPackById(session.packId!);
|
||||||
|
final category = pack != null ? (pack.category ?? 'unknown') : 'unknown';
|
||||||
|
|
||||||
|
// Рассчитать время сессии в минутах
|
||||||
|
int sessionMinutes = 0;
|
||||||
|
if (session.endTime != null && session.startTime != null) {
|
||||||
|
final duration = session.endTime!.dateTime.difference(session.startTime.dateTime);
|
||||||
|
sessionMinutes = duration.inMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryMinutes[category] = (categoryMinutes[category] ?? 0) + sessionMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
return categoryMinutes;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate current streak (consecutive days with study activity)
|
/// Calculate current streak (consecutive days with study activity)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import '../packs/free_packs_distributor.dart';
|
||||||
import '../statistics/session_tracker.dart';
|
import '../statistics/session_tracker.dart';
|
||||||
import '../statistics/statistics_calculator.dart';
|
import '../statistics/statistics_calculator.dart';
|
||||||
import '../statistics/achievement_manager.dart';
|
import '../statistics/achievement_manager.dart';
|
||||||
|
import '../statistics/word_statistics_manager.dart';
|
||||||
import 'secure.dart';
|
import 'secure.dart';
|
||||||
import 'user_drift_extension.dart';
|
import 'user_drift_extension.dart';
|
||||||
|
|
||||||
|
|
@ -25,6 +26,7 @@ class UserManager {
|
||||||
final SessionTracker _sessionTracker;
|
final SessionTracker _sessionTracker;
|
||||||
final StatisticsCalculator _statisticsCalculator;
|
final StatisticsCalculator _statisticsCalculator;
|
||||||
final AchievementManager _achievementManager;
|
final AchievementManager _achievementManager;
|
||||||
|
final WordStatisticsManager _wordStatisticsManager;
|
||||||
|
|
||||||
UserManager(
|
UserManager(
|
||||||
this._db,
|
this._db,
|
||||||
|
|
@ -32,6 +34,7 @@ class UserManager {
|
||||||
this._sessionTracker,
|
this._sessionTracker,
|
||||||
this._statisticsCalculator,
|
this._statisticsCalculator,
|
||||||
this._achievementManager,
|
this._achievementManager,
|
||||||
|
this._wordStatisticsManager,
|
||||||
);
|
);
|
||||||
|
|
||||||
Future<UserModel?> fetchUser(String id) async {
|
Future<UserModel?> fetchUser(String id) async {
|
||||||
|
|
@ -241,29 +244,49 @@ class UserManager {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновить статистику пользователя
|
// Записать статистику по словам в WordStatistics
|
||||||
final now = DateTime.now();
|
// Для каждого слова из теста находим карточку и записываем ответ
|
||||||
final todayNormalized = DateTime(now.year, now.month, now.day);
|
for (final wordStat in testStat.words.words) {
|
||||||
final studyDates = List<DateTime>.from(userData.studyDates ?? []);
|
// Найти карточку по слову (original)
|
||||||
|
// Примечание: если несколько карточек с одинаковым original, берем первую
|
||||||
|
final cards = await _db.packDao.searchCardsByOriginal(wordStat.word);
|
||||||
|
if (cards.isNotEmpty) {
|
||||||
|
final card = cards.first;
|
||||||
|
|
||||||
// Добавить сегодняшнюю дату если еще нет
|
// Записать каждый ответ (correct/incorrect)
|
||||||
if (!studyDates.any((date) =>
|
// correct и incorrect в WordStatisticsDto - это уже агрегированные значения
|
||||||
date.year == todayNormalized.year &&
|
// Но для простоты запишем их как один ответ
|
||||||
date.month == todayNormalized.month &&
|
if (wordStat.correct > 0) {
|
||||||
date.day == todayNormalized.day)) {
|
await _wordStatisticsManager.recordAnswer(
|
||||||
studyDates.add(todayNormalized);
|
userId: user.id!,
|
||||||
|
cardId: card.id,
|
||||||
|
isCorrect: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (wordStat.incorrect > 0) {
|
||||||
|
await _wordStatisticsManager.recordAnswer(
|
||||||
|
userId: user.id!,
|
||||||
|
cardId: card.id,
|
||||||
|
isCorrect: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Пересчитать streak
|
// Обновить статистику пользователя
|
||||||
|
// studyDates теперь рассчитывается из StudySessions, не хранится в UserDatas
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
// Пересчитать streak из StudySessions
|
||||||
|
final studyDates = await _statisticsCalculator.calculateStudyDates(user.id!);
|
||||||
final currentStreak = _statisticsCalculator.calculateStreak(studyDates);
|
final currentStreak = _statisticsCalculator.calculateStreak(studyDates);
|
||||||
final longestStreak = math.max(userData.longestStreak, currentStreak);
|
final longestStreak = math.max(userData.longestStreak, currentStreak);
|
||||||
|
|
||||||
// Обновить UserData
|
// Обновить UserData (без studyDates - это поле удалено)
|
||||||
await _db.userDao.updateUserDataPartial(
|
await _db.userDao.updateUserDataPartial(
|
||||||
UserDatasCompanion(
|
UserDatasCompanion(
|
||||||
userId: drift.Value(user.id!),
|
userId: drift.Value(user.id!),
|
||||||
lastTestSessionToken: drift.Value(testStat.sessionToken),
|
lastTestSessionToken: drift.Value(testStat.sessionToken),
|
||||||
studyDates: drift.Value(studyDates),
|
|
||||||
currentStreak: drift.Value(currentStreak),
|
currentStreak: drift.Value(currentStreak),
|
||||||
longestStreak: drift.Value(longestStreak),
|
longestStreak: drift.Value(longestStreak),
|
||||||
lastTimeOnline: drift.Value(PgDateTime(now)),
|
lastTimeOnline: drift.Value(PgDateTime(now)),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue