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

This commit is contained in:
Dmitry 2025-12-14 23:42:38 +03:00
parent c7b01e0dd0
commit e4e6a259c7
12 changed files with 396 additions and 139 deletions

View file

@ -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}`,

View file

@ -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}`,

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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();
}

View file

@ -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();

View file

@ -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())),
));
}
}

View file

@ -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 ====================

View file

@ -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()),
));
}
/// Создать код авторизации

View file

@ -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;
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<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)

View file

@ -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 ?? []);
// Добавить сегодняшнюю дату если еще нет
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)),