From e4e6a259c7af1dcbf894e95f7e9baf861b453f9f Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sun, 14 Dec 2025 23:42:38 +0300 Subject: [PATCH] backaend + admin --- mnemo_cards_admin/src/api/cards.ts | 46 +++-- mnemo_cards_admin/src/api/packs.ts | 52 ++++-- .../src/components/BulkCardEditor.tsx | 8 +- mnemo_cards_admin/src/pages/CardsPage.tsx | 10 +- mnemo_cards_admin/src/pages/PacksPage.tsx | 12 +- .../lib/database/daos/discount_dao.dart | 32 +++- .../lib/database/daos/pack_dao.dart | 5 +- .../lib/database/daos/promo_code_dao.dart | 39 +++-- .../lib/database/daos/test_dao.dart | 28 +-- .../lib/database/daos/user_dao.dart | 90 ++++++---- .../lib/statistics/statistics_calculator.dart | 162 ++++++++++++++++-- .../lib/user/user_manager.dart | 51 ++++-- 12 files changed, 396 insertions(+), 139 deletions(-) diff --git a/mnemo_cards_admin/src/api/cards.ts b/mnemo_cards_admin/src/api/cards.ts index 5619f7d..4f55223 100644 --- a/mnemo_cards_admin/src/api/cards.ts +++ b/mnemo_cards_admin/src/api/cards.ts @@ -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}`, diff --git a/mnemo_cards_admin/src/api/packs.ts b/mnemo_cards_admin/src/api/packs.ts index 22b9212..fae965d 100644 --- a/mnemo_cards_admin/src/api/packs.ts +++ b/mnemo_cards_admin/src/api/packs.ts @@ -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}`, diff --git a/mnemo_cards_admin/src/components/BulkCardEditor.tsx b/mnemo_cards_admin/src/components/BulkCardEditor.tsx index d737fbf..76b5be6 100644 --- a/mnemo_cards_admin/src/components/BulkCardEditor.tsx +++ b/mnemo_cards_admin/src/components/BulkCardEditor.tsx @@ -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) diff --git a/mnemo_cards_admin/src/pages/CardsPage.tsx b/mnemo_cards_admin/src/pages/CardsPage.tsx index 1638758..8dcf555 100644 --- a/mnemo_cards_admin/src/pages/CardsPage.tsx +++ b/mnemo_cards_admin/src/pages/CardsPage.tsx @@ -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) diff --git a/mnemo_cards_admin/src/pages/PacksPage.tsx b/mnemo_cards_admin/src/pages/PacksPage.tsx index 1efda7b..d55844f 100644 --- a/mnemo_cards_admin/src/pages/PacksPage.tsx +++ b/mnemo_cards_admin/src/pages/PacksPage.tsx @@ -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) diff --git a/mnemo_cards_backend/lib/database/daos/discount_dao.dart b/mnemo_cards_backend/lib/database/daos/discount_dao.dart index 631f2ee..7dfaa71 100644 --- a/mnemo_cards_backend/lib/database/daos/discount_dao.dart +++ b/mnemo_cards_backend/lib/database/daos/discount_dao.dart @@ -12,9 +12,11 @@ class DiscountDao extends DatabaseAccessor with _$DiscountDaoMixin // ==================== DiscountCampaigns ==================== - /// Получить кампанию по ID + /// Получить кампанию по ID (только активные) Future 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 with _$DiscountDaoMixin } /// Удалить кампанию (soft delete) - Future deleteCampaign(String campaignId) { + Future 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 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> getDiscountsByCampaignId(String campaignId) { return (select(db.discounts) ..where((d) => d.campaignId.equals(campaignId)) @@ -97,6 +102,16 @@ class DiscountDao extends DatabaseAccessor with _$DiscountDaoMixin ).get(); } + /// Удалить скидку (soft delete) + Future 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 createDiscount(DiscountsCompanion discount) async { final inserted = await into(db.discounts).insertReturning(discount); @@ -110,7 +125,7 @@ class DiscountDao extends DatabaseAccessor with _$DiscountDaoMixin // ==================== DiscountUserDatas ==================== - /// Получить скидки пользователя + /// Получить скидки пользователя (только активные) Future> getUserDiscounts(String userId) async { final query = select(db.discounts).join([ innerJoin( @@ -118,7 +133,8 @@ class DiscountDao extends DatabaseAccessor 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(); } diff --git a/mnemo_cards_backend/lib/database/daos/pack_dao.dart b/mnemo_cards_backend/lib/database/daos/pack_dao.dart index 927a120..f7709d1 100644 --- a/mnemo_cards_backend/lib/database/daos/pack_dao.dart +++ b/mnemo_cards_backend/lib/database/daos/pack_dao.dart @@ -94,14 +94,15 @@ class PackDao extends DatabaseAccessor with _$PackDaoMixin { } /// Получить все карточки пака + /// Связь теперь только через CardPackCards (packId удален из GameCards) Future> 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(); diff --git a/mnemo_cards_backend/lib/database/daos/promo_code_dao.dart b/mnemo_cards_backend/lib/database/daos/promo_code_dao.dart index fa4206c..58d018b 100644 --- a/mnemo_cards_backend/lib/database/daos/promo_code_dao.dart +++ b/mnemo_cards_backend/lib/database/daos/promo_code_dao.dart @@ -12,9 +12,11 @@ class PromoCodeDao extends DatabaseAccessor with _$PromoCodeDaoMixi // ==================== PromoCodesCampaigns ==================== - /// Получить кампанию по ID + /// Получить кампанию по ID (только активные) Future 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 with _$PromoCodeDaoMixi // ==================== PromoCodes ==================== - /// Получить промокод по коду + /// Получить промокод по коду (только активные) Future 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> 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> 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 with _$PromoCodeDaoMixi } } - /// Удалить промокод - Future deletePromoCode(String promoCodeId) { - return (delete(db.promoCodes)..where((pc) => pc.id.equals(promoCodeId))).go(); + /// Удалить промокод (soft delete) + Future 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 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())), + )); } } diff --git a/mnemo_cards_backend/lib/database/daos/test_dao.dart b/mnemo_cards_backend/lib/database/daos/test_dao.dart index 1fcfa70..7ac73bb 100644 --- a/mnemo_cards_backend/lib/database/daos/test_dao.dart +++ b/mnemo_cards_backend/lib/database/daos/test_dao.dart @@ -12,19 +12,21 @@ class TestDao extends DatabaseAccessor with _$TestDaoMixin { // ==================== Tests ==================== - /// Получить тест по ID + /// Получить тест по ID (только активные) Future 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> getAllTests() { return (select(tests) ..where((t) => t.isDeleted.equals(false)) ).get(); } - /// Получить тесты пака + /// Получить тесты пака (только активные) Future> getTestsByPackId(String packId) async { final query = select(tests).join([ innerJoin( @@ -32,7 +34,8 @@ class TestDao extends DatabaseAccessor 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 with _$TestDaoMixin { // ==================== TestQuestions ==================== - /// Получить вопросы теста + /// Получить вопросы теста (только активные) Future> 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 with _$TestDaoMixin { return update(testQuestions).replace(question); } - /// Удалить вопрос теста - Future deleteTestQuestion(String questionId) { - return (delete(testQuestions)..where((tq) => tq.id.equals(questionId))).go(); + /// Удалить вопрос теста (soft delete) + Future 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 ==================== diff --git a/mnemo_cards_backend/lib/database/daos/user_dao.dart b/mnemo_cards_backend/lib/database/daos/user_dao.dart index 9dc6cbb..7cab002 100644 --- a/mnemo_cards_backend/lib/database/daos/user_dao.dart +++ b/mnemo_cards_backend/lib/database/daos/user_dao.dart @@ -171,25 +171,26 @@ class UserDao extends DatabaseAccessor with _$UserDaoMixin { // ==================== Tokens ==================== - /// Получить токен по значению + /// Получить токен по значению (только активные) Future 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 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 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 with _$UserDaoMixin { return inserted.id; } - /// Удалить токен - Future deleteToken(String tokenId) { - return (delete(tokens)..where((t) => t.id.equals(tokenId))).go(); + /// Удалить токен (soft delete) + Future softDeleteToken(String tokenId) { + return (update(tokens)..where((t) => t.id.equals(tokenId))) + .write(TokensCompanion( + isDeleted: const Value(true), + deletedAt: Value(DateTime.now()), + )); } - /// Удалить токен по значению - Future deleteTokenByValue(String tokenValue) { - return (delete(tokens)..where((t) => t.token.equals(tokenValue))).go(); + /// Удалить токен по значению (soft delete) + Future softDeleteTokenByValue(String tokenValue) { + return (update(tokens)..where((t) => t.token.equals(tokenValue))) + .write(TokensCompanion( + isDeleted: const Value(true), + deletedAt: Value(DateTime.now()), + )); } - /// Удалить истекшие токены - Future deleteExpiredTokens() { - return (delete(tokens) - ..where((t) => t.expires.isSmallerThanValue(PgDateTime(DateTime.now()))) - ).go(); + /// Удалить истекшие токены (soft delete) + Future 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 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> 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 with _$UserDaoMixin { )); } - /// Удалить истекшие refresh токены - Future deleteExpiredRefreshTokens() { - return (delete(refreshTokens) - ..where((rt) => rt.expiresAt.isSmallerThanValue(PgDateTime(DateTime.now()))) - ).go(); + /// Удалить истекшие refresh токены (soft delete) + Future 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 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 softDeleteAuthCode(String codeId) { + return (update(db.telegramAuthCodes)..where((ac) => ac.id.equals(codeId))) + .write(TelegramAuthCodesCompanion( + isDeleted: const Value(true), + deletedAt: Value(DateTime.now()), + )); } /// Создать код авторизации diff --git a/mnemo_cards_backend/lib/statistics/statistics_calculator.dart b/mnemo_cards_backend/lib/statistics/statistics_calculator.dart index a48f573..4cac536 100644 --- a/mnemo_cards_backend/lib/statistics/statistics_calculator.dart +++ b/mnemo_cards_backend/lib/statistics/statistics_calculator.dart @@ -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 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; - - if (existingProgress != null) { - return existingProgress.toDto(); + + // Получить статистику по словам пака из WordStatistics + final wordStats = await _db.wordStatisticsDao.getPackStatistics(userId, packId); + + // Получить сессии изучения этого пака + 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( + 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 = {}; + 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(0, (sum, s) => sum + s.correctAnswers); + final totalIncorrect = wordStats.fold(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> calculateAllPackProgress(String userId) async { + // Получить все паки пользователя + final userPacks = await _db.userDao.getUserPacks(userId); + + // Рассчитать прогресс для каждого пака + final progressList = []; + 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> calculateStudyDates(String userId) async { + final sessions = await _db.statisticsDao.getSessionsByUserId(userId); + + // Получить уникальные даты (без времени) + final uniqueDates = {}; + 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> calculateCategoryMinutes(String userId) async { + final sessions = await _db.statisticsDao.getSessionsByUserId(userId); + + final categoryMinutes = {}; + + 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) diff --git a/mnemo_cards_backend/lib/user/user_manager.dart b/mnemo_cards_backend/lib/user/user_manager.dart index 11dd181..36d5ea0 100644 --- a/mnemo_cards_backend/lib/user/user_manager.dart +++ b/mnemo_cards_backend/lib/user/user_manager.dart @@ -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 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.from(userData.studyDates ?? []); - - // Добавить сегодняшнюю дату если еще нет - if (!studyDates.any((date) => - date.year == todayNormalized.year && - date.month == todayNormalized.month && - date.day == todayNormalized.day)) { - studyDates.add(todayNormalized); + // Записать статистику по словам в 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; + + // Записать каждый ответ (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)),