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 { AxiosError } from 'axios'
|
||||
|
||||
export class CardsApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public statusCode?: number,
|
||||
public field?: string,
|
||||
public originalError?: unknown
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'CardsApiError'
|
||||
export interface CardsApiError {
|
||||
message: string
|
||||
statusCode?: number
|
||||
field?: string
|
||||
originalError?: unknown
|
||||
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 = {
|
||||
// Get all cards with pagination and search
|
||||
getCards: async (params?: {
|
||||
|
|
@ -32,7 +52,7 @@ export const cardsApi = {
|
|||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ error?: string; message?: string; field?: string }>
|
||||
throw new CardsApiError(
|
||||
throw createCardsApiError(
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.error ||
|
||||
'Failed to load cards',
|
||||
|
|
@ -50,7 +70,7 @@ export const cardsApi = {
|
|||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ error?: string; message?: string }>
|
||||
throw new CardsApiError(
|
||||
throw createCardsApiError(
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.error ||
|
||||
`Failed to load card ${cardId}`,
|
||||
|
|
@ -82,7 +102,7 @@ export const cardsApi = {
|
|||
fullMessage += ` (Field: ${axiosError.response.data.field})`
|
||||
}
|
||||
|
||||
throw new CardsApiError(
|
||||
throw createCardsApiError(
|
||||
fullMessage,
|
||||
axiosError.response?.status,
|
||||
axiosError.response?.data?.field,
|
||||
|
|
@ -98,7 +118,7 @@ export const cardsApi = {
|
|||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ error?: string; message?: string }>
|
||||
throw new CardsApiError(
|
||||
throw createCardsApiError(
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.error ||
|
||||
`Failed to delete card ${cardId}`,
|
||||
|
|
|
|||
|
|
@ -2,18 +2,38 @@ import { adminApiClient } from './client'
|
|||
import type { EditCardPackDto, CardPackPreviewDto, PaginatedResponse } from '@/types/models'
|
||||
import type { AxiosError } from 'axios'
|
||||
|
||||
export class PacksApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public statusCode?: number,
|
||||
public field?: string,
|
||||
public originalError?: unknown
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'PacksApiError'
|
||||
export interface PacksApiError {
|
||||
message: string
|
||||
statusCode?: number
|
||||
field?: string
|
||||
originalError?: unknown
|
||||
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 = {
|
||||
// Get all packs with pagination and search
|
||||
getPacks: async (params?: {
|
||||
|
|
@ -39,7 +59,7 @@ export const packsApi = {
|
|||
if (axiosError.response?.status === 400) {
|
||||
const errorData = axiosError.response.data
|
||||
if (errorData?.message?.includes('Limit')) {
|
||||
throw new PacksApiError(
|
||||
throw createPacksApiError(
|
||||
`Invalid limit: ${errorData.message}. Maximum allowed limit is 100.`,
|
||||
axiosError.response.status,
|
||||
'limit',
|
||||
|
|
@ -48,7 +68,7 @@ export const packsApi = {
|
|||
}
|
||||
}
|
||||
|
||||
throw new PacksApiError(
|
||||
throw createPacksApiError(
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.error ||
|
||||
'Failed to load packs',
|
||||
|
|
@ -68,7 +88,7 @@ export const packsApi = {
|
|||
const axiosError = error as AxiosError<{ error?: string; message?: string }>
|
||||
|
||||
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.`,
|
||||
axiosError.response.status,
|
||||
undefined,
|
||||
|
|
@ -76,7 +96,7 @@ export const packsApi = {
|
|||
)
|
||||
}
|
||||
|
||||
throw new PacksApiError(
|
||||
throw createPacksApiError(
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.error ||
|
||||
`Failed to load pack ${packId}`,
|
||||
|
|
@ -114,7 +134,7 @@ export const packsApi = {
|
|||
fullMessage += ` (Field: ${axiosError.response.data.field})`
|
||||
}
|
||||
|
||||
throw new PacksApiError(
|
||||
throw createPacksApiError(
|
||||
fullMessage,
|
||||
axiosError.response?.status,
|
||||
axiosError.response?.data?.field,
|
||||
|
|
@ -132,7 +152,7 @@ export const packsApi = {
|
|||
const axiosError = error as AxiosError<{ error?: string; message?: string }>
|
||||
|
||||
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.`,
|
||||
axiosError.response.status,
|
||||
undefined,
|
||||
|
|
@ -140,7 +160,7 @@ export const packsApi = {
|
|||
)
|
||||
}
|
||||
|
||||
throw new PacksApiError(
|
||||
throw createPacksApiError(
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.error ||
|
||||
`Failed to delete pack ${packId}`,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { cardsApi, CardsApiError } from '@/api/cards'
|
||||
import { packsApi, PacksApiError } from '@/api/packs'
|
||||
import { cardsApi, isCardsApiError } from '@/api/cards'
|
||||
import { packsApi, isPacksApiError } from '@/api/packs'
|
||||
import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils'
|
||||
import type { GameCardDto } from '@/types/models'
|
||||
import { Button } from './ui/button'
|
||||
|
|
@ -69,7 +69,7 @@ export function BulkCardEditor({ images, onComplete, onCancel }: BulkCardEditorP
|
|||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage = error instanceof PacksApiError
|
||||
const errorMessage = isPacksApiError(error)
|
||||
? error.message
|
||||
: formatApiError(error)
|
||||
console.error('Failed to load packs:', error)
|
||||
|
|
@ -126,7 +126,7 @@ export function BulkCardEditor({ images, onComplete, onCancel }: BulkCardEditorP
|
|||
toast.success('Card saved successfully')
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const errorMessage = error instanceof CardsApiError
|
||||
const errorMessage = isCardsApiError(error)
|
||||
? error.message
|
||||
: getDetailedErrorMessage(error, 'save', 'card')
|
||||
toast.error(errorMessage)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { cardsApi, CardsApiError } from '@/api/cards'
|
||||
import { cardsApi, isCardsApiError } from '@/api/cards'
|
||||
import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils'
|
||||
import type { GameCardDto, PaginatedResponse } from '@/types/models'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -81,7 +81,7 @@ export default function CardsPage() {
|
|||
queryFn: () => cardsApi.getCards({ page, limit, search }),
|
||||
retry: (failureCount, error) => {
|
||||
// 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 failureCount < 2
|
||||
|
|
@ -97,7 +97,7 @@ export default function CardsPage() {
|
|||
closeDialog()
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const errorMessage = error instanceof CardsApiError
|
||||
const errorMessage = isCardsApiError(error)
|
||||
? error.message
|
||||
: getDetailedErrorMessage(error, 'create', 'card')
|
||||
toast.error(errorMessage)
|
||||
|
|
@ -113,7 +113,7 @@ export default function CardsPage() {
|
|||
closeDialog()
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const errorMessage = error instanceof CardsApiError
|
||||
const errorMessage = isCardsApiError(error)
|
||||
? error.message
|
||||
: getDetailedErrorMessage(error, 'update', 'card')
|
||||
toast.error(errorMessage)
|
||||
|
|
@ -130,7 +130,7 @@ export default function CardsPage() {
|
|||
setCardToDelete(null)
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const errorMessage = error instanceof CardsApiError
|
||||
const errorMessage = isCardsApiError(error)
|
||||
? error.message
|
||||
: getDetailedErrorMessage(error, 'delete', 'card')
|
||||
toast.error(errorMessage)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { packsApi, PacksApiError } from '@/api/packs'
|
||||
import { packsApi, isPacksApiError } from '@/api/packs'
|
||||
import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils'
|
||||
import type { EditCardPackDto, CardPackPreviewDto, PaginatedResponse } from '@/types/models'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -80,7 +80,7 @@ export default function PacksPage() {
|
|||
queryFn: () => packsApi.getPacks({ page, limit, search, showDisabled }),
|
||||
retry: (failureCount, error) => {
|
||||
// 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 failureCount < 2
|
||||
|
|
@ -96,7 +96,7 @@ export default function PacksPage() {
|
|||
closeDialog()
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const errorMessage = error instanceof PacksApiError
|
||||
const errorMessage = isPacksApiError(error)
|
||||
? error.message
|
||||
: getDetailedErrorMessage(error, 'create', 'pack')
|
||||
toast.error(errorMessage)
|
||||
|
|
@ -112,7 +112,7 @@ export default function PacksPage() {
|
|||
closeDialog()
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const errorMessage = error instanceof PacksApiError
|
||||
const errorMessage = isPacksApiError(error)
|
||||
? error.message
|
||||
: getDetailedErrorMessage(error, 'update', 'pack')
|
||||
toast.error(errorMessage)
|
||||
|
|
@ -129,7 +129,7 @@ export default function PacksPage() {
|
|||
setPackToDelete(null)
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const errorMessage = error instanceof PacksApiError
|
||||
const errorMessage = isPacksApiError(error)
|
||||
? error.message
|
||||
: getDetailedErrorMessage(error, 'delete', 'pack')
|
||||
toast.error(errorMessage)
|
||||
|
|
@ -186,7 +186,7 @@ export default function PacksPage() {
|
|||
setCardsToRemove([])
|
||||
setIsDialogOpen(true)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof PacksApiError
|
||||
const errorMessage = isPacksApiError(error)
|
||||
? error.message
|
||||
: getDetailedErrorMessage(error, 'load', `pack "${pack.id}"`)
|
||||
toast.error(errorMessage)
|
||||
|
|
|
|||
|
|
@ -12,9 +12,11 @@ class DiscountDao extends DatabaseAccessor<AppDatabase> with _$DiscountDaoMixin
|
|||
|
||||
// ==================== DiscountCampaigns ====================
|
||||
|
||||
/// Получить кампанию по ID
|
||||
/// Получить кампанию по 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)
|
||||
Future<void> deleteCampaign(String campaignId) {
|
||||
Future<void> softDeleteCampaign(String campaignId) {
|
||||
return (update(db.discountCampaigns)
|
||||
..where((c) => c.id.equals(campaignId))
|
||||
).write(DiscountCampaignsCompanion(
|
||||
isDeleted: const Value(true),
|
||||
deletedAt: Value(DateTime.now()),
|
||||
updatedAt: Value(PgDateTime(DateTime.now())),
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== Discounts ====================
|
||||
|
||||
/// Получить скидку по ID
|
||||
/// Получить скидку по 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) {
|
||||
return (select(db.discounts)
|
||||
..where((d) => d.campaignId.equals(campaignId))
|
||||
|
|
@ -97,6 +102,16 @@ class DiscountDao extends DatabaseAccessor<AppDatabase> with _$DiscountDaoMixin
|
|||
).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 {
|
||||
final inserted = await into(db.discounts).insertReturning(discount);
|
||||
|
|
@ -110,7 +125,7 @@ class DiscountDao extends DatabaseAccessor<AppDatabase> with _$DiscountDaoMixin
|
|||
|
||||
// ==================== DiscountUserDatas ====================
|
||||
|
||||
/// Получить скидки пользователя
|
||||
/// Получить скидки пользователя (только активные)
|
||||
Future<List<Discount>> getUserDiscounts(String userId) async {
|
||||
final query = select(db.discounts).join([
|
||||
innerJoin(
|
||||
|
|
@ -118,7 +133,8 @@ class DiscountDao extends DatabaseAccessor<AppDatabase> with _$DiscountDaoMixin
|
|||
db.discountUserDatas.discountId.equalsExp(db.discounts.id) &
|
||||
db.discountUserDatas.userId.equals(userId),
|
||||
),
|
||||
]);
|
||||
])
|
||||
..where(db.discounts.isDeleted.equals(false));
|
||||
|
||||
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 {
|
||||
final query = select(gameCards).join([
|
||||
leftOuterJoin(
|
||||
innerJoin(
|
||||
cardPackCards,
|
||||
cardPackCards.cardId.equalsExp(gameCards.id) &
|
||||
cardPackCards.packId.equals(packId),
|
||||
),
|
||||
])..where(gameCards.packId.equals(packId));
|
||||
])..where(gameCards.isDeleted.equals(false));
|
||||
|
||||
final results = await query.get();
|
||||
|
||||
|
|
|
|||
|
|
@ -12,9 +12,11 @@ class PromoCodeDao extends DatabaseAccessor<AppDatabase> with _$PromoCodeDaoMixi
|
|||
|
||||
// ==================== PromoCodesCampaigns ====================
|
||||
|
||||
/// Получить кампанию по ID
|
||||
/// Получить кампанию по 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 ====================
|
||||
|
||||
/// Получить промокод по коду
|
||||
/// Получить промокод по коду (только активные)
|
||||
Future<PromoCode?> getPromoCodeByCode(String code) {
|
||||
return (select(db.promoCodes)
|
||||
..where((pc) => pc.code.equals(code))
|
||||
..where((pc) => pc.code.equals(code) & pc.isDeleted.equals(false))
|
||||
).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Получить промокоды кампании
|
||||
/// Получить промокоды кампании (только активные)
|
||||
Future<List<PromoCode>> getPromoCodesByCampaignId(String campaignId) {
|
||||
return (select(db.promoCodes)
|
||||
..where((pc) => pc.campaignId.equals(campaignId))
|
||||
..where((pc) => pc.campaignId.equals(campaignId) & pc.isDeleted.equals(false))
|
||||
).get();
|
||||
}
|
||||
|
||||
/// Получить индивидуальные промокоды пользователя
|
||||
/// Получить индивидуальные промокоды пользователя (только активные)
|
||||
Future<List<PromoCode>> getUserPromoCodes(String userId) {
|
||||
return (select(db.promoCodes)
|
||||
..where((pc) => pc.userId.equals(userId))
|
||||
..where((pc) => pc.userId.equals(userId) & pc.isDeleted.equals(false))
|
||||
).get();
|
||||
}
|
||||
|
||||
|
|
@ -115,8 +117,23 @@ class PromoCodeDao extends DatabaseAccessor<AppDatabase> with _$PromoCodeDaoMixi
|
|||
}
|
||||
}
|
||||
|
||||
/// Удалить промокод
|
||||
Future<void> deletePromoCode(String promoCodeId) {
|
||||
return (delete(db.promoCodes)..where((pc) => pc.id.equals(promoCodeId))).go();
|
||||
/// Удалить промокод (soft delete)
|
||||
Future<void> softDeletePromoCode(String promoCodeId) {
|
||||
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 ====================
|
||||
|
||||
/// Получить тест по ID
|
||||
/// Получить тест по 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() {
|
||||
return (select(tests)
|
||||
..where((t) => t.isDeleted.equals(false))
|
||||
).get();
|
||||
}
|
||||
|
||||
/// Получить тесты пака
|
||||
/// Получить тесты пака (только активные)
|
||||
Future<List<Test>> getTestsByPackId(String packId) async {
|
||||
final query = select(tests).join([
|
||||
innerJoin(
|
||||
|
|
@ -32,7 +34,8 @@ class TestDao extends DatabaseAccessor<AppDatabase> with _$TestDaoMixin {
|
|||
testPackRelations.testId.equalsExp(tests.id) &
|
||||
testPackRelations.packId.equals(packId),
|
||||
),
|
||||
]);
|
||||
])
|
||||
..where(tests.isDeleted.equals(false));
|
||||
|
||||
return query.map((row) => row.readTable(tests)).get();
|
||||
}
|
||||
|
|
@ -70,10 +73,10 @@ class TestDao extends DatabaseAccessor<AppDatabase> with _$TestDaoMixin {
|
|||
|
||||
// ==================== TestQuestions ====================
|
||||
|
||||
/// Получить вопросы теста
|
||||
/// Получить вопросы теста (только активные)
|
||||
Future<List<TestQuestion>> getTestQuestions(String testId) {
|
||||
return (select(testQuestions)
|
||||
..where((tq) => tq.testId.equals(testId))
|
||||
..where((tq) => tq.testId.equals(testId) & tq.isDeleted.equals(false))
|
||||
).get();
|
||||
}
|
||||
|
||||
|
|
@ -88,9 +91,14 @@ class TestDao extends DatabaseAccessor<AppDatabase> with _$TestDaoMixin {
|
|||
return update(testQuestions).replace(question);
|
||||
}
|
||||
|
||||
/// Удалить вопрос теста
|
||||
Future<void> deleteTestQuestion(String questionId) {
|
||||
return (delete(testQuestions)..where((tq) => tq.id.equals(questionId))).go();
|
||||
/// Удалить вопрос теста (soft delete)
|
||||
Future<void> softDeleteTestQuestion(String questionId) {
|
||||
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 ====================
|
||||
|
|
|
|||
|
|
@ -171,25 +171,26 @@ class UserDao extends DatabaseAccessor<AppDatabase> with _$UserDaoMixin {
|
|||
|
||||
// ==================== Tokens ====================
|
||||
|
||||
/// Получить токен по значению
|
||||
/// Получить токен по значению (только активные)
|
||||
Future<Token?> getTokenByValue(String tokenValue) {
|
||||
return (select(tokens)..where((t) => t.token.equals(tokenValue)))
|
||||
.getSingleOrNull();
|
||||
return (select(tokens)
|
||||
..where((t) => t.token.equals(tokenValue) & t.isDeleted.equals(false))
|
||||
).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Получить токен по externalUserId
|
||||
/// Получить токен по externalUserId (только активные)
|
||||
Future<Token?> getTokenByExternalUserId(String externalUserId) {
|
||||
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)])
|
||||
..limit(1)
|
||||
).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Получить токен пользователя
|
||||
/// Получить токен пользователя (только активные)
|
||||
Future<Token?> getTokenByUserId(String userId) {
|
||||
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())))
|
||||
..orderBy([(t) => OrderingTerm.desc(t.created)])
|
||||
).getSingleOrNull();
|
||||
|
|
@ -201,35 +202,48 @@ class UserDao extends DatabaseAccessor<AppDatabase> with _$UserDaoMixin {
|
|||
return inserted.id;
|
||||
}
|
||||
|
||||
/// Удалить токен
|
||||
Future<int> deleteToken(String tokenId) {
|
||||
return (delete(tokens)..where((t) => t.id.equals(tokenId))).go();
|
||||
/// Удалить токен (soft delete)
|
||||
Future<int> softDeleteToken(String tokenId) {
|
||||
return (update(tokens)..where((t) => t.id.equals(tokenId)))
|
||||
.write(TokensCompanion(
|
||||
isDeleted: const Value(true),
|
||||
deletedAt: Value(DateTime.now()),
|
||||
));
|
||||
}
|
||||
|
||||
/// Удалить токен по значению
|
||||
Future<int> deleteTokenByValue(String tokenValue) {
|
||||
return (delete(tokens)..where((t) => t.token.equals(tokenValue))).go();
|
||||
/// Удалить токен по значению (soft delete)
|
||||
Future<int> softDeleteTokenByValue(String tokenValue) {
|
||||
return (update(tokens)..where((t) => t.token.equals(tokenValue)))
|
||||
.write(TokensCompanion(
|
||||
isDeleted: const Value(true),
|
||||
deletedAt: Value(DateTime.now()),
|
||||
));
|
||||
}
|
||||
|
||||
/// Удалить истекшие токены
|
||||
Future<int> deleteExpiredTokens() {
|
||||
return (delete(tokens)
|
||||
..where((t) => t.expires.isSmallerThanValue(PgDateTime(DateTime.now())))
|
||||
).go();
|
||||
/// Удалить истекшие токены (soft delete)
|
||||
Future<int> softDeleteExpiredTokens() {
|
||||
final now = DateTime.now();
|
||||
return (update(tokens)
|
||||
..where((t) => t.expires.isSmallerThanValue(PgDateTime(now)) & t.isDeleted.equals(false))
|
||||
).write(TokensCompanion(
|
||||
isDeleted: const Value(true),
|
||||
deletedAt: Value(now),
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== RefreshTokens ====================
|
||||
|
||||
/// Получить refresh token по JTI
|
||||
/// Получить refresh token по JTI (только активные)
|
||||
Future<RefreshToken?> getRefreshTokenByJti(String jti) {
|
||||
return (select(refreshTokens)..where((rt) => rt.jti.equals(jti)))
|
||||
.getSingleOrNull();
|
||||
return (select(refreshTokens)
|
||||
..where((rt) => rt.jti.equals(jti) & rt.isDeleted.equals(false))
|
||||
).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Получить активные refresh токены пользователя
|
||||
/// Получить активные refresh токены пользователя (только активные)
|
||||
Future<List<RefreshToken>> getActiveRefreshTokens(String userId) {
|
||||
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.expiresAt.isBiggerThanValue(PgDateTime(DateTime.now())))
|
||||
..orderBy([(rt) => OrderingTerm.desc(rt.createdAt)])
|
||||
|
|
@ -258,19 +272,33 @@ class UserDao extends DatabaseAccessor<AppDatabase> with _$UserDaoMixin {
|
|||
));
|
||||
}
|
||||
|
||||
/// Удалить истекшие refresh токены
|
||||
Future<int> deleteExpiredRefreshTokens() {
|
||||
return (delete(refreshTokens)
|
||||
..where((rt) => rt.expiresAt.isSmallerThanValue(PgDateTime(DateTime.now())))
|
||||
).go();
|
||||
/// Удалить истекшие refresh токены (soft delete)
|
||||
Future<int> softDeleteExpiredRefreshTokens() {
|
||||
final now = DateTime.now();
|
||||
return (update(refreshTokens)
|
||||
..where((rt) => rt.expiresAt.isSmallerThanValue(PgDateTime(now)) & rt.isDeleted.equals(false))
|
||||
).write(RefreshTokensCompanion(
|
||||
isDeleted: const Value(true),
|
||||
deletedAt: Value(now),
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== TelegramAuthCodes ====================
|
||||
|
||||
/// Получить код авторизации
|
||||
/// Получить код авторизации (только активные)
|
||||
Future<TelegramAuthCode?> getAuthCode(String code) {
|
||||
return (select(db.telegramAuthCodes)..where((ac) => ac.code.equals(code)))
|
||||
.getSingleOrNull();
|
||||
return (select(db.telegramAuthCodes)
|
||||
..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:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
import 'package:drift_postgres/drift_postgres.dart';
|
||||
|
||||
/// Service for calculating various user statistics from raw data
|
||||
@lazySingleton
|
||||
class StatisticsCalculator {
|
||||
final AppDatabase _db;
|
||||
|
||||
StatisticsCalculator(this._db);
|
||||
|
||||
/// 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,
|
||||
) {
|
||||
// Load user data
|
||||
final userData = user.userData;
|
||||
if (userData == null) {
|
||||
) async {
|
||||
// Получить информацию о паке
|
||||
final pack = await _db.packDao.getPackById(packId);
|
||||
if (pack == null) {
|
||||
return PackProgressDto.empty(packId, 0);
|
||||
}
|
||||
|
||||
// Find existing pack progress or create new
|
||||
final existingProgress = userData.packProgress
|
||||
.where((progress) => progress.packId == packId)
|
||||
.firstOrNull;
|
||||
// Получить статистику по словам пака из WordStatistics
|
||||
final wordStats = await _db.wordStatisticsDao.getPackStatistics(userId, packId);
|
||||
|
||||
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
|
||||
// For now, return empty progress
|
||||
return PackProgressDto.empty(packId, 0);
|
||||
// Средняя точность
|
||||
final totalCorrect = wordStats.fold<int>(0, (sum, s) => sum + s.correctAnswers);
|
||||
final totalIncorrect = wordStats.fold<int>(0, (sum, s) => sum + s.incorrectAnswers);
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import '../packs/free_packs_distributor.dart';
|
|||
import '../statistics/session_tracker.dart';
|
||||
import '../statistics/statistics_calculator.dart';
|
||||
import '../statistics/achievement_manager.dart';
|
||||
import '../statistics/word_statistics_manager.dart';
|
||||
import 'secure.dart';
|
||||
import 'user_drift_extension.dart';
|
||||
|
||||
|
|
@ -25,6 +26,7 @@ class UserManager {
|
|||
final SessionTracker _sessionTracker;
|
||||
final StatisticsCalculator _statisticsCalculator;
|
||||
final AchievementManager _achievementManager;
|
||||
final WordStatisticsManager _wordStatisticsManager;
|
||||
|
||||
UserManager(
|
||||
this._db,
|
||||
|
|
@ -32,6 +34,7 @@ class UserManager {
|
|||
this._sessionTracker,
|
||||
this._statisticsCalculator,
|
||||
this._achievementManager,
|
||||
this._wordStatisticsManager,
|
||||
);
|
||||
|
||||
Future<UserModel?> fetchUser(String id) async {
|
||||
|
|
@ -241,29 +244,49 @@ class UserManager {
|
|||
);
|
||||
}
|
||||
|
||||
// Обновить статистику пользователя
|
||||
final now = DateTime.now();
|
||||
final todayNormalized = DateTime(now.year, now.month, now.day);
|
||||
final studyDates = List<DateTime>.from(userData.studyDates ?? []);
|
||||
// Записать статистику по словам в WordStatistics
|
||||
// Для каждого слова из теста находим карточку и записываем ответ
|
||||
for (final wordStat in testStat.words.words) {
|
||||
// Найти карточку по слову (original)
|
||||
// Примечание: если несколько карточек с одинаковым original, берем первую
|
||||
final cards = await _db.packDao.searchCardsByOriginal(wordStat.word);
|
||||
if (cards.isNotEmpty) {
|
||||
final card = cards.first;
|
||||
|
||||
// Добавить сегодняшнюю дату если еще нет
|
||||
if (!studyDates.any((date) =>
|
||||
date.year == todayNormalized.year &&
|
||||
date.month == todayNormalized.month &&
|
||||
date.day == todayNormalized.day)) {
|
||||
studyDates.add(todayNormalized);
|
||||
// Записать каждый ответ (correct/incorrect)
|
||||
// correct и incorrect в WordStatisticsDto - это уже агрегированные значения
|
||||
// Но для простоты запишем их как один ответ
|
||||
if (wordStat.correct > 0) {
|
||||
await _wordStatisticsManager.recordAnswer(
|
||||
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 longestStreak = math.max(userData.longestStreak, currentStreak);
|
||||
|
||||
// Обновить UserData
|
||||
// Обновить UserData (без studyDates - это поле удалено)
|
||||
await _db.userDao.updateUserDataPartial(
|
||||
UserDatasCompanion(
|
||||
userId: drift.Value(user.id!),
|
||||
lastTestSessionToken: drift.Value(testStat.sessionToken),
|
||||
studyDates: drift.Value(studyDates),
|
||||
currentStreak: drift.Value(currentStreak),
|
||||
longestStreak: drift.Value(longestStreak),
|
||||
lastTimeOnline: drift.Value(PgDateTime(now)),
|
||||
|
|
|
|||
Loading…
Reference in a new issue