bd1
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (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:36:05 +03:00
parent e5c7a35ebd
commit c7b01e0dd0
22 changed files with 4378 additions and 916 deletions

View file

@ -24,6 +24,7 @@ import '../../promo_codes/promo_codes_manager.dart' as _i151;
import '../../statistics/achievement_manager.dart' as _i802;
import '../../statistics/session_tracker.dart' as _i71;
import '../../statistics/statistics_calculator.dart' as _i1029;
import '../../statistics/word_statistics_manager.dart' as _i909;
import '../../tasks/task_manager.dart' as _i586;
import '../../tests/test_manager.dart' as _i259;
import '../../user/user_manager.dart' as _i280;
@ -96,6 +97,9 @@ extension GetItInjectableX on _i174.GetIt {
gh.lazySingleton<_i71.SessionTracker>(
() => _i71.SessionTracker(gh<_i1072.AppDatabase>()),
);
gh.lazySingleton<_i909.WordStatisticsManager>(
() => _i909.WordStatisticsManager(gh<_i1072.AppDatabase>()),
);
gh.lazySingleton<_i586.TaskManager>(
() => _i586.TaskManager(gh<_i1072.AppDatabase>()),
);

View file

@ -242,38 +242,37 @@ class AdminCardsApiV2 {
);
}
// Update existing card
final updated = existing.copyWith(
original: data['original'] ?? existing.original,
translation: data['translation'] ?? existing.translation,
mnemo: data['mnemo'] ?? existing.mnemo,
image: data['image'] ?? existing.image,
imageBack: data['imageBack'] ?? existing.imageBack,
back: data['back'] ?? existing.back,
transcription: data['transcription'] ?? existing.transcription,
transcriptionMnemo: data['transcriptionMnemo'] ?? existing.transcriptionMnemo,
updatedAt: PgDateTime(DateTime.now()),
);
await _db.packDao.updateCard(updated);
final cardIdInt = int.tryParse(updated.id) ?? 0;
return Response.ok(
json.encode({
'success': true,
'card': {
'id': cardIdInt,
'packId': updated.packId,
'original': updated.original,
'translation': updated.translation,
'mnemo': updated.mnemo,
'image': updated.image,
'imageBack': updated.imageBack,
'back': updated.back,
'transcription': updated.transcription,
'transcriptionMnemo': updated.transcriptionMnemo,
},
}),
headers: {'Content-Type': 'application/json'},
);
}
final updated = existing.copyWith(
original: data['original'] ?? existing.original,
translation: data['translation'] ?? existing.translation,
mnemo: data['mnemo'] ?? existing.mnemo,
image: data['image'] ?? existing.image,
imageBack: data['imageBack'] ?? existing.imageBack,
back: data['back'] ?? existing.back,
transcription: data['transcription'] ?? existing.transcription,
transcriptionMnemo: data['transcriptionMnemo'] ?? existing.transcriptionMnemo,
updatedAt: PgDateTime(DateTime.now()),
);
await _db.packDao.updateCard(updated);
final cardIdInt = int.tryParse(updated.id) ?? 0;
return Response.ok(
json.encode({
'success': true,
'card': {
'id': cardIdInt,
'packId': updated.packId,
'original': updated.original,
'translation': updated.translation,
'mnemo': updated.mnemo,
'image': updated.image,
'imageBack': updated.imageBack,
'back': updated.back,
'transcription': updated.transcription,
'transcriptionMnemo': updated.transcriptionMnemo,
},
}),
headers: {'Content-Type': 'application/json'},
);
}
// Create new card

View file

@ -0,0 +1,77 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import '../database.dart';
import '../tables/audit.dart';
part 'audit_dao.g.dart';
@DriftAccessor(tables: [AuditLogs])
class AuditDao extends DatabaseAccessor<AppDatabase> with _$AuditDaoMixin {
AuditDao(super.db);
/// Записать событие в audit log
///
/// ВАЖНО: Пока не используется в коде. Инфраструктура создана для будущего использования.
///
/// Параметры:
/// - tableName: имя таблицы (например, 'Users', 'Payments')
/// - recordId: ID записи, которая изменилась
/// - action: тип операции ('INSERT', 'UPDATE', 'DELETE', 'SOFT_DELETE', 'RESTORE')
/// - userId: ID пользователя, который сделал изменение (null для системных операций)
/// - oldData: данные до изменения (для UPDATE, DELETE)
/// - newData: данные после изменения (для INSERT, UPDATE)
/// - ipAddress: IP адрес пользователя
/// - userAgent: User-Agent браузера
Future<void> log({
required String tableName,
required String recordId,
required String action,
String? userId,
Map<String, dynamic>? oldData,
Map<String, dynamic>? newData,
String? ipAddress,
String? userAgent,
}) async {
await into(auditLogs).insert(
AuditLogsCompanion.insert(
tableName: tableName,
recordId: recordId,
action: action,
userId: Value(userId),
oldData: Value(oldData != null ? jsonEncode(oldData) : null),
newData: Value(newData != null ? jsonEncode(newData) : null),
ipAddress: Value(ipAddress),
userAgent: Value(userAgent),
),
);
}
/// Получить историю изменений для конкретной записи
///
/// Возвращает список записей audit log, отсортированных по дате создания (новые первыми)
Future<List<AuditLog>> getLogsByRecord({
required String tableName,
required String recordId,
int? limit,
}) {
final query = select(auditLogs)
..where((a) => a.tableName.equals(tableName) & a.recordId.equals(recordId))
..orderBy([(a) => OrderingTerm.desc(a.createdAt)]);
if (limit != null) {
query.limit(limit);
}
return query.get();
}
/// Получить последние события (для мониторинга)
///
/// Возвращает последние N записей audit log, отсортированных по дате создания (новые первыми)
Future<List<AuditLog>> getRecentLogs({int limit = 100}) {
return (select(auditLogs)
..orderBy([(a) => OrderingTerm.desc(a.createdAt)])
..limit(limit))
.get();
}
}

View file

@ -0,0 +1,8 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'audit_dao.dart';
// ignore_for_file: type=lint
mixin _$AuditDaoMixin on DatabaseAccessor<AppDatabase> {
$AuditLogsTable get auditLogs => attachedDatabase.auditLogs;
}

View file

@ -0,0 +1,93 @@
import 'package:drift/drift.dart';
import 'package:mnemo_cards_backend/database/database.dart';
/// Миксин для добавления soft delete функциональности в DAO
///
/// Требования к таблице:
/// - Должна иметь поля: isDeleted (bool), deletedAt (DateTime?)
/// - Должна иметь поле id (String)
///
/// Использование:
/// ```dart
/// @DriftAccessor(tables: [Payments])
/// class PaymentsDao extends DatabaseAccessor<AppDatabase>
/// with _$PaymentsDaoMixin, SoftDeleteMixin<Payments, Payment> {
///
/// PaymentsDao(super.db);
///
/// @override
/// TableInfo<Payments, Payment> get table => payments;
/// }
/// ```
mixin SoftDeleteMixin<T extends Table, D> on DatabaseAccessor<AppDatabase> {
/// Таблица, с которой работает DAO
TableInfo<T, D> get table;
/// Мягкое удаление записи по ID
///
/// Устанавливает isDeleted = true и deletedAt = текущее время.
/// Возвращает true если запись была обновлена.
Future<bool> softDelete(String id) async {
final now = DateTime.now();
// Получаем динамический доступ к полям через reflection
final updated = await (update(table)
..where((t) {
final idColumn = (t as dynamic).id;
return idColumn.equals(id);
}))
.write(
table.companion(
isDeleted: const Value(true),
deletedAt: Value(now),
updatedAt: Value(now), // если есть поле updatedAt
) as UpdateCompanion<D>,
);
return updated > 0;
}
/// Получить только активные (не удаленные) записи
///
/// Используйте этот метод вместо select(table) когда нужно
/// автоматически фильтровать удаленные записи.
SimpleSelectStatement<T, D> selectActive() {
return select(table)
..where((t) {
final isDeletedColumn = (t as dynamic).isDeleted;
return isDeletedColumn.equals(false);
});
}
/// Получить запись по ID (только активные)
///
/// Возвращает null если запись не найдена или удалена.
Future<D?> getActiveById(String id) async {
return (selectActive()
..where((t) {
final idColumn = (t as dynamic).id;
return idColumn.equals(id);
}))
.getSingleOrNull();
}
/// Восстановить удаленную запись
///
/// Устанавливает isDeleted = false и deletedAt = null.
/// Возвращает true если запись была обновлена.
Future<bool> restore(String id) async {
final now = DateTime.now();
final updated = await (update(table)
..where((t) {
final idColumn = (t as dynamic).id;
return idColumn.equals(id);
}))
.write(
table.companion(
isDeleted: const Value(false),
deletedAt: const Value(null),
updatedAt: Value(now),
) as UpdateCompanion<D>,
);
return updated > 0;
}
}

View file

@ -2,24 +2,30 @@ import 'package:drift/drift.dart';
import 'package:drift_postgres/drift_postgres.dart';
import '../database.dart';
import '../tables/payments.dart';
import 'mixins/soft_delete_mixin.dart';
part 'payment_dao.g.dart';
@DriftAccessor(tables: [Payments])
class PaymentDao extends DatabaseAccessor<AppDatabase> with _$PaymentDaoMixin {
class PaymentDao extends DatabaseAccessor<AppDatabase>
with _$PaymentDaoMixin, SoftDeleteMixin<Payments, Payment> {
PaymentDao(super.db);
/// Получить платеж по ID
@override
TableInfo<Payments, Payment> get table => payments;
/// Получить платеж по ID (только активные)
Future<Payment?> getPaymentById(String id) {
return (select(payments)..where((p) => p.id.equals(id))).getSingleOrNull();
return getActiveById(id);
}
/// Получить платежи пользователя
/// Получить платежи пользователя (только активные)
Future<List<Payment>> getPaymentsByUserId(String userId, {
int? limit,
int? offset,
}) {
final query = select(payments)
final query = selectActive()
..where((p) => p.userId.equals(userId))
..orderBy([(p) => OrderingTerm.desc(p.date)]);
@ -30,9 +36,9 @@ class PaymentDao extends DatabaseAccessor<AppDatabase> with _$PaymentDaoMixin {
return query.get();
}
/// Получить платежи по статусу
/// Получить платежи по статусу (только активные)
Future<List<Payment>> getPaymentsByStatus(String status) {
return (select(payments)
return (selectActive()
..where((p) => p.status.equals(status))
..orderBy([(p) => OrderingTerm.desc(p.date)])
).get();
@ -96,28 +102,28 @@ class PaymentDao extends DatabaseAccessor<AppDatabase> with _$PaymentDaoMixin {
return await query.map((row) => row.read(countExpr)!).getSingle();
}
/// Получить платеж по externalToken
/// Получить платеж по externalToken (только активные)
Future<Payment?> getPaymentByExternalToken(String token) {
return (select(payments)..where((p) => p.externalToken.equals(token)))
return (selectActive()..where((p) => p.externalToken.equals(token)))
.getSingleOrNull();
}
/// Получить платежи по продукту (store ID)
/// Получить платежи по продукту (store ID) (только активные)
Future<List<Payment>> getPaymentsByProduct(String productId) async {
// Поиск по продуктам в JSON массиве
final query = select(payments)
final query = selectActive()
..where((p) => p.products.like('%$productId%'));
return query.get();
}
/// Получить платежи по статусу с пагинацией
/// Получить платежи по статусу с пагинацией (только активные)
Future<List<Payment>> getPaymentsByStatusPaged(
String status, {
int? limit,
int? offset,
}) {
final query = select(payments)
final query = selectActive()
..where((p) => p.status.equals(status))
..orderBy([(p) => OrderingTerm.desc(p.date)]);
@ -128,12 +134,12 @@ class PaymentDao extends DatabaseAccessor<AppDatabase> with _$PaymentDaoMixin {
return query.get();
}
/// Получить все платежи (для админки)
/// Получить все платежи (для админки) (только активные)
Future<List<Payment>> getAllPayments({
int? limit,
int? offset,
}) {
final query = select(payments)
final query = selectActive()
..orderBy([(p) => OrderingTerm.desc(p.date)]);
if (limit != null) {
@ -143,10 +149,23 @@ class PaymentDao extends DatabaseAccessor<AppDatabase> with _$PaymentDaoMixin {
return query.get();
}
/// Подсчитать все платежи
/// Подсчитать все платежи (только активные)
Future<int> countAllPayments() async {
final countExpr = payments.id.count();
final query = selectOnly(payments)..addColumns([countExpr]);
final query = selectOnly(payments)
..addColumns([countExpr])
..where(payments.isDeleted.equals(false));
return await query.map((row) => row.read(countExpr)!).getSingle();
}
/// Подсчитать платежи пользователя (только активные)
@override
Future<int> countPaymentsByUserId(String userId) async {
final countExpr = payments.id.count();
final query = selectOnly(payments)
..addColumns([countExpr])
..where(payments.userId.equals(userId) & payments.isDeleted.equals(false));
return await query.map((row) => row.read(countExpr)!).getSingle();
}

View file

@ -3,42 +3,48 @@ import 'package:drift_postgres/drift_postgres.dart';
import '../database.dart';
import '../tables/statistics.dart';
import '../tables/users.dart';
import 'mixins/soft_delete_mixin.dart';
part 'statistics_dao.g.dart';
@DriftAccessor(tables: [StudySessions])
class StatisticsDao extends DatabaseAccessor<AppDatabase> with _$StatisticsDaoMixin {
class StatisticsDao extends DatabaseAccessor<AppDatabase>
with _$StatisticsDaoMixin, SoftDeleteMixin<StudySessions, StudySession> {
StatisticsDao(super.db);
/// Получить сессию по ID
@override
TableInfo<StudySessions, StudySession> get table => studySessions;
/// Получить сессию по ID (только активные)
Future<StudySession?> getSessionById(String id) {
return (select(studySessions)..where((s) => s.id.equals(id))).getSingleOrNull();
return getActiveById(id);
}
/// Получить сессию по sessionId
/// Получить сессию по sessionId (только активные)
Future<StudySession?> getSessionBySessionId(String sessionId) {
return (select(studySessions)
return (selectActive()
..where((s) => s.sessionId.equals(sessionId))
).getSingleOrNull();
}
/// Получить активные сессии пользователя
/// Получить активные сессии пользователя (не завершенные и не удаленные)
Future<List<StudySession>> getActiveSessions(String userId) {
return (select(studySessions)
return (selectActive()
..where((s) => s.userId.equals(userId))
..where((s) => s.endTime.isNull())
..orderBy([(s) => OrderingTerm.desc(s.startTime)])
).get();
}
/// Получить сессии пользователя
/// Получить сессии пользователя (только активные)
Future<List<StudySession>> getSessionsByUserId(String userId, {
int? limit,
int? offset,
DateTime? fromDate,
DateTime? toDate,
}) {
final query = select(studySessions)
final query = selectActive()
..where((s) => s.userId.equals(userId))
..orderBy([(s) => OrderingTerm.desc(s.startTime)]);
@ -86,12 +92,12 @@ class StatisticsDao extends DatabaseAccessor<AppDatabase> with _$StatisticsDaoMi
.write(updates);
}
/// Подсчитать сессии пользователя
/// Подсчитать сессии пользователя (только активные)
Future<int> countSessionsByUserId(String userId) async {
final countExpr = studySessions.id.count();
final query = selectOnly(studySessions)
..addColumns([countExpr])
..where(studySessions.userId.equals(userId));
..where(studySessions.userId.equals(userId) & studySessions.isDeleted.equals(false));
return await query.map((row) => row.read(countExpr)!).getSingle();
}

View file

@ -0,0 +1,111 @@
import 'package:drift/drift.dart';
import 'package:drift_postgres/drift_postgres.dart';
import '../database.dart';
import '../tables/word_statistics.dart';
import '../tables/packs.dart';
import '../tables/relations.dart';
import 'mixins/soft_delete_mixin.dart';
part 'word_statistics_dao.g.dart';
@DriftAccessor(tables: [WordStatistics, GameCards, CardPackCards])
class WordStatisticsDao extends DatabaseAccessor<AppDatabase>
with _$WordStatisticsDaoMixin, SoftDeleteMixin<WordStatistics, WordStatistic> {
WordStatisticsDao(super.db);
@override
TableInfo<WordStatistics, WordStatistic> get table => wordStatistics;
/// Получить статистику для пользователя и карточки
Future<WordStatistic?> getByUserAndCard(String userId, String cardId) {
return (selectActive()
..where((w) => w.userId.equals(userId) & w.cardId.equals(cardId)))
.getSingleOrNull();
}
/// Создать новую запись статистики
///
/// Автоматически рассчитывает mastery = correctAnswers / (correctAnswers + incorrectAnswers)
Future<WordStatistic> create({
required String userId,
required String cardId,
required int correctAnswers,
required int incorrectAnswers,
}) async {
final total = correctAnswers + incorrectAnswers;
final mastery = total > 0 ? correctAnswers / total : 0.0;
final companion = WordStatisticsCompanion.insert(
userId: userId,
cardId: cardId,
correctAnswers: correctAnswers,
incorrectAnswers: incorrectAnswers,
mastery: mastery,
lastReviewed: Value(DateTime.now()),
);
final id = await into(wordStatistics).insertReturning(companion);
return id;
}
/// Обновить статистику
///
/// Автоматически пересчитывает mastery на основе новых значений
Future<void> update({
required String id,
required int correctAnswers,
required int incorrectAnswers,
required DateTime lastReviewed,
}) async {
final total = correctAnswers + incorrectAnswers;
final mastery = total > 0 ? correctAnswers / total : 0.0;
await (update(wordStatistics)..where((w) => w.id.equals(id)))
.write(
WordStatisticsCompanion(
correctAnswers: Value(correctAnswers),
incorrectAnswers: Value(incorrectAnswers),
mastery: Value(mastery),
lastReviewed: Value(lastReviewed),
updatedAt: Value(DateTime.now()),
),
);
}
/// Получить статистику по всем карточкам пака для пользователя
///
/// Использует JOIN через CardPackCards для связи Pack Card
Future<List<WordStatistic>> getPackStatistics(
String userId,
String packId,
) async {
// JOIN: WordStatistics GameCards CardPackCards
final query = selectActive()
.join([
innerJoin(
gameCards,
gameCards.id.equalsExp(wordStatistics.cardId),
),
innerJoin(
cardPackCards,
cardPackCards.cardId.equalsExp(gameCards.id),
),
])
..where(
wordStatistics.userId.equals(userId) &
cardPackCards.packId.equals(packId)
);
final rows = await query.get();
return rows.map((row) => row.readTable(wordStatistics)).toList();
}
/// Получить все статистики пользователя
Future<List<WordStatistic>> getUserStatistics(String userId) {
return (selectActive()
..where((w) => w.userId.equals(userId))
..orderBy([(w) => OrderingTerm.desc(w.lastReviewed)]))
.get();
}
}

View file

@ -0,0 +1,12 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'word_statistics_dao.dart';
// ignore_for_file: type=lint
mixin _$WordStatisticsDaoMixin on DatabaseAccessor<AppDatabase> {
$UsersTable get users => attachedDatabase.users;
$GameCardsTable get gameCards => attachedDatabase.gameCards;
$WordStatisticsTable get wordStatistics => attachedDatabase.wordStatistics;
$CardPacksTable get cardPacks => attachedDatabase.cardPacks;
$CardPackCardsTable get cardPackCards => attachedDatabase.cardPackCards;
}

View file

@ -20,6 +20,8 @@ import 'tables/discounts.dart';
import 'tables/statistics.dart';
import 'tables/telegram.dart';
import 'tables/achievements.dart';
import 'tables/word_statistics.dart';
import 'tables/audit.dart';
// Импорт DAOs
import 'daos/user_dao.dart';
@ -32,6 +34,8 @@ import 'daos/promo_code_dao.dart';
import 'daos/discount_dao.dart';
import 'daos/statistics_dao.dart';
import 'daos/achievement_dao.dart';
import 'daos/word_statistics_dao.dart';
import 'daos/audit_dao.dart';
// Сгенерированный код будет здесь
part 'database.g.dart';
@ -88,12 +92,16 @@ part 'database.g.dart';
// Statistics tables
StudySessions,
WordStatistics,
// Achievements tables
UserAchievements,
// Telegram tables
ShareRequests,
// Audit tables
AuditLogs,
],
daos: [
UserDao,
@ -106,6 +114,8 @@ part 'database.g.dart';
DiscountDao,
StatisticsDao,
AchievementDao,
WordStatisticsDao,
AuditDao,
],
)
class AppDatabase extends _$AppDatabase {

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,48 @@
import 'package:drift/drift.dart';
import 'package:drift_postgres/drift_postgres.dart';
import '../converters.dart' show generateUuid;
/// Таблица AuditLogs - audit trail для отслеживания изменений
///
/// ВАЖНО: Инфраструктура создается, но пока НЕ используется в коде.
/// Вызовы auditDao.log() будут добавлены позже, когда определим
/// какие операции нужно логировать.
///
/// Планируемые операции для логирования:
/// - Критичные: платежи, удаления пользователей, изменения подписок
/// - Retention policy: автоматическая очистка записей старше 90 дней
class AuditLogs extends Table {
// ID как String, но фактически UUID (генерируется PostgreSQL на стороне БД)
TextColumn get id => text()
.withDefault(const CustomExpression('gen_random_uuid()::text'))();
// Какая таблица и запись изменена
// например: 'Users', 'Payments'
TextColumn get tableName => text()();
// ID записи, которая изменилась
TextColumn get recordId => text()();
// Тип операции: 'INSERT', 'UPDATE', 'DELETE', 'SOFT_DELETE', 'RESTORE'
TextColumn get action => text()();
// Кто сделал изменение
// ID пользователя (null для системных операций)
TextColumn get userId => text().nullable()();
// Данные до и после (JSON as TEXT)
// JSON данные до изменения
TextColumn get oldData => text().nullable()();
// JSON данные после изменения
TextColumn get newData => text().nullable()();
// Дополнительная информация
TextColumn get ipAddress => text().nullable()();
TextColumn get userAgent => text().nullable()();
// Audit
Column<PgDateTime> get createdAt => customType(PgTypes.timestampWithTimezone)
.withDefault(now())();
@override
Set<Column> get primaryKey => {id};
}

View file

@ -17,6 +17,11 @@ class Tokens extends Table {
Column<PgDateTime> get created => customType(PgTypes.timestampWithTimezone)
.withDefault(now())();
Column<PgDateTime> get expires => customType(PgTypes.timestampWithTimezone)();
BoolColumn get isDeleted => boolean()
.withDefault(const Constant(false))
.customConstraint('')();
Column<PgDateTime> get deletedAt => customType(PgTypes.timestampWithTimezone)
.nullable()();
@override
Set<Column> get primaryKey => {id};
@ -44,6 +49,11 @@ class RefreshTokens extends Table {
Column<PgDateTime> get createdAt => customType(PgTypes.timestampWithTimezone)
.withDefault(now())();
Column<PgDateTime> get expiresAt => customType(PgTypes.timestampWithTimezone)();
BoolColumn get isDeleted => boolean()
.withDefault(const Constant(false))
.customConstraint('')();
Column<PgDateTime> get deletedAt => customType(PgTypes.timestampWithTimezone)
.nullable()();
@override
Set<Column> get primaryKey => {id};
@ -70,6 +80,11 @@ class TelegramAuthCodes extends Table {
Column<PgDateTime> get createdAt => customType(PgTypes.timestampWithTimezone)
.withDefault(now())();
Column<PgDateTime> get expiresAt => customType(PgTypes.timestampWithTimezone)();
BoolColumn get isDeleted => boolean()
.withDefault(const Constant(false))
.customConstraint('')();
Column<PgDateTime> get deletedAt => customType(PgTypes.timestampWithTimezone)
.nullable()();
@override
Set<Column> get primaryKey => {id};

View file

@ -30,6 +30,8 @@ class DiscountCampaigns extends Table {
BoolColumn get isDeleted => boolean()
.withDefault(const Constant(false))
.customConstraint('')(); // PostgreSQL использует нативный BOOLEAN
Column<PgDateTime> get deletedAt => customType(PgTypes.timestampWithTimezone)
.nullable()();
@override
Set<Column> get primaryKey => {id};
@ -59,6 +61,8 @@ class Discounts extends Table {
BoolColumn get isDeleted => boolean()
.withDefault(const Constant(false))
.customConstraint('')(); // PostgreSQL использует нативный BOOLEAN
Column<PgDateTime> get deletedAt => customType(PgTypes.timestampWithTimezone)
.nullable()();
@override
Set<Column> get primaryKey => {id};

View file

@ -57,8 +57,7 @@ class GameCards extends Table {
// ID как String, но фактически UUID (генерируется PostgreSQL на стороне БД)
TextColumn get id => text()
.withDefault(const CustomExpression('gen_random_uuid()::text'))();
TextColumn get packId => text()
.references(CardPacks, #id, onDelete: KeyAction.cascade)();
// packId удален - связь теперь только через CardPackCards (many-to-many)
// Основной контент
TextColumn get original => text()(); // слово на иностранном языке

View file

@ -29,19 +29,16 @@ class Payments extends Table {
.withDefault(const Constant('[]'))
.map(const JsonListConverter())();
// Deprecated fields (для обратной совместимости)
TextColumn get packs => text()
.withDefault(const Constant('[]'))
.map(const StringListConverter())();
BoolColumn get subscription => boolean()
.withDefault(const Constant(false))
.customConstraint('')(); // PostgreSQL использует нативный BOOLEAN
// Audit
Column<PgDateTime> get createdAt => customType(PgTypes.timestampWithTimezone)
.withDefault(now())();
Column<PgDateTime> get updatedAt => customType(PgTypes.timestampWithTimezone)
.withDefault(now())();
BoolColumn get isDeleted => boolean()
.withDefault(const Constant(false))
.customConstraint('')();
Column<PgDateTime> get deletedAt => customType(PgTypes.timestampWithTimezone)
.nullable()();
@override
Set<Column> get primaryKey => {id};

View file

@ -40,6 +40,8 @@ class PromoCodesCampaigns extends Table {
BoolColumn get isDeleted => boolean()
.withDefault(const Constant(false))
.customConstraint('')(); // PostgreSQL использует нативный BOOLEAN
Column<PgDateTime> get deletedAt => customType(PgTypes.timestampWithTimezone)
.nullable()();
@override
Set<Column> get primaryKey => {id};
@ -66,6 +68,11 @@ class PromoCodes extends Table {
.withDefault(now())();
Column<PgDateTime> get updatedAt => customType(PgTypes.timestampWithTimezone)
.withDefault(now())();
BoolColumn get isDeleted => boolean()
.withDefault(const Constant(false))
.customConstraint('')();
Column<PgDateTime> get deletedAt => customType(PgTypes.timestampWithTimezone)
.nullable()();
@override
Set<Column> get primaryKey => {id};

View file

@ -29,6 +29,11 @@ class StudySessions extends Table {
.withDefault(now())();
Column<PgDateTime> get updatedAt => customType(PgTypes.timestampWithTimezone)
.withDefault(now())();
BoolColumn get isDeleted => boolean()
.withDefault(const Constant(false))
.customConstraint('')();
Column<PgDateTime> get deletedAt => customType(PgTypes.timestampWithTimezone)
.nullable()();
@override
Set<Column> get primaryKey => {id};

View file

@ -24,6 +24,8 @@ class Tests extends Table {
BoolColumn get isDeleted => boolean()
.withDefault(const Constant(false))
.customConstraint('')(); // PostgreSQL использует нативный BOOLEAN
Column<PgDateTime> get deletedAt => customType(PgTypes.timestampWithTimezone)
.nullable()();
@override
Set<Column> get primaryKey => {id};
@ -46,6 +48,11 @@ class TestQuestions extends Table {
.withDefault(now())();
Column<PgDateTime> get updatedAt => customType(PgTypes.timestampWithTimezone)
.withDefault(now())();
BoolColumn get isDeleted => boolean()
.withDefault(const Constant(false))
.customConstraint('')();
Column<PgDateTime> get deletedAt => customType(PgTypes.timestampWithTimezone)
.nullable()();
@override
Set<Column> get primaryKey => {id};

View file

@ -59,31 +59,11 @@ class UserDatas extends Table {
.withDefault(now())();
TextColumn get lastTestSessionToken => text().nullable()();
// Сложные структуры (JSON)
TextColumn get words => text()
.withDefault(const Constant('[]'))
.map(const JsonListConverter())();
// Теги (JSON array) - небольшой размер, оставляем
TextColumn get tags => text()
.withDefault(const Constant('[]'))
.map(const StringListConverter())();
TextColumn get packProgress => text()
.withDefault(const Constant('[]'))
.map(const JsonListConverter())();
TextColumn get studyDates => text()
.withDefault(const Constant('[]'))
.map(const DateTimeListConverter())();
TextColumn get achievements => text()
.withDefault(const Constant('[]'))
.map(const JsonListConverter())();
TextColumn get categoryMinutes => text()
.withDefault(const Constant('{}'))
.map(const JsonMapConverter())();
// Audit
Column<PgDateTime> get createdAt => customType(PgTypes.timestampWithTimezone)
.withDefault(now())();

View file

@ -0,0 +1,51 @@
import 'package:drift/drift.dart';
import 'package:drift_postgres/drift_postgres.dart';
import 'users.dart';
import 'packs.dart';
/// Таблица WordStatistics - статистика ответов пользователей на карточки
///
/// Заменяет денормализованное поле UserDatas.words.
/// Создается при первом ответе пользователя на карточку.
/// Обновляется при каждом последующем ответе.
class WordStatistics extends Table {
// ID как String, но фактически UUID (генерируется PostgreSQL на стороне БД)
TextColumn get id => text()
.withDefault(const CustomExpression('gen_random_uuid()::text'))();
// Связи
TextColumn get userId => text()
.references(Users, #id, onDelete: KeyAction.cascade)();
TextColumn get cardId => text()
.references(GameCards, #id, onDelete: KeyAction.cascade)();
// Статистика ответов
IntColumn get correctAnswers => integer().withDefault(const Constant(0))();
IntColumn get incorrectAnswers => integer().withDefault(const Constant(0))();
// Процент правильных ответов: correctAnswers / (correctAnswers + incorrectAnswers)
RealColumn get mastery => real().withDefault(const Constant(0.0))();
// Временные метки
Column<PgDateTime> get lastReviewed => customType(PgTypes.timestampWithTimezone)
.nullable()();
Column<PgDateTime> get nextReview => customType(PgTypes.timestampWithTimezone)
.nullable()();
// Audit fields
Column<PgDateTime> get createdAt => customType(PgTypes.timestampWithTimezone)
.withDefault(now())();
Column<PgDateTime> get updatedAt => customType(PgTypes.timestampWithTimezone)
.withDefault(now())();
BoolColumn get isDeleted => boolean()
.withDefault(const Constant(false))();
Column<PgDateTime> get deletedAt => customType(PgTypes.timestampWithTimezone)
.nullable()();
@override
Set<Column> get primaryKey => {id};
/// Уникальный индекс на (userId, cardId) для предотвращения дубликатов
/// Примечание: Drift не поддерживает уникальные индексы напрямую,
/// но можно добавить через raw SQL миграцию в будущем
}

View file

@ -0,0 +1,82 @@
import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/database/database.dart';
/// Менеджер для работы со статистикой ответов пользователей на карточки
///
/// Заменяет денормализованное поле UserDatas.words.
/// Создает запись в WordStatistics при первом ответе пользователя на карточку.
/// Обновляет существующую запись при каждом последующем ответе.
@lazySingleton
class WordStatisticsManager {
final AppDatabase _db;
WordStatisticsManager(this._db);
/// Записать ответ пользователя на карточку
///
/// Вызывается из: TestManager.submitTest() после проверки ответа
///
/// Параметры:
/// - userId: ID пользователя
/// - cardId: ID карточки
/// - isCorrect: правильный ли был ответ
Future<void> recordAnswer({
required String userId,
required String cardId,
required bool isCorrect,
}) async {
// Получить существующую статистику или создать новую
final existing = await _db.wordStatisticsDao
.getByUserAndCard(userId, cardId);
if (existing == null) {
// Создать новую запись при первом ответе
await _db.wordStatisticsDao.create(
userId: userId,
cardId: cardId,
correctAnswers: isCorrect ? 1 : 0,
incorrectAnswers: isCorrect ? 0 : 1,
);
} else {
// Обновить существующую статистику
final newCorrect = existing.correctAnswers + (isCorrect ? 1 : 0);
final newIncorrect = existing.incorrectAnswers + (isCorrect ? 0 : 1);
await _db.wordStatisticsDao.update(
id: existing.id,
correctAnswers: newCorrect,
incorrectAnswers: newIncorrect,
lastReviewed: DateTime.now(),
);
}
}
/// Рассчитать mastery (процент правильных ответов)
///
/// Формула: mastery = correctAnswers / (correctAnswers + incorrectAnswers)
///
/// Примеры:
/// - 10 правильных, 0 неправильных 1.0 (100%)
/// - 7 правильных, 3 неправильных 0.7 (70%)
/// - 0 правильных, 0 неправильных 0.0 (0%)
double calculateMastery(int correctAnswers, int incorrectAnswers) {
final total = correctAnswers + incorrectAnswers;
if (total == 0) return 0.0;
return correctAnswers / total;
}
/// Получить статистику пользователя по всем карточкам пака
///
/// Используется для расчета packProgress
Future<List<WordStatistic>> getPackStatistics(
String userId,
String packId,
) async {
return _db.wordStatisticsDao.getPackStatistics(userId, packId);
}
/// Получить всю статистику пользователя
Future<List<WordStatistic>> getUserStatistics(String userId) async {
return _db.wordStatisticsDao.getUserStatistics(userId);
}
}