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
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:
parent
e5c7a35ebd
commit
c7b01e0dd0
22 changed files with 4378 additions and 916 deletions
|
|
@ -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>()),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
77
mnemo_cards_backend/lib/database/daos/audit_dao.dart
Normal file
77
mnemo_cards_backend/lib/database/daos/audit_dao.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
8
mnemo_cards_backend/lib/database/daos/audit_dao.g.dart
Normal file
8
mnemo_cards_backend/lib/database/daos/audit_dao.g.dart
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
111
mnemo_cards_backend/lib/database/daos/word_statistics_dao.dart
Normal file
111
mnemo_cards_backend/lib/database/daos/word_statistics_dao.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
48
mnemo_cards_backend/lib/database/tables/audit.dart
Normal file
48
mnemo_cards_backend/lib/database/tables/audit.dart
Normal 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};
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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()(); // слово на иностранном языке
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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())();
|
||||
|
|
|
|||
51
mnemo_cards_backend/lib/database/tables/word_statistics.dart
Normal file
51
mnemo_cards_backend/lib/database/tables/word_statistics.dart
Normal 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 миграцию в будущем
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue