From 99100deb20cb5a5c2d8b5ba1fe6b47835a741586 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Fri, 9 Jan 2026 21:02:12 +0300 Subject: [PATCH] vack tasks --- .../lib/api/v2/admin_tasks_api_v2.dart | 489 ++++++++++++++++++ .../lib/api/v2/admin_tasks_api_v2.g.dart | 17 + .../repository/achievement_repository.dart | 57 ++ .../lib/repository/discount_repository.dart | 138 +++++ .../lib/repository/export.dart | 13 + .../lib/repository/iap_repository.dart | 219 ++++++++ .../lib/repository/pack_repository.dart | 224 ++++++++ .../lib/repository/payment_repository.dart | 112 ++++ .../lib/repository/promo_code_repository.dart | 94 ++++ .../lib/repository/statistics_repository.dart | 73 +++ .../repository/subscription_repository.dart | 85 +++ .../lib/repository/task_repository.dart | 127 +++++ .../lib/repository/test_repository.dart | 122 +++++ .../lib/repository/user_repository.dart | 217 ++++++++ .../word_statistics_repository.dart | 59 +++ .../сonverters/card_pack_converter.dart | 29 ++ .../lib/repository/сonverters/export.dart | 4 + .../сonverters/game_card_converter.dart | 22 + .../сonverters/subscription_converter.dart | 45 ++ .../repository/сonverters/user_converter.dart | 73 +++ 20 files changed, 2219 insertions(+) create mode 100644 mnemo_cards_backend/lib/api/v2/admin_tasks_api_v2.dart create mode 100644 mnemo_cards_backend/lib/api/v2/admin_tasks_api_v2.g.dart create mode 100644 mnemo_cards_backend/lib/repository/achievement_repository.dart create mode 100644 mnemo_cards_backend/lib/repository/discount_repository.dart create mode 100644 mnemo_cards_backend/lib/repository/export.dart create mode 100644 mnemo_cards_backend/lib/repository/iap_repository.dart create mode 100644 mnemo_cards_backend/lib/repository/pack_repository.dart create mode 100644 mnemo_cards_backend/lib/repository/payment_repository.dart create mode 100644 mnemo_cards_backend/lib/repository/promo_code_repository.dart create mode 100644 mnemo_cards_backend/lib/repository/statistics_repository.dart create mode 100644 mnemo_cards_backend/lib/repository/subscription_repository.dart create mode 100644 mnemo_cards_backend/lib/repository/task_repository.dart create mode 100644 mnemo_cards_backend/lib/repository/test_repository.dart create mode 100644 mnemo_cards_backend/lib/repository/user_repository.dart create mode 100644 mnemo_cards_backend/lib/repository/word_statistics_repository.dart create mode 100644 mnemo_cards_backend/lib/repository/сonverters/card_pack_converter.dart create mode 100644 mnemo_cards_backend/lib/repository/сonverters/export.dart create mode 100644 mnemo_cards_backend/lib/repository/сonverters/game_card_converter.dart create mode 100644 mnemo_cards_backend/lib/repository/сonverters/subscription_converter.dart create mode 100644 mnemo_cards_backend/lib/repository/сonverters/user_converter.dart diff --git a/mnemo_cards_backend/lib/api/v2/admin_tasks_api_v2.dart b/mnemo_cards_backend/lib/api/v2/admin_tasks_api_v2.dart new file mode 100644 index 0000000..58b5fb0 --- /dev/null +++ b/mnemo_cards_backend/lib/api/v2/admin_tasks_api_v2.dart @@ -0,0 +1,489 @@ +import 'dart:convert'; +import 'dart:developer'; + +import 'package:drift/drift.dart' as drift; +import 'package:drift_postgres/drift_postgres.dart'; +import 'package:injectable/injectable.dart'; +import 'package:mnemo_cards_backend/database/database.dart'; +import 'package:mnemo_cards_backend/api/authorize/acl_types.dart'; +import 'package:mnemo_cards_backend/api/authorize/access_service.dart'; +import 'package:mnemo_cards_backend/api/authorize/helpers.dart'; +import 'package:mnemo_cards_backend/repository/export.dart'; +import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; +import 'package:shelf/shelf.dart'; +import 'package:shelf_router/shelf_router.dart'; + +part 'admin_tasks_api_v2.g.dart'; + +/// Admin endpoints for managing user tasks in API v2. +@injectable +class AdminTasksApiV2 { + final AppDatabase _db; + final TaskRepository _taskRepository; + + AdminTasksApiV2(this._db, this._taskRepository); + + Response _json( + Object? data, { + int statusCode = 200, + Map headers = const {}, + }) { + return Response( + statusCode, + body: data == null ? null : jsonEncode(data), + headers: {'Content-Type': 'application/json', ...headers}, + ); + } + + Future _ensureAdmin(Request request) async { + try { + await request.access!.requireAdmin( + AdminAction.access, + user: request.user, + ); + return Response.ok(null); + } on AccessDenied catch (e) { + return Response(e.status, body: e.message); + } + } + + /// Convert Drift UserTask to UserTaskModel for JSON serialization + UserTaskModel _userTaskToModel(UserTask task) { + // Parse rewards from JSON + final rewardsJson = task.rewards as List? ?? []; + final rewards = rewardsJson + .map( + (r) => TaskRewardModel.fromJson(Map.from(r as Map)), + ) + .toList(); + + return UserTaskModel( + id: task.id, + title: task.title, + description: task.description, + type: task.type, + difficulty: task.difficulty, + status: task.status, + rewards: rewards, + createdAt: task.createdAt.dateTime, + expiresAt: task.expiresAt.dateTime, + completedAt: task.completedAt?.dateTime, + proofUrl: task.proofUrl, + instructions: task.instructions, + tags: task.tags, + imageUrl: task.imageUrl, + ); + } + + /// Convert UserTaskModel to UserTasksCompanion for database operations + UserTasksCompanion _modelToCompanion( + UserTaskModel model, { + bool isUpdate = false, + }) { + final rewardsJson = model.rewards.map((r) => r.toJson()).toList(); + final now = PgDateTime(DateTime.now()); + + return UserTasksCompanion( + id: isUpdate && model.id != null + ? drift.Value(model.id!) + : const drift.Value.absent(), + title: drift.Value(model.title), + description: drift.Value(model.description), + type: drift.Value(model.type), + difficulty: drift.Value(model.difficulty), + status: drift.Value(model.status), + rewards: drift.Value(rewardsJson), + createdAt: isUpdate + ? const drift.Value.absent() + : drift.Value(PgDateTime(model.createdAt)), + expiresAt: drift.Value(PgDateTime(model.expiresAt)), + completedAt: model.completedAt != null + ? drift.Value(PgDateTime(model.completedAt!)) + : const drift.Value.absent(), + proofUrl: model.proofUrl != null + ? drift.Value(model.proofUrl!) + : const drift.Value.absent(), + instructions: model.instructions != null + ? drift.Value(model.instructions!) + : const drift.Value.absent(), + tags: drift.Value(model.tags), + imageUrl: model.imageUrl != null + ? drift.Value(model.imageUrl!) + : const drift.Value.absent(), + updatedAt: drift.Value(now), + ); + } + + /// GET /api/v2/admin/tasks + /// Get all tasks with pagination and filters + @Route.get('/admin/tasks') + Future getTasks(Request request) async { + try { + final auth = await _ensureAdmin(request); + if (auth.statusCode != 200) { + return auth; + } + + final queryParams = request.url.queryParameters; + + // Parse pagination parameters + final page = int.tryParse(queryParams['page'] ?? '1') ?? 1; + final limit = int.tryParse(queryParams['limit'] ?? '20') ?? 20; + final type = queryParams['type']; + final difficulty = queryParams['difficulty']; + final status = queryParams['status']; + final search = queryParams['search'] ?? ''; + + // Validate pagination + if (page < 1) { + return _json({ + 'error': 'Invalid page parameter', + 'message': 'Page must be greater than 0. Received: $page', + 'field': 'page', + }, statusCode: 400); + } + + // Limit validation: cap at 100 + final validatedLimit = limit < 1 ? 20 : (limit > 100 ? 100 : limit); + + // Get all tasks + List allTasks = await _taskRepository.getAllUserTasks(); + + // Apply filters + if (type != null && type.isNotEmpty) { + allTasks = allTasks.where((t) => t.type == type).toList(); + } + if (difficulty != null && difficulty.isNotEmpty) { + allTasks = allTasks.where((t) => t.difficulty == difficulty).toList(); + } + if (status != null && status.isNotEmpty) { + allTasks = allTasks.where((t) => t.status == status).toList(); + } + + // Apply search filter + if (search.isNotEmpty) { + final searchTerm = search.toLowerCase(); + allTasks = allTasks.where((task) { + return task.title.toLowerCase().contains(searchTerm) || + task.description.toLowerCase().contains(searchTerm) || + task.id.toLowerCase().contains(searchTerm); + }).toList(); + } + + // Convert to models + final taskModels = allTasks.map(_userTaskToModel).toList(); + + // Calculate pagination + final total = taskModels.length; + final totalPages = (total / validatedLimit).ceil(); + final offset = (page - 1) * validatedLimit; + final paginatedTasks = taskModels + .skip(offset) + .take(validatedLimit) + .toList(); + + return _json({ + 'items': paginatedTasks.map((t) => t.toJson()).toList(), + 'total': total, + 'page': page, + 'limit': validatedLimit, + 'totalPages': totalPages, + }); + } catch (e, s) { + log('Error in getTasks: $e\n$s'); + return _json({ + 'error': 'Internal server error', + 'message': 'Failed to fetch tasks', + }, statusCode: 500); + } + } + + /// GET /api/v2/admin/tasks/{taskId} + /// Get task details by ID + @Route.get('/admin/tasks/') + Future getTask(Request request, String taskId) async { + try { + final auth = await _ensureAdmin(request); + if (auth.statusCode != 200) { + return auth; + } + + if (taskId.isEmpty) { + return _json({ + 'error': 'Invalid task ID', + 'message': 'Task ID cannot be empty', + 'field': 'taskId', + }, statusCode: 400); + } + + final task = await _taskRepository.getUserTaskById(taskId); + if (task == null) { + return _json({ + 'error': 'Task not found', + 'message': 'The requested task does not exist or has been deleted', + }, statusCode: 404); + } + + final model = _userTaskToModel(task); + return _json(model.toJson()); + } catch (e, s) { + log('Error in getTask: $e\n$s'); + return _json({ + 'error': 'Internal server error', + 'message': 'Failed to fetch task', + }, statusCode: 500); + } + } + + /// POST /api/v2/admin/tasks + /// Create a new task + @Route.post('/admin/tasks') + Future createTask(Request request) async { + try { + final auth = await _ensureAdmin(request); + if (auth.statusCode != 200) { + return auth; + } + + final body = await request.readAsString(); + if (body.isEmpty) { + return _json({ + 'error': 'bad_request', + 'message': 'Task payload is required', + }, statusCode: 400); + } + + late final UserTaskModel taskModel; + try { + taskModel = UserTaskModel.fromJson( + jsonDecode(body) as Map, + ); + } catch (e) { + return _json({ + 'error': 'bad_request', + 'message': 'Invalid task payload: $e', + }, statusCode: 400); + } + + // Validate required fields + if (taskModel.title.trim().isEmpty) { + return _json({ + 'error': 'Validation error', + 'message': 'Title is required', + 'field': 'title', + }, statusCode: 400); + } + + if (taskModel.description.trim().isEmpty) { + return _json({ + 'error': 'Validation error', + 'message': 'Description is required', + 'field': 'description', + }, statusCode: 400); + } + + // Create task + final companion = _modelToCompanion(taskModel, isUpdate: false); + final taskId = await _taskRepository.createUserTask(companion); + + // Fetch created task + final createdTask = await _taskRepository.getUserTaskById(taskId); + if (createdTask == null) { + return _json({ + 'error': 'Internal server error', + 'message': 'Failed to retrieve created task', + }, statusCode: 500); + } + + final model = _userTaskToModel(createdTask); + return _json({'success': true, 'task': model.toJson()}); + } catch (e, s) { + log('Error in createTask: $e\n$s'); + return _json({ + 'error': 'Internal server error', + 'message': 'Failed to create task', + }, statusCode: 500); + } + } + + /// PUT /api/v2/admin/tasks/{taskId} + /// Update an existing task + @Route.put('/admin/tasks/') + Future updateTask(Request request, String taskId) async { + try { + final auth = await _ensureAdmin(request); + if (auth.statusCode != 200) { + return auth; + } + + if (taskId.isEmpty) { + return _json({ + 'error': 'Invalid task ID', + 'message': 'Task ID cannot be empty', + 'field': 'taskId', + }, statusCode: 400); + } + + // Check if task exists + final existing = await _taskRepository.getUserTaskById(taskId); + if (existing == null) { + return _json({ + 'error': 'Task not found', + 'message': 'The task you are trying to update does not exist', + }, statusCode: 404); + } + + final body = await request.readAsString(); + if (body.isEmpty) { + return _json({ + 'error': 'bad_request', + 'message': 'Task payload is required', + }, statusCode: 400); + } + + late final Map updateData; + try { + updateData = jsonDecode(body) as Map; + } catch (e) { + return _json({ + 'error': 'bad_request', + 'message': 'Invalid task payload: $e', + }, statusCode: 400); + } + + // Convert existing task to model + final existingModel = _userTaskToModel(existing); + + // Apply updates (partial update) + final updatedModel = existingModel.copyWith( + title: updateData['title'] as String? ?? existingModel.title, + description: + updateData['description'] as String? ?? existingModel.description, + type: updateData['type'] as String? ?? existingModel.type, + difficulty: + updateData['difficulty'] as String? ?? existingModel.difficulty, + status: updateData['status'] as String? ?? existingModel.status, + rewards: updateData['rewards'] != null + ? (updateData['rewards'] as List) + .map( + (r) => TaskRewardModel.fromJson( + Map.from(r as Map), + ), + ) + .toList() + : existingModel.rewards, + expiresAt: updateData['expiresAt'] != null + ? DateTime.parse(updateData['expiresAt'] as String) + : existingModel.expiresAt, + completedAt: updateData['completedAt'] != null + ? DateTime.parse(updateData['completedAt'] as String) + : existingModel.completedAt, + proofUrl: updateData['proofUrl'] as String? ?? existingModel.proofUrl, + instructions: + updateData['instructions'] as String? ?? existingModel.instructions, + tags: updateData['tags'] != null + ? List.from(updateData['tags'] as List) + : existingModel.tags, + imageUrl: updateData['imageUrl'] as String? ?? existingModel.imageUrl, + ); + + // Convert to Drift UserTask for update using update().write() for better null handling + final rewardsJson = updatedModel.rewards.map((r) => r.toJson()).toList(); + final now = PgDateTime(DateTime.now()); + + await (_db.update( + _db.userTasks, + )..where((ut) => ut.id.equals(taskId))).write( + UserTasksCompanion( + title: drift.Value(updatedModel.title), + description: drift.Value(updatedModel.description), + type: drift.Value(updatedModel.type), + difficulty: drift.Value(updatedModel.difficulty), + status: drift.Value(updatedModel.status), + rewards: drift.Value(rewardsJson), + expiresAt: drift.Value(PgDateTime(updatedModel.expiresAt)), + completedAt: updatedModel.completedAt != null + ? drift.Value(PgDateTime(updatedModel.completedAt!)) + : const drift.Value.absent(), + proofUrl: updatedModel.proofUrl != null + ? drift.Value(updatedModel.proofUrl!) + : const drift.Value.absent(), + instructions: updatedModel.instructions != null + ? drift.Value(updatedModel.instructions!) + : const drift.Value.absent(), + tags: drift.Value(updatedModel.tags), + imageUrl: updatedModel.imageUrl != null + ? drift.Value(updatedModel.imageUrl!) + : const drift.Value.absent(), + updatedAt: drift.Value(now), + ), + ); + + // Fetch updated task + final updatedTask = await _taskRepository.getUserTaskById(taskId); + if (updatedTask == null) { + return _json({ + 'error': 'Internal server error', + 'message': 'Failed to retrieve updated task', + }, statusCode: 500); + } + + final model = _userTaskToModel(updatedTask); + return _json({'success': true, 'task': model.toJson()}); + } catch (e, s) { + log('Error in updateTask: $e\n$s'); + return _json({ + 'error': 'Internal server error', + 'message': 'Failed to update task', + }, statusCode: 500); + } + } + + /// DELETE /api/v2/admin/tasks/{taskId} + /// Delete a task + @Route.delete('/admin/tasks/') + Future deleteTask(Request request, String taskId) async { + try { + final auth = await _ensureAdmin(request); + if (auth.statusCode != 200) { + return auth; + } + + if (taskId.isEmpty) { + return _json({ + 'error': 'Invalid task ID', + 'message': 'Task ID cannot be empty', + 'field': 'taskId', + }, statusCode: 400); + } + + // Check if task exists + final task = await _taskRepository.getUserTaskById(taskId); + if (task == null) { + return _json({ + 'error': 'Task not found', + 'message': 'The task you are trying to delete does not exist', + }, statusCode: 404); + } + + // Delete task + final deleted = await _taskRepository.deleteUserTask(taskId); + if (!deleted) { + return _json({ + 'error': 'Internal server error', + 'message': 'Failed to delete task', + }, statusCode: 500); + } + + return _json({'success': true, 'message': 'Task deleted successfully'}); + } catch (e, s) { + log('Error in deleteTask: $e\n$s'); + return _json({ + 'error': 'Internal server error', + 'message': 'Failed to delete task', + }, statusCode: 500); + } + } + + Router get router => _$AdminTasksApiV2Router(this); +} diff --git a/mnemo_cards_backend/lib/api/v2/admin_tasks_api_v2.g.dart b/mnemo_cards_backend/lib/api/v2/admin_tasks_api_v2.g.dart new file mode 100644 index 0000000..bdf6168 --- /dev/null +++ b/mnemo_cards_backend/lib/api/v2/admin_tasks_api_v2.g.dart @@ -0,0 +1,17 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'admin_tasks_api_v2.dart'; + +// ************************************************************************** +// ShelfRouterGenerator +// ************************************************************************** + +Router _$AdminTasksApiV2Router(AdminTasksApiV2 service) { + final router = Router(); + router.add('GET', r'/admin/tasks', service.getTasks); + router.add('GET', r'/admin/tasks/', service.getTask); + router.add('POST', r'/admin/tasks', service.createTask); + router.add('PUT', r'/admin/tasks/', service.updateTask); + router.add('DELETE', r'/admin/tasks/', service.deleteTask); + return router; +} diff --git a/mnemo_cards_backend/lib/repository/achievement_repository.dart b/mnemo_cards_backend/lib/repository/achievement_repository.dart new file mode 100644 index 0000000..4944332 --- /dev/null +++ b/mnemo_cards_backend/lib/repository/achievement_repository.dart @@ -0,0 +1,57 @@ +import 'package:injectable/injectable.dart'; +import 'package:mnemo_cards_backend/database/database.dart'; + +/// Repository для работы с достижениями +/// Работает с Drift моделями (UserAchievement), использует DAO для доступа к БД +@lazySingleton +class AchievementRepository { + final AppDatabase _db; + + AchievementRepository(this._db); + + /// Получить все достижения пользователя + Future> getUserAchievements(String userId) async { + return await _db.achievementDao.getUserAchievements(userId); + } + + /// Получить достижение пользователя + Future getUserAchievement( + String userId, + String achievementId, + ) async { + return await _db.achievementDao.getUserAchievement(userId, achievementId); + } + + /// Проверить, есть ли у пользователя достижение + Future hasAchievement(String userId, String achievementId) async { + return await _db.achievementDao.hasAchievement(userId, achievementId); + } + + /// Создать достижение пользователя + Future unlockAchievement(UserAchievementsCompanion companion) async { + return await _db.achievementDao.unlockAchievement(companion); + } + + /// Обновить прогресс достижения + Future updateAchievementProgress( + String userId, + String achievementId, + double progress, + ) async { + return await _db.achievementDao.updateAchievementProgress( + userId, + achievementId, + progress, + ); + } + + /// Получить прогресс по всем достижениям пользователя + Future> getAchievementProgress(String userId) async { + return await _db.achievementDao.getAchievementProgress(userId); + } + + /// Удалить достижение пользователя (для сброса) + Future removeAchievement(String userId, String achievementId) async { + await _db.achievementDao.removeAchievement(userId, achievementId); + } +} diff --git a/mnemo_cards_backend/lib/repository/discount_repository.dart b/mnemo_cards_backend/lib/repository/discount_repository.dart new file mode 100644 index 0000000..80fd30f --- /dev/null +++ b/mnemo_cards_backend/lib/repository/discount_repository.dart @@ -0,0 +1,138 @@ +import 'package:injectable/injectable.dart'; +import 'package:mnemo_cards_backend/database/database.dart'; + +/// Repository для работы со скидками +/// Работает с Drift моделями (DiscountCampaign, Discount), использует DAO для доступа к БД +@lazySingleton +class DiscountRepository { + final AppDatabase _db; + + DiscountRepository(this._db); + + // ==================== DiscountCampaigns ==================== + + /// Получить кампанию по ID + Future getCampaignById(String id) async { + return await _db.discountDao.getCampaignById(id); + } + + /// Получить все активные кампании + Future> getActiveCampaigns() async { + return await _db.discountDao.getActiveCampaigns(); + } + + /// Получить все кампании + Future> getAllCampaigns() async { + return await _db.discountDao.getAllCampaigns(); + } + + /// Получить кампании по статусу + Future> getCampaignsByStatus(String status) async { + return await _db.discountDao.getCampaignsByStatus(status); + } + + /// Получить кампании по статусам + Future> getCampaignsByStatuses( + List statuses, + ) async { + return await _db.discountDao.getCampaignsByStatuses(statuses); + } + + /// Получить активные кампании для пользователя с учетом тегов и продуктов + Future> getActiveCampaignsForUser({ + required List userTags, + String? productType, + String? productId, + }) async { + return await _db.discountDao.getActiveCampaignsForUser( + userTags: userTags, + productType: productType, + productId: productId, + ); + } + + /// Создать кампанию + Future createCampaign(DiscountCampaignsCompanion campaign) async { + return await _db.discountDao.createCampaign(campaign); + } + + /// Обновить кампанию + Future updateCampaign(DiscountCampaign campaign) async { + return await _db.discountDao.updateCampaign(campaign); + } + + /// Обновить статус кампании + Future updateCampaignStatus(String campaignId, String status) async { + await _db.discountDao.updateCampaignStatus(campaignId, status); + } + + /// Удалить кампанию (soft delete) + Future deleteCampaign(String campaignId) async { + await _db.discountDao.deleteCampaign(campaignId); + } + + // ==================== Discounts ==================== + + /// Получить скидку по ID + Future getDiscountById(String id) async { + return await _db.discountDao.getDiscountById(id); + } + + /// Получить скидки кампании + Future> getDiscountsByCampaignId(String campaignId) async { + return await _db.discountDao.getDiscountsByCampaignId(campaignId); + } + + /// Создать скидку + Future createDiscount(DiscountsCompanion discount) async { + return await _db.discountDao.createDiscount(discount); + } + + /// Обновить скидку + Future updateDiscount(Discount discount) async { + return await _db.discountDao.updateDiscount(discount); + } + + /// Удалить скидку (soft delete) + Future softDeleteDiscount(String discountId) async { + await _db.discountDao.softDeleteDiscount(discountId); + } + + // ==================== DiscountUserDatas ==================== + + /// Получить скидки пользователя + Future> getUserDiscounts(String userId) async { + return await _db.discountDao.getUserDiscounts(userId); + } + + /// Дать пользователю доступ к скидке + Future grantDiscountToUser(String userId, String discountId) async { + await _db.discountDao.grantDiscountToUser(userId, discountId); + } + + /// Дать пользователю доступ к нескольким скидкам + Future grantDiscountsToUser( + String userId, + List discountIds, + ) async { + await _db.discountDao.grantDiscountsToUser(userId, discountIds); + } + + /// Отозвать скидку у пользователя + Future revokeDiscountFromUser(String userId, String discountId) async { + await _db.discountDao.revokeDiscountFromUser(userId, discountId); + } + + /// Отозвать несколько скидок у пользователя + Future revokeDiscountsFromUser( + String userId, + List discountIds, + ) async { + await _db.discountDao.revokeDiscountsFromUser(userId, discountIds); + } + + /// Проверить, есть ли у пользователя доступ к скидке + Future hasDiscountAccess(String userId, String discountId) async { + return await _db.discountDao.hasDiscountAccess(userId, discountId); + } +} diff --git a/mnemo_cards_backend/lib/repository/export.dart b/mnemo_cards_backend/lib/repository/export.dart new file mode 100644 index 0000000..f61c7f6 --- /dev/null +++ b/mnemo_cards_backend/lib/repository/export.dart @@ -0,0 +1,13 @@ +export 'user_repository.dart'; +export 'pack_repository.dart'; +export 'iap_repository.dart'; +export 'payment_repository.dart'; +export 'subscription_repository.dart'; +export 'test_repository.dart'; +export 'task_repository.dart'; +export 'promo_code_repository.dart'; +export 'discount_repository.dart'; +export 'statistics_repository.dart'; +export 'word_statistics_repository.dart'; +export 'achievement_repository.dart'; +export 'сonverters/export.dart'; diff --git a/mnemo_cards_backend/lib/repository/iap_repository.dart b/mnemo_cards_backend/lib/repository/iap_repository.dart new file mode 100644 index 0000000..f543a90 --- /dev/null +++ b/mnemo_cards_backend/lib/repository/iap_repository.dart @@ -0,0 +1,219 @@ +import 'package:googleapis/firestore/v1.dart'; + +import '../api/purchase/products.dart'; + +enum IAPSource { googleplay, appstore } + +abstract class Purchase { + final IAPSource iapSource; + final String orderId; + final String productId; + final String? userId; + final DateTime purchaseDate; + final ProductType type; + + const Purchase({ + required this.iapSource, + required this.orderId, + required this.productId, + required this.userId, + required this.purchaseDate, + required this.type, + }); + + Map toDocument() { + return { + 'iapSource': Value(stringValue: iapSource.name), + 'orderId': Value(stringValue: orderId), + 'productId': Value(stringValue: productId), + 'userId': Value(stringValue: userId), + 'purchaseDate': Value( + timestampValue: purchaseDate.toUtc().toIso8601String(), + ), + 'type': Value(stringValue: type.name), + }; + } + + Map updateDocument(); + + static Purchase fromDocument(Document e) { + final type = ProductType.values.firstWhere( + (element) => element.name == e.fields!['type']!.stringValue, + ); + switch (type) { + case ProductType.subscription: + return SubscriptionPurchase( + iapSource: e.fields!['iapSource']!.stringValue == 'googleplay' + ? IAPSource.googleplay + : IAPSource.appstore, + orderId: e.fields!['orderId']!.stringValue!, + productId: e.fields!['productId']!.stringValue!, + userId: e.fields!['userId']?.stringValue, + purchaseDate: DateTime.parse( + e.fields!['purchaseDate']!.timestampValue!, + ), + status: SubscriptionStatus.values.firstWhere( + (element) => element.name == e.fields!['status']!.stringValue, + ), + expiryDate: + DateTime.tryParse( + e.fields!['expiryDate']?.timestampValue ?? '', + ) ?? + DateTime.now(), + ); + case ProductType.nonSubscription: + return NonSubscriptionPurchase( + iapSource: e.fields!['iapSource']!.stringValue == 'googleplay' + ? IAPSource.googleplay + : IAPSource.appstore, + orderId: e.fields!['orderId']!.stringValue!, + productId: e.fields!['productId']!.stringValue!, + userId: e.fields!['userId']?.stringValue, + purchaseDate: DateTime.parse( + e.fields!['purchaseDate']!.timestampValue!, + ), + status: NonSubscriptionStatus.values.firstWhere( + (element) => element.name == e.fields!['status']!.stringValue, + ), + ); + } + } +} + +enum NonSubscriptionStatus { pending, completed, cancelled } + +enum SubscriptionStatus { pending, active, expired } + +class NonSubscriptionPurchase extends Purchase { + final NonSubscriptionStatus status; + + NonSubscriptionPurchase({ + required super.iapSource, + required super.orderId, + required super.productId, + required super.userId, + required super.purchaseDate, + required this.status, + super.type = ProductType.nonSubscription, + }); + + @override + Map toDocument() { + final doc = super.toDocument(); + doc.addAll({'status': Value(stringValue: status.name)}); + return doc; + } + + @override + Map updateDocument() { + return {'status': Value(stringValue: status.name)}; + } + + @override + String toString() { + return 'NonSubscriptionPurchase { ' + 'iapSource: $iapSource, ' + 'orderId: $orderId, ' + 'productId: $productId, ' + 'userId: $userId, ' + 'purchaseDate: $purchaseDate, ' + 'status: $status, ' + 'type: $type ' + '}'; + } +} + +class SubscriptionPurchase extends Purchase { + final SubscriptionStatus status; + final DateTime expiryDate; + + SubscriptionPurchase({ + required super.iapSource, + required super.orderId, + required super.productId, + required super.userId, + required super.purchaseDate, + required this.status, + required this.expiryDate, + super.type = ProductType.subscription, + }); + + @override + Map toDocument() { + final doc = super.toDocument(); + doc.addAll({ + 'expiryDate': Value(timestampValue: expiryDate.toUtc().toIso8601String()), + 'status': Value(stringValue: status.name), + }); + return doc; + } + + @override + Map updateDocument() { + return {'status': Value(stringValue: status.name)}; + } + + @override + String toString() { + return 'SubscriptionPurchase { ' + 'iapSource: $iapSource, ' + 'orderId: $orderId, ' + 'productId: $productId, ' + 'userId: $userId, ' + 'purchaseDate: $purchaseDate, ' + 'status: $status, ' + 'expiryDate: $expiryDate, ' + 'type: $type ' + '}'; + } +} + +class IapRepository { + final FirestoreApi api; + final String projectId; + + IapRepository(this.api, this.projectId); + + Future createOrUpdatePurchase(Purchase purchaseData) async { + print('Updating $purchaseData'); + final purchaseId = _purchaseId(purchaseData); + await api.projects.databases.documents.commit( + CommitRequest( + writes: [ + Write( + update: Document( + fields: purchaseData.toDocument(), + name: + 'projects/$projectId/databases/(default)/documents/purchases/$purchaseId', + ), + ), + ], + ), + 'projects/$projectId/databases/(default)', + ); + } + + Future updatePurchase(Purchase purchaseData) async { + print('Updating $purchaseData'); + final purchaseId = _purchaseId(purchaseData); + await api.projects.databases.documents.commit( + CommitRequest( + writes: [ + Write( + update: Document( + fields: purchaseData.updateDocument(), + name: + 'projects/$projectId/databases/(default)/documents/purchases/$purchaseId', + ), + updateMask: DocumentMask(fieldPaths: ['status']), + ), + ], + ), + 'projects/$projectId/databases/(default)', + ); + } + + String _purchaseId(Purchase purchaseData) { + return '${purchaseData.iapSource.name}_${purchaseData.orderId}'; + } +} diff --git a/mnemo_cards_backend/lib/repository/pack_repository.dart b/mnemo_cards_backend/lib/repository/pack_repository.dart new file mode 100644 index 0000000..e847b1c --- /dev/null +++ b/mnemo_cards_backend/lib/repository/pack_repository.dart @@ -0,0 +1,224 @@ +import 'package:drift/drift.dart'; +import 'package:injectable/injectable.dart'; +import 'package:mnemo_cards_backend/database/database.dart'; +import 'package:mnemo_cards_backend/repository/сonverters/export.dart'; +import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart' + hide VoiceModel; + +/// Repository для работы с паками +/// Работает с доменными моделями (CardPackModel), использует DAO для доступа к БД +@lazySingleton +class PackRepository { + final AppDatabase _db; + final CardPackConverter _cardPackConverter; + final GameCardConverter _gameCardConverter; + + PackRepository(this._db, this._cardPackConverter, this._gameCardConverter); + + /// Получить пак по ID + /// [includeCards] - загружать ли связанные карточки + /// [includePreviewCards] - загружать ли превью карточки + Future getPackById( + String id, { + bool includeCards = false, + bool includePreviewCards = false, + }) async { + final pack = await _db.packDao.getPackById(id); + if (pack == null) return null; + + final packModel = await _cardPackConverter.toModel(pack); + + if (includeCards) { + final cards = await _db.packDao.getPackCards(id); + packModel.cards.addAll(cards.map((c) => _gameCardConverter.toModel(c))); + } + + if (includePreviewCards) { + final previewCards = await _db.packDao.getPreviewCards(id); + packModel.previewCards.addAll( + previewCards.map((c) => _gameCardConverter.toModel(c)), + ); + } + + return packModel; + } + + /// Получить все паки + Future> getAllPacks({ + bool enabledOnly = false, + String? orderByField, + bool orderDesc = false, + bool includeCards = false, + bool includePreviewCards = false, + }) async { + final packs = await _db.packDao.getAllPacks( + enabledOnly: enabledOnly, + orderByField: orderByField, + orderDesc: orderDesc, + ); + + if (includeCards || includePreviewCards) { + final packModels = []; + for (final pack in packs) { + final packModel = await _cardPackConverter.toModel(pack); + + if (includeCards) { + final cards = await _db.packDao.getPackCards(pack.id); + packModel.cards.addAll( + cards.map((c) => _gameCardConverter.toModel(c)), + ); + } + + if (includePreviewCards) { + final previewCards = await _db.packDao.getPreviewCards(pack.id); + packModel.previewCards.addAll( + previewCards.map((c) => _gameCardConverter.toModel(c)), + ); + } + + packModels.add(packModel); + } + return packModels; + } + + return await Future.wait(packs.map((p) => _cardPackConverter.toModel(p))); + } + + /// Создать пак + Future createPack(CardPackModel packModel) async { + final companion = CardPacksCompanion.insert( + title: packModel.title, + subtitle: packModel.subtitle, + size: packModel.size, + color: Value(packModel.color), + version: Value(packModel.version), + cover: Value(packModel.cover), + description: Value(packModel.description), + googlePlayId: Value(packModel.googlePlayId), + rustoreId: Value(packModel.rustoreId), + appStoreId: Value(packModel.appStoreId), + price: Value(packModel.price), + currency: packModel.currency != null + ? Value(packModel.currency!) + : const Value.absent(), + enabled: Value(packModel.enabled), + order: Value(packModel.order), + cardsOrder: Value(packModel.cardsOrder), + ); + + return await _db.packDao.createPack(companion); + } + + /// Обновить пак частично + Future updatePackPartial(CardPacksCompanion updates) async { + await _db.packDao.updatePackPartial(updates); + } + + /// Удалить пак (soft delete) + Future softDeletePack(String packId) async { + await _db.packDao.softDeletePack(packId); + } + + /// Получить карточки пака + Future> getPackCards(String packId) async { + final cards = await _db.packDao.getPackCards(packId); + return cards.map((c) => _gameCardConverter.toModel(c)).toList(); + } + + /// Получить превью карточки пака + Future> getPreviewCards(String packId) async { + final cards = await _db.packDao.getPreviewCards(packId); + return cards.map((c) => _gameCardConverter.toModel(c)).toList(); + } + + /// Получить карточку по ID + Future getCardById(String id) async { + final card = await _db.packDao.getCardById(id); + return card != null ? _gameCardConverter.toModel(card) : null; + } + + /// Добавить карточку в пак + Future addCardToPack({ + required String packId, + required String cardId, + int order = 0, + }) async { + await _db.packDao.addCardToPack( + packId: packId, + cardId: cardId, + order: order, + ); + } + + /// Удалить карточку из пака + Future removeCardFromPack(String packId, String cardId) async { + await _db.packDao.removeCardFromPack(packId, cardId); + } + + /// Обновить порядок карточек в паке + Future updatePackCardsOrder(String packId, List cardIds) async { + await _db.packDao.updatePackCardsOrder(packId, cardIds); + } + + /// Установить preview карточки для пака + Future setPreviewCards(String packId, List cardIds) async { + await _db.packDao.setPreviewCards(packId, cardIds); + } + + // ==================== VoiceModels ==================== + + /// Получить голосовые модели карточки + Future> getCardVoices(String cardId) async { + return await _db.packDao.getCardVoices(cardId); + } + + /// Получить голосовую модель по ID + Future getVoiceById(String id) async { + return await _db.packDao.getVoiceById(id); + } + + /// Обновить путь/URL аудиофайла голосовой модели + Future updateVoiceUrl(String voiceId, String voiceUrl) async { + await _db.packDao.updateVoiceUrl(voiceId, voiceUrl); + } + + // ==================== Cards (дополнительные методы) ==================== + + /// Создать карточку + Future createCard(GameCardsCompanion companion) async { + return await _db.packDao.createCard(companion); + } + + /// Обновить карточку + Future updateCard(GameCard card) async { + return await _db.packDao.updateCard(card); + } + + /// Получить все карточки + Future> getAllCards({int? limit, int? offset}) async { + final cards = await _db.packDao.getAllCards(limit: limit, offset: offset); + return cards.map((c) => _gameCardConverter.toModel(c)).toList(); + } + + /// Получить паки для карточки + Future> getPacksForCard(String cardId) async { + final packs = await _db.packDao.getPacksForCard(cardId); + return await Future.wait(packs.map((p) => _cardPackConverter.toModel(p))); + } + + /// Поиск карточек по оригинальному тексту + Future> searchCardsByOriginal(String original) async { + final cards = await _db.packDao.searchCardsByOriginal(original); + return cards.map((c) => _gameCardConverter.toModel(c)).toList(); + } + + /// Подсчитать карточки + Future countCards() async { + return await _db.packDao.countCards(); + } + + /// Подсчитать паки + Future countPacks({bool enabledOnly = false}) async { + return await _db.packDao.countPacks(enabledOnly: enabledOnly); + } +} diff --git a/mnemo_cards_backend/lib/repository/payment_repository.dart b/mnemo_cards_backend/lib/repository/payment_repository.dart new file mode 100644 index 0000000..2ffad47 --- /dev/null +++ b/mnemo_cards_backend/lib/repository/payment_repository.dart @@ -0,0 +1,112 @@ +import 'package:injectable/injectable.dart'; +import 'package:mnemo_cards_backend/database/database.dart'; +import 'package:mnemo_cards_backend/api/purchase/payment_drift_extension.dart'; +import 'package:mnemo_cards_common/mnemo_cards_common.dart'; + +/// Repository для работы с платежами +/// Работает с доменными моделями (PaymentDto), использует DAO для доступа к БД +@lazySingleton +class PaymentRepository { + final AppDatabase _db; + + PaymentRepository(this._db); + + /// Создать платеж + Future createPayment(PaymentDto paymentDto, String userId) async { + final companion = paymentDto.toCompanion(userId); + final paymentId = await _db.paymentDao.createPayment(companion); + final payment = await _db.paymentDao.getPaymentById(paymentId); + if (payment == null) { + throw Exception('Failed to create payment'); + } + return payment.toDto(); + } + + /// Получить платеж по ID + Future getPaymentById(String id) async { + final payment = await _db.paymentDao.getPaymentById(id); + return payment?.toDto(); + } + + /// Получить платеж по externalToken + Future getPaymentByExternalToken(String token) async { + final payment = await _db.paymentDao.getPaymentByExternalToken(token); + return payment?.toDto(); + } + + /// Получить последний платеж для пользователя и пака + Future getLatestPaymentForUserAndPack({ + required String userId, + required String packId, + }) async { + final payment = await _db.paymentDao.getLatestPaymentForUserAndPack( + userId: userId, + packId: packId, + ); + return payment?.toDto(); + } + + /// Получить платежи пользователя + Future> getPaymentsByUserId( + String userId, { + int? limit, + int? offset, + }) async { + final payments = await _db.paymentDao.getPaymentsByUserId( + userId, + limit: limit, + offset: offset, + ); + return payments.map((p) => p.toDto()).toList(); + } + + /// Получить платежи по продукту + Future> getPaymentsByProduct(String productId) async { + final payments = await _db.paymentDao.getPaymentsByProduct(productId); + return payments.map((p) => p.toDto()).toList(); + } + + /// Получить платежи по статусу + Future> getPaymentsByStatus(String status) async { + final payments = await _db.paymentDao.getPaymentsByStatus(status); + return payments.map((p) => p.toDto()).toList(); + } + + /// Получить все платежи (для админки) + Future> getAllPayments({int? limit, int? offset}) async { + final payments = await _db.paymentDao.getAllPayments( + limit: limit, + offset: offset, + ); + return payments.map((p) => p.toDto()).toList(); + } + + /// Обновить платеж + Future updatePayment(String paymentId, PaymentDto paymentDto) async { + final companion = paymentDto.toUpdateCompanion(); + await _db.paymentDao.updatePaymentCompanion(paymentId, companion); + } + + /// Обновить статус платежа + Future updatePaymentStatus(String externalToken, String status) async { + await _db.paymentDao.updatePaymentStatus(externalToken, status); + } + + /// Обновить платеж частично + Future updatePaymentCompanion( + String paymentId, + PaymentsCompanion companion, + ) async { + await _db.paymentDao.updatePaymentCompanion(paymentId, companion); + } + + /// Подсчитать все платежи + Future countPayments() async { + return await _db.paymentDao.countAllPayments(); + } + + /// Подсчитать платежи пользователя + Future countPaymentsByUserId(String userId) async { + return await _db.paymentDao.countPaymentsByUserId(userId); + } +} diff --git a/mnemo_cards_backend/lib/repository/promo_code_repository.dart b/mnemo_cards_backend/lib/repository/promo_code_repository.dart new file mode 100644 index 0000000..67521cc --- /dev/null +++ b/mnemo_cards_backend/lib/repository/promo_code_repository.dart @@ -0,0 +1,94 @@ +import 'package:injectable/injectable.dart'; +import 'package:mnemo_cards_backend/database/database.dart'; + +/// Repository для работы с промокодами +/// Работает с Drift моделями (PromoCodesCampaign, PromoCode), использует DAO для доступа к БД +@lazySingleton +class PromoCodeRepository { + final AppDatabase _db; + + PromoCodeRepository(this._db); + + // ==================== PromoCodesCampaigns ==================== + + /// Получить кампанию по ID + Future getCampaignById(String id) async { + return await _db.promoCodeDao.getCampaignById(id); + } + + /// Получить все активные кампании + Future> getActiveCampaigns() async { + return await _db.promoCodeDao.getActiveCampaigns(); + } + + /// Получить кампании по статусам + Future> getCampaignsByStatuses( + List statuses, + ) async { + return await _db.promoCodeDao.getCampaignsByStatuses(statuses); + } + + /// Создать кампанию + Future createCampaign(PromoCodesCampaignsCompanion campaign) async { + return await _db.promoCodeDao.createCampaign(campaign); + } + + /// Обновить кампанию + Future updateCampaign(PromoCodesCampaign campaign) async { + return await _db.promoCodeDao.updateCampaign(campaign); + } + + /// Обновить статус кампании + Future updateCampaignStatus(String campaignId, String status) async { + await _db.promoCodeDao.updateCampaignStatus(campaignId, status); + } + + /// Удалить кампанию (soft delete) + Future softDeleteCampaign(String campaignId) async { + await _db.promoCodeDao.softDeleteCampaign(campaignId); + } + + // ==================== PromoCodes ==================== + + /// Получить промокод по коду + Future getPromoCodeByCode(String code) async { + return await _db.promoCodeDao.getPromoCodeByCode(code); + } + + /// Получить промокоды кампании + Future> getPromoCodesByCampaignId(String campaignId) async { + return await _db.promoCodeDao.getPromoCodesByCampaignId(campaignId); + } + + /// Получить промокоды пользователя + Future> getUserPromoCodes(String userId) async { + return await _db.promoCodeDao.getUserPromoCodes(userId); + } + + /// Создать промокод + Future createPromoCode(PromoCodesCompanion promoCode) async { + return await _db.promoCodeDao.createPromoCode(promoCode); + } + + /// Создать несколько промокодов + Future> createPromoCodes( + List promoCodes, + ) async { + return await _db.promoCodeDao.createPromoCodes(promoCodes); + } + + /// Обновить промокод + Future updatePromoCode(PromoCode promoCode) async { + return await _db.promoCodeDao.updatePromoCode(promoCode); + } + + /// Увеличить счетчик активаций + Future incrementActivations(String promoCodeId) async { + await _db.promoCodeDao.incrementActivations(promoCodeId); + } + + /// Удалить промокод (soft delete) + Future softDeletePromoCode(String promoCodeId) async { + await _db.promoCodeDao.softDeletePromoCode(promoCodeId); + } +} diff --git a/mnemo_cards_backend/lib/repository/statistics_repository.dart b/mnemo_cards_backend/lib/repository/statistics_repository.dart new file mode 100644 index 0000000..484140d --- /dev/null +++ b/mnemo_cards_backend/lib/repository/statistics_repository.dart @@ -0,0 +1,73 @@ +import 'package:injectable/injectable.dart'; +import 'package:mnemo_cards_backend/database/database.dart'; + +/// Repository для работы со статистикой сессий +/// Работает с Drift моделями (StudySession), использует DAO для доступа к БД +@lazySingleton +class StatisticsRepository { + final AppDatabase _db; + + StatisticsRepository(this._db); + + /// Получить сессию по ID + Future getSessionById(String id) async { + return await _db.statisticsDao.getSessionById(id); + } + + /// Получить сессию по sessionId + Future getSessionBySessionId(String sessionId) async { + return await _db.statisticsDao.getSessionBySessionId(sessionId); + } + + /// Получить активные сессии пользователя + Future> getActiveSessions(String userId) async { + return await _db.statisticsDao.getActiveSessions(userId); + } + + /// Получить сессии пользователя + Future> getSessionsByUserId( + String userId, { + int? limit, + int? offset, + DateTime? fromDate, + DateTime? toDate, + }) async { + return await _db.statisticsDao.getSessionsByUserId( + userId, + limit: limit, + offset: offset, + fromDate: fromDate, + toDate: toDate, + ); + } + + /// Создать сессию + Future createSession(StudySessionsCompanion session) async { + return await _db.statisticsDao.createSession(session); + } + + /// Обновить сессию + Future updateSession(StudySession session) async { + return await _db.statisticsDao.updateSession(session); + } + + /// Завершить сессию + Future endSession( + String sessionId, { + int? wordsLearned, + int? testsCompleted, + double? accuracy, + }) async { + await _db.statisticsDao.endSession( + sessionId, + wordsLearned: wordsLearned, + testsCompleted: testsCompleted, + accuracy: accuracy, + ); + } + + /// Подсчитать сессии пользователя + Future countSessionsByUserId(String userId) async { + return await _db.statisticsDao.countSessionsByUserId(userId); + } +} diff --git a/mnemo_cards_backend/lib/repository/subscription_repository.dart b/mnemo_cards_backend/lib/repository/subscription_repository.dart new file mode 100644 index 0000000..56a74d4 --- /dev/null +++ b/mnemo_cards_backend/lib/repository/subscription_repository.dart @@ -0,0 +1,85 @@ +import 'package:injectable/injectable.dart'; +import 'package:mnemo_cards_backend/database/database.dart'; +import 'package:mnemo_cards_backend/repository/сonverters/subscription_converter.dart'; +import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; + +/// Repository для работы с подписками +/// Работает с доменными моделями (SubscriptionPlanModel, UserSubscriptionModel), использует DAO для доступа к БД +@lazySingleton +class SubscriptionRepository { + final AppDatabase _db; + final SubscriptionConverter _subscriptionConverter; + + SubscriptionRepository(this._db, this._subscriptionConverter); + + // ==================== SubscriptionPlans ==================== + + /// Получить план подписки по ID + Future getPlanById(String id) async { + final plan = await _db.subscriptionDao.getPlanById(id); + if (plan == null) return null; + return _subscriptionConverter.planToModel(plan); + } + + /// Получить все планы подписки + Future> getAllPlans() async { + final plans = await _db.subscriptionDao.getAllPlans(); + return plans.map((p) => _subscriptionConverter.planToModel(p)).toList(); + } + + /// Создать план подписки + Future createPlan(SubscriptionPlansCompanion companion) async { + return await _db.subscriptionDao.createPlan(companion); + } + + /// Обновить план подписки + Future updatePlan(SubscriptionPlan plan) async { + return await _db.subscriptionDao.updatePlan(plan); + } + + // ==================== UserSubscriptions ==================== + + /// Получить активную подписку пользователя + Future getActiveSubscription(String userId) async { + final subscription = await _db.subscriptionDao.getActiveUserSubscription( + userId, + ); + if (subscription == null) return null; + return _subscriptionConverter.toModel(subscription); + } + + /// Получить подписку пользователя (последнюю) + Future getUserSubscription(String userId) async { + final subscription = await _db.subscriptionDao.getUserSubscription(userId); + if (subscription == null) return null; + return _subscriptionConverter.toModel(subscription); + } + + /// Проверить, есть ли у пользователя активная подписка + Future hasActiveSubscription(String userId) async { + return await _db.subscriptionDao.hasActiveSubscription(userId); + } + + /// Создать подписку пользователя + Future createSubscription( + UserSubscriptionsCompanion companion, + ) async { + return await _db.subscriptionDao.createUserSubscription(companion); + } + + /// Обновить подписку пользователя + Future updateSubscription(UserSubscription subscription) async { + return await _db.subscriptionDao.updateUserSubscription(subscription); + } + + /// Отменить подписку пользователя + Future cancelSubscription(String userId) async { + await _db.subscriptionDao.cancelUserSubscription(userId); + } + + /// Получить всех пользователей с активными подписками + Future> getActiveSubscriptions() async { + final subscriptions = await _db.subscriptionDao.getActiveSubscriptions(); + return subscriptions.map((s) => _subscriptionConverter.toModel(s)).toList(); + } +} diff --git a/mnemo_cards_backend/lib/repository/task_repository.dart b/mnemo_cards_backend/lib/repository/task_repository.dart new file mode 100644 index 0000000..909c10a --- /dev/null +++ b/mnemo_cards_backend/lib/repository/task_repository.dart @@ -0,0 +1,127 @@ +import 'package:injectable/injectable.dart'; +import 'package:mnemo_cards_backend/database/database.dart'; + +/// Repository для работы с задачами +/// Работает с Drift моделями (Task, UserTask), использует DAO для доступа к БД +@lazySingleton +class TaskRepository { + final AppDatabase _db; + + TaskRepository(this._db); + + // ==================== Tasks ==================== + + /// Получить задачу по ID + Future getTaskById(String id) async { + return await _db.taskDao.getTaskById(id); + } + + /// Получить все задачи + Future> getAllTasks() async { + return await _db.taskDao.getAllTasks(); + } + + /// Создать задачу + Future createTask(TasksCompanion task) async { + return await _db.taskDao.createTask(task); + } + + /// Обновить задачу + Future updateTask(Task task) async { + return await _db.taskDao.updateTask(task); + } + + /// Обновить время последнего выполнения + Future updateLastExecution(String taskId) async { + await _db.taskDao.updateLastExecution(taskId); + } + + // ==================== UserTasks ==================== + + /// Получить задачу пользователя по ID + Future getUserTaskById(String id) async { + return await _db.taskDao.getUserTaskById(id); + } + + /// Получить все задачи пользователей + Future> getAllUserTasks() async { + return await _db.taskDao.getAllUserTasks(); + } + + /// Получить задачи пользователя + Future> getUserTasks( + String userId, { + String? status, + bool activeOnly = false, + }) async { + return await _db.taskDao.getUserTasks( + userId, + status: status, + activeOnly: activeOnly, + ); + } + + /// Создать задачу пользователя + Future createUserTask(UserTasksCompanion task) async { + return await _db.taskDao.createUserTask(task); + } + + /// Создать задачу пользователя или игнорировать, если уже существует + Future createUserTaskOrIgnore(UserTasksCompanion task) async { + return await _db.taskDao.createUserTaskOrIgnore(task); + } + + /// Обновить задачу пользователя + Future updateUserTask(UserTask task) async { + return await _db.taskDao.updateUserTask(task); + } + + /// Завершить задачу пользователя + Future completeUserTask(String taskId) async { + await _db.taskDao.completeUserTask(taskId); + } + + /// Подсчитать задачи пользователя + Future countUserTasks(String userId, {String? status}) async { + return await _db.taskDao.countUserTasks(userId, status: status); + } + + /// Удалить задачу пользователя + Future deleteUserTask(String taskId) async { + return await _db.taskDao.deleteUserTask(taskId); + } + + // ==================== UserTaskProgresses ==================== + + /// Получить прогресс задачи пользователя + Future getTaskProgress( + String userId, + String taskId, + ) async { + return await _db.taskDao.getTaskProgress(userId, taskId); + } + + /// Создать прогресс задачи + Future createTaskProgress( + UserTaskProgressesCompanion progress, + ) async { + return await _db.taskDao.createTaskProgress(progress); + } + + /// Обновить прогресс задачи + Future updateTaskProgress(UserTaskProgressesData progress) async { + return await _db.taskDao.updateTaskProgress(progress); + } + + // ==================== UserTaskResults ==================== + + /// Получить результаты задач пользователя + Future> getTaskResults(String userId) async { + return await _db.taskDao.getTaskResults(userId); + } + + /// Создать результат задачи + Future createTaskResult(UserTaskResultsCompanion result) async { + return await _db.taskDao.createTaskResult(result); + } +} diff --git a/mnemo_cards_backend/lib/repository/test_repository.dart b/mnemo_cards_backend/lib/repository/test_repository.dart new file mode 100644 index 0000000..38772d4 --- /dev/null +++ b/mnemo_cards_backend/lib/repository/test_repository.dart @@ -0,0 +1,122 @@ +import 'package:injectable/injectable.dart'; +import 'package:mnemo_cards_backend/database/database.dart'; + +/// Repository для работы с тестами +/// Работает с Drift моделями (Test, TestQuestion, TestStatistic), использует DAO для доступа к БД +@lazySingleton +class TestRepository { + final AppDatabase _db; + + TestRepository(this._db); + + // ==================== Tests ==================== + + /// Получить тест по ID + Future getTestById(String id) async { + return await _db.testDao.getTestById(id); + } + + /// Получить все тесты + Future> getAllTests() async { + return await _db.testDao.getAllTests(); + } + + /// Получить тесты пака + Future> getTestsByPackId(String packId) async { + return await _db.testDao.getTestsByPackId(packId); + } + + /// Создать тест + Future createTest(TestsCompanion test) async { + return await _db.testDao.createTest(test); + } + + /// Обновить тест + Future updateTest(Test test) async { + return await _db.testDao.updateTest(test); + } + + /// Удалить тест (soft delete) + Future softDeleteTest(String testId) async { + await _db.testDao.softDeleteTest(testId); + } + + /// Hard delete старые сгенерированные тесты, которые были soft-deleted + Future hardDeleteOldSoftDeletedGeneratedTests({ + required Duration olderThan, + }) async { + return await _db.testDao.hardDeleteOldSoftDeletedGeneratedTests( + olderThan: olderThan, + ); + } + + /// Hard delete сгенерированные тесты, которые не связаны ни с одним паком + Future hardDeleteOrphanGeneratedTests({ + required Duration olderThan, + }) async { + return await _db.testDao.hardDeleteOrphanGeneratedTests( + olderThan: olderThan, + ); + } + + /// Связать тест с паком + Future linkTestToPack(String testId, String packId) async { + await _db.testDao.linkTestToPack(testId, packId); + } + + /// Удалить связь теста с паком + Future unlinkTestFromPack(String testId, String packId) async { + await _db.testDao.unlinkTestFromPack(testId, packId); + } + + /// Получить packId для теста + Future getPackIdForTest(String testId) async { + return await _db.testDao.getPackIdForTest(testId); + } + + /// Получить все packId для теста + Future> getPackIdsForTest(String testId) async { + return await _db.testDao.getPackIdsForTest(testId); + } + + // ==================== TestQuestions ==================== + + /// Получить вопросы теста + Future> getTestQuestions(String testId) async { + return await _db.testDao.getTestQuestions(testId); + } + + /// Создать вопрос теста + Future createTestQuestion(TestQuestionsCompanion question) async { + return await _db.testDao.createTestQuestion(question); + } + + /// Обновить вопрос теста + Future updateTestQuestion(TestQuestion question) async { + return await _db.testDao.updateTestQuestion(question); + } + + /// Удалить вопрос теста (soft delete) + Future softDeleteTestQuestion(String questionId) async { + await _db.testDao.softDeleteTestQuestion(questionId); + } + + // ==================== TestStatistics ==================== + + /// Получить статистику теста пользователя + Future getTestStatistics(String userId, String testId) async { + return await _db.testDao.getTestStatistics(userId, testId); + } + + /// Создать статистику теста + Future createTestStatistics( + TestStatisticsCompanion statistics, + ) async { + return await _db.testDao.createTestStatistics(statistics); + } + + /// Обновить статистику теста + Future updateTestStatistics(TestStatistic statistics) async { + return await _db.testDao.updateTestStatistics(statistics); + } +} diff --git a/mnemo_cards_backend/lib/repository/user_repository.dart b/mnemo_cards_backend/lib/repository/user_repository.dart new file mode 100644 index 0000000..813f647 --- /dev/null +++ b/mnemo_cards_backend/lib/repository/user_repository.dart @@ -0,0 +1,217 @@ +import 'package:drift/drift.dart'; +import 'package:drift_postgres/drift_postgres.dart'; +import 'package:injectable/injectable.dart'; +import 'package:mnemo_cards_backend/database/database.dart'; +import 'package:mnemo_cards_backend/repository/сonverters/export.dart'; +import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; + +/// Repository для работы с пользователями +/// Работает с доменными моделями (UserModel), использует DAO для доступа к БД +@lazySingleton +class UserRepository { + final AppDatabase _db; + final UserConverter _userConverter; + final CardPackConverter _cardPackConverter; + final SubscriptionConverter _subscriptionConverter; + + UserRepository( + this._db, + this._userConverter, + this._cardPackConverter, + this._subscriptionConverter, + ); + + /// Получить пользователя по ID + /// [includePacks] - загружать ли связанные паки пользователя + /// [withData] - загружать ли UserData пользователя + Future getUserById(String id, {bool includePacks = false}) async { + final user = await _db.userDao.getUserById(id); + if (user == null) return null; + + if (includePacks) { + final packs = await _db.userDao.getUserPacks(id); + return await _userConverter.toModel(user, packs); + } + + return await _userConverter.toModel(user); + } + + Future<(UserModel, UserData?)?> getUserWithDataById( + String id, { + bool includePacks = false, + }) async { + final userWithData = await _db.userDao.getUserWithDataById(id); + if (userWithData == null) return null; + + if (includePacks) { + final packs = await _db.userDao.getUserPacks(id); + final userModel = await _userConverter.toModel(userWithData.user, packs); + return (userModel, userWithData.userData); + } + + final userModel = await _userConverter.toModel(userWithData.user); + return (userModel, userWithData.userData); + } + + /// Получить пользователя по externalUserId + Future getUserByExternalId(String externalId) async { + final user = await _db.userDao.getUserByExternalId(externalId); + + if (user == null) return null; + + return await _userConverter.toModel(user); + } + + /// Создать пользователя с UserData + Future createUserWithData({ + required UserModel userModel, + required UserDatasCompanion userData, + }) async { + final userCompanion = _userConverter.toCompanion(userModel); + return await _db.userDao.createUserWithData( + user: userCompanion, + userData: userData, + ); + } + + /// Обновить пользователя + Future updateUser(UserModel userModel) async { + if (userModel.id == null) { + throw ArgumentError('User ID is required'); + } + + await _db.userDao.updateUserPartial( + _userConverter + .toCompanion(userModel) + .copyWith(updatedAt: Value(PgDateTime(DateTime.now()))), + ); + } + + /// Обновить пользователя частично + Future updateUserPartial(UsersCompanion updates) async { + await _db.userDao.updateUserPartial(updates); + } + + /// Удалить пользователя (soft delete) + Future softDeleteUser(String userId) async { + await _db.userDao.softDeleteUser(userId); + } + + /// Получить всех пользователей (для админки) + Future> getAllUsers({ + int? limit, + int? offset, + bool includeDeleted = false, + bool includePacks = false, + }) async { + final users = await _db.userDao.getAllUsers( + limit: limit, + offset: offset, + includeDeleted: includeDeleted, + ); + + if (includePacks) { + final userModels = []; + for (final user in users) { + final packs = await _db.userDao.getUserPacks(user.id); + final userModel = await _userConverter.toModel(user, packs); + userModels.add(userModel); + } + return userModels; + } + + return await Future.wait(users.map((u) => _userConverter.toModel(u))); + } + + /// Подсчитать пользователей + Future countUsers({bool includeDeleted = false}) async { + return await _db.userDao.countUsers(includeDeleted: includeDeleted); + } + + /// Получить паки пользователя + Future> getUserPacks(String userId) async { + final packs = await _db.userDao.getUserPacks(userId); + return await Future.wait(packs.map((p) => _cardPackConverter.toModel(p))); + } + + /// Получить UserData пользователя + Future getUserData(String userId) async { + return await _db.userDao.getUserData(userId); + } + + /// Получить UserData пользователя по ID (алиас для getUserData) + Future getUserDataById(String userId) async { + return await getUserData(userId); + } + + /// Создать UserData + Future createUserData(UserDatasCompanion userData) async { + await _db.userDao.createUserData(userData); + } + + /// Получить активную подписку пользователя + Future getUserSubscription(String userId) async { + final subscription = await _db.subscriptionDao.getActiveSubscription( + userId, + ); + if (subscription == null) return null; + return _subscriptionConverter.toModel(subscription); + } + + /// Обновить UserData частично + Future updateUserDataPartial(UserDatasCompanion updates) async { + await _db.userDao.updateUserDataPartial(updates); + } + + // ==================== Tokens ==================== + + /// Получить токен по значению + Future getTokenByValue(String tokenValue) async { + return await _db.userDao.getTokenByValue(tokenValue); + } + + /// Получить токен пользователя + Future getTokenByUserId(String userId) async { + return await _db.userDao.getTokenByUserId(userId); + } + + /// Создать токен + Future createToken(TokensCompanion token) async { + return await _db.userDao.createToken(token); + } + + /// Удалить токен (hard delete) + Future deleteToken(String token) async { + await _db.userDao.deleteToken(token); + } + + /// Удалить токен (soft delete) + Future softDeleteToken(String tokenId) async { + return await _db.userDao.softDeleteToken(tokenId); + } + + // ==================== User Packs ==================== + + /// Проверить, есть ли у пользователя доступ к паку + Future hasPackAccess(String userId, String packId) async { + return await _db.userDao.hasPackAccess(userId, packId); + } + + /// Дать пользователю доступ к паку + Future grantPackAccess({ + required String userId, + required String packId, + String grantType = 'purchase', + }) async { + await _db.userDao.grantPackAccess( + userId: userId, + packId: packId, + grantType: grantType, + ); + } + + /// Отозвать доступ к паку + Future revokePackAccess(String userId, String packId) async { + await _db.userDao.revokePackAccess(userId, packId); + } +} diff --git a/mnemo_cards_backend/lib/repository/word_statistics_repository.dart b/mnemo_cards_backend/lib/repository/word_statistics_repository.dart new file mode 100644 index 0000000..733f775 --- /dev/null +++ b/mnemo_cards_backend/lib/repository/word_statistics_repository.dart @@ -0,0 +1,59 @@ +import 'package:injectable/injectable.dart'; +import 'package:mnemo_cards_backend/database/database.dart'; + +/// Repository для работы со статистикой слов +/// Работает с Drift моделями (WordStatistic), использует DAO для доступа к БД +@lazySingleton +class WordStatisticsRepository { + final AppDatabase _db; + + WordStatisticsRepository(this._db); + + /// Получить статистику для пользователя и карточки + Future getByUserAndCard(String userId, String cardId) async { + return await _db.wordStatisticsDao.getByUserAndCard(userId, cardId); + } + + /// Создать новую запись статистики + Future create({ + required String userId, + required String cardId, + required int correctAnswers, + required int incorrectAnswers, + }) async { + return await _db.wordStatisticsDao.create( + userId: userId, + cardId: cardId, + correctAnswers: correctAnswers, + incorrectAnswers: incorrectAnswers, + ); + } + + /// Обновить статистику + Future updateStatistics({ + required String id, + required int correctAnswers, + required int incorrectAnswers, + required DateTime lastReviewed, + }) async { + await _db.wordStatisticsDao.updateStatistics( + id: id, + correctAnswers: correctAnswers, + incorrectAnswers: incorrectAnswers, + lastReviewed: lastReviewed, + ); + } + + /// Получить статистику по всем карточкам пака для пользователя + Future> getPackStatistics( + String userId, + String packId, + ) async { + return await _db.wordStatisticsDao.getPackStatistics(userId, packId); + } + + /// Получить все статистики пользователя + Future> getUserStatistics(String userId) async { + return await _db.wordStatisticsDao.getUserStatistics(userId); + } +} diff --git a/mnemo_cards_backend/lib/repository/сonverters/card_pack_converter.dart b/mnemo_cards_backend/lib/repository/сonverters/card_pack_converter.dart new file mode 100644 index 0000000..16deece --- /dev/null +++ b/mnemo_cards_backend/lib/repository/сonverters/card_pack_converter.dart @@ -0,0 +1,29 @@ +import 'package:injectable/injectable.dart'; +import 'package:mnemo_cards_backend/database/database.dart'; +import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; + +/// Конвертер для преобразования CardPack (Drift) в CardPackModel +@lazySingleton +class CardPackConverter { + /// Конвертировать CardPack в CardPackModel + Future toModel(CardPack pack) async { + return CardPackModel( + id: pack.id, + title: pack.title, + subtitle: pack.subtitle, + size: pack.size, + color: pack.color, + version: pack.version, + cover: pack.cover, + description: pack.description, + googlePlayId: pack.googlePlayId, + rustoreId: pack.rustoreId, + appStoreId: pack.appStoreId, + price: pack.price, + currency: pack.currency, + enabled: pack.enabled, + order: pack.order, + cardsOrder: pack.cardsOrder, + ); + } +} diff --git a/mnemo_cards_backend/lib/repository/сonverters/export.dart b/mnemo_cards_backend/lib/repository/сonverters/export.dart new file mode 100644 index 0000000..9bc6d08 --- /dev/null +++ b/mnemo_cards_backend/lib/repository/сonverters/export.dart @@ -0,0 +1,4 @@ +export 'card_pack_converter.dart'; +export 'game_card_converter.dart'; +export 'subscription_converter.dart'; +export 'user_converter.dart'; diff --git a/mnemo_cards_backend/lib/repository/сonverters/game_card_converter.dart b/mnemo_cards_backend/lib/repository/сonverters/game_card_converter.dart new file mode 100644 index 0000000..8385ee7 --- /dev/null +++ b/mnemo_cards_backend/lib/repository/сonverters/game_card_converter.dart @@ -0,0 +1,22 @@ +import 'package:injectable/injectable.dart'; +import 'package:mnemo_cards_backend/database/database.dart'; +import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; + +/// Конвертер для преобразования GameCard (Drift) в GameCardModel +@lazySingleton +class GameCardConverter { + /// Конвертировать GameCard в GameCardModel + GameCardModel toModel(GameCard card) { + return GameCardModel( + id: card.id, + image: card.image, + mnemo: card.mnemo ?? '', + original: card.original, + translation: card.translation, + transcription: card.transcription, + transcriptionMnemo: card.transcriptionMnemo, + imageBack: card.imageBack, + back: card.back, + ); + } +} diff --git a/mnemo_cards_backend/lib/repository/сonverters/subscription_converter.dart b/mnemo_cards_backend/lib/repository/сonverters/subscription_converter.dart new file mode 100644 index 0000000..1c44101 --- /dev/null +++ b/mnemo_cards_backend/lib/repository/сonverters/subscription_converter.dart @@ -0,0 +1,45 @@ +import 'package:injectable/injectable.dart'; +import 'package:mnemo_cards_backend/database/database.dart'; +import 'package:mnemo_cards_common/mnemo_cards_common.dart'; +import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; + +/// Конвертер для преобразования UserSubscription (Drift) в UserSubscriptionModel +@lazySingleton +class SubscriptionConverter { + /// Конвертировать UserSubscription в UserSubscriptionModel + UserSubscriptionModel toModel(UserSubscription subscription) { + return UserSubscriptionModel( + id: subscription.id, + start: subscription.start.toDateTime(), + finish: subscription.finish.toDateTime(), + features: subscription.features + .map( + (f) => SubscriptionFeatureEnum.values.firstWhere( + (e) => e.name == f, + orElse: () => SubscriptionFeatureEnum.unknown, + ), + ) + .toList(), + ); + } + + /// Конвертировать SubscriptionPlan в SubscriptionPlanModel + SubscriptionPlanModel planToModel(SubscriptionPlan plan) { + final uiMap = plan.ui; + final ui = uiMap != null ? SubscriptionPlanUI.fromJson(uiMap) : null; + + return SubscriptionPlanModel( + id: plan.id, + ui: ui, + price: plan.price, + currency: plan.currency, + durationDays: plan.durationDays, + features: [], + paymentSystem: PaymentSystem.values.firstWhere( + (ps) => ps.name == plan.paymentSystem, + orElse: () => PaymentSystem.unknown, + ), + paymentId: plan.paymentId, + ); + } +} diff --git a/mnemo_cards_backend/lib/repository/сonverters/user_converter.dart b/mnemo_cards_backend/lib/repository/сonverters/user_converter.dart new file mode 100644 index 0000000..63ea437 --- /dev/null +++ b/mnemo_cards_backend/lib/repository/сonverters/user_converter.dart @@ -0,0 +1,73 @@ +import 'package:drift/drift.dart' as drift; +import 'package:injectable/injectable.dart'; +import 'package:mnemo_cards_backend/database/daos/user_dao.dart'; +import 'package:mnemo_cards_backend/database/database.dart'; +import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; + +import 'card_pack_converter.dart'; + +/// Конвертер для преобразования User (Drift) в UserModel +@lazySingleton +class UserConverter { + final CardPackConverter _cardPackConverter; + + UserConverter(this._cardPackConverter); + + /// Конвертировать User в UserModel + /// [packs] - опциональный список связанных паков пользователя + Future toModel(User user, [List? packs]) async { + final packModels = packs != null + ? await Future.wait(packs.map((p) => _cardPackConverter.toModel(p))) + : []; + + return UserModel( + id: user.id, + name: user.name, + email: user.email, + telegram: user.telegram, + admin: user.admin, + purchases: user.purchases, + userSettings: user.userSettings, + packs: packModels, + ); + } + + /// Конвертировать UserModel в UsersCompanion + UsersCompanion toCompanion(UserModel userModel) { + return UsersCompanion( + id: userModel.id != null + ? drift.Value(userModel.id!) + : const drift.Value.absent(), + externalUserId: const drift.Value.absent(), + name: drift.Value(userModel.name), + email: drift.Value(userModel.email), + telegram: drift.Value(userModel.telegram), + admin: drift.Value(userModel.admin), + purchases: drift.Value(userModel.purchases), + userSettings: drift.Value(userModel.userSettings), + ); + } +} + +/// Конвертер для преобразования UserWithData в UserDataModel +@lazySingleton +class UserWithDataConverter { + /// Конвертировать UserWithData в UserDataModel + Future toUserDataModel(UserWithData userWithData) async { + if (userWithData.userData == null) { + return UserDataModel(); + } + + return UserDataModel( + id: userWithData.userData!.id, + lastTestSessionToken: userWithData.userData!.lastTestSessionToken, + lastTimeOnline: userWithData.userData!.lastTimeOnline?.toDateTime(), + tags: userWithData.userData!.tags, + totalStudyTimeMinutes: userWithData.userData!.totalStudyTimeMinutes, + currentStreak: userWithData.userData!.currentStreak, + longestStreak: userWithData.userData!.longestStreak, + // words, packProgress, studyDates, categoryMinutes, achievements + // are loaded separately from database when needed + ); + } +}