api
Some checks failed
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
Backend CI / test (push) Has been cancelled
Backend CI / build (push) Has been cancelled

This commit is contained in:
Dmitry 2025-12-15 01:27:14 +03:00
parent aec56c2fd8
commit 5a8c55c1ef
6 changed files with 677 additions and 0 deletions

View file

@ -40,6 +40,8 @@ import '../v2/admin_analytics_api_v2.dart' as _i368;
import '../v2/admin_auth_api_v2.dart' as _i483;
import '../v2/admin_cards_api_v2.dart' as _i922;
import '../v2/admin_packs_api_v2.dart' as _i1015;
import '../v2/admin_tests_api_v2.dart' as _i116;
import '../v2/admin_users_api_v2.dart' as _i895;
import '../v2/auth_api_v2.dart' as _i52;
import '../v2/discounts_api_v2.dart' as _i858;
import '../v2/jwt_service.dart' as _i108;
@ -112,6 +114,12 @@ extension GetItInjectableX on _i174.GetIt {
gh.factory<_i1015.AdminPacksApiV2>(
() => _i1015.AdminPacksApiV2(gh<_i1072.AppDatabase>()),
);
gh.factory<_i116.AdminTestsApiV2>(
() => _i116.AdminTestsApiV2(gh<_i1072.AppDatabase>()),
);
gh.factory<_i895.AdminUsersApiV2>(
() => _i895.AdminUsersApiV2(gh<_i1072.AppDatabase>()),
);
gh.lazySingleton<_i964.SubscriptionsApiV2>(
() => _i964.SubscriptionsApiV2(gh<_i377.SubscriptionManager>()),
);

View file

@ -10,6 +10,8 @@ import 'v2/admin_analytics_api_v2.dart';
import 'v2/admin_auth_api_v2.dart';
import 'v2/admin_cards_api_v2.dart';
import 'v2/admin_packs_api_v2.dart';
import 'v2/admin_tests_api_v2.dart';
import 'v2/admin_users_api_v2.dart';
import 'v2/auth_api_v2.dart';
import 'v2/discounts_api_v2.dart';
import 'v2/packs_api_v2.dart';
@ -52,6 +54,8 @@ class MnemoShelf {
v2Router.mount('/', getIt.get<AdminAnalyticsApiV2>().router);
v2Router.mount('/', getIt.get<AdminCardsApiV2>().router);
v2Router.mount('/', getIt.get<AdminPacksApiV2>().router);
v2Router.mount('/', getIt.get<AdminTestsApiV2>().router);
v2Router.mount('/', getIt.get<AdminUsersApiV2>().router);
v2Router.mount('/', getIt.get<PacksApiV2>().router);
v2Router.mount('/', getIt.get<TestsApiV2>().router);
v2Router.mount('/', getIt.get<PromocodesApiV2>().router);

View file

@ -0,0 +1,440 @@
import 'dart:convert';
import 'package:injectable/injectable.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:drift/drift.dart' as drift;
import 'package:drift_postgres/drift_postgres.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
part 'admin_tests_api_v2.g.dart';
/// Admin endpoints for managing tests in API v2.
@injectable
class AdminTestsApiV2 {
final AppDatabase _db;
AdminTestsApiV2(this._db);
Response _json(
Object? data, {
int statusCode = 200,
Map<String, String> headers = const {},
}) {
return Response(
statusCode,
body: data == null ? null : jsonEncode(data),
headers: {
'Content-Type': 'application/json',
...headers,
},
);
}
Future<Response> _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);
}
}
/// GET /api/v2/admin/tests
/// Get all tests with pagination and search
@Route.get('/admin/tests')
Future<Response> getAllTests(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 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',
'details': 'Page numbers start from 1. Please provide a valid page number.',
},
statusCode: 400,
);
}
// Limit validation: if limit is invalid, set to default; if > 100, cap at 100
final validatedLimit = limit < 1 ? 20 : (limit > 100 ? 100 : limit);
// Get all tests
final allTests = await _db.testDao.getAllTests();
// Convert to TestDto format (simplified, without questions for list view)
final testDtos = <Map<String, dynamic>>[];
for (final test in allTests) {
final questions = await _db.testDao.getTestQuestions(test.id);
testDtos.add({
'id': test.id,
'name': test.name,
'color': test.color,
'cover': test.cover,
'version': test.version ?? '1.0',
'time': test.time,
'timeSubtitle': test.timeSubtitle,
'questions': questions.length,
});
}
// Apply search filter if provided
List<Map<String, dynamic>> filteredTests = testDtos;
if (search.isNotEmpty) {
final searchTerm = search.toLowerCase();
filteredTests = testDtos.where((test) {
return (test['name'] as String?)?.toLowerCase().contains(searchTerm) == true ||
(test['id'] as String?)?.toLowerCase().contains(searchTerm) == true;
}).toList();
}
// Calculate pagination
final total = filteredTests.length;
final totalPages = (total / validatedLimit).ceil();
final offset = (page - 1) * validatedLimit;
final paginatedTests = filteredTests.skip(offset).take(validatedLimit).toList();
return _json({
'items': paginatedTests,
'total': total,
'page': page,
'limit': validatedLimit,
'totalPages': totalPages,
});
} catch (e, s) {
print('Error in getAllTests: $e\n$s');
return _json(
{
'error': 'Internal server error',
'message': 'Failed to retrieve tests',
'details': 'An unexpected error occurred while fetching tests. Please try again later or contact support if the problem persists.',
},
statusCode: 500,
);
}
}
/// GET /api/v2/admin/tests/{testId}
/// Get a specific test by ID
@Route.get('/admin/tests/<testId>')
Future<Response> getTest(Request request, String testId) async {
try {
final auth = await _ensureAdmin(request);
if (auth.statusCode != 200) {
return auth;
}
if (testId.isEmpty) {
return _json(
{
'error': 'Invalid test ID',
'message': 'Test ID cannot be empty',
'field': 'testId',
'details': 'Please provide a valid test ID to retrieve test details.',
},
statusCode: 400,
);
}
final test = await _db.testDao.getTestById(testId);
if (test == null) {
return _json(
{
'error': 'Test not found',
'message': 'The requested test does not exist or has been deleted',
'details': 'Test with ID "$testId" was not found. Please verify the test ID and try again.',
},
statusCode: 404,
);
}
// Get questions
final questions = await _db.testDao.getTestQuestions(testId);
final questionsList = questions.map((q) {
final body = json.decode(q.body) as Map<String, dynamic>;
return AbstractTestQuestion.fromJson({
'questionType': q.questionType,
...body,
});
}).toList();
return _json({
'id': test.id,
'name': test.name,
'color': test.color,
'cover': test.cover,
'version': test.version ?? '1.0',
'time': test.time,
'timeSubtitle': test.timeSubtitle,
'questions': questionsList.map((q) => q.toJson()).toList(),
});
} catch (e, s) {
print('Error in getTest: $e\n$s');
return _json(
{
'error': 'Internal server error',
'message': 'Failed to retrieve test',
'details': 'An unexpected error occurred while fetching the test. Please try again later or contact support if the problem persists.',
},
statusCode: 500,
);
}
}
/// POST /api/v2/admin/tests
/// Create or update a test
@Route.post('/admin/tests')
Future<Response> upsertTest(Request request) async {
try {
final auth = await _ensureAdmin(request);
if (auth.statusCode != 200) {
return auth;
}
final bodyString = await request.readAsString();
if (bodyString.isEmpty) {
return _json(
{
'error': 'Invalid request body',
'message': 'Request body is required',
'details': 'Please provide test data in the request body.',
},
statusCode: 400,
);
}
final bodyJson = jsonDecode(bodyString) as Map<String, dynamic>;
final testId = bodyJson['id'] as String?;
final name = bodyJson['name'] as String?;
final color = bodyJson['color'] as String?;
final cover = bodyJson['cover'] as String?;
final version = bodyJson['version'] as String?;
final time = bodyJson['time'] as String?;
final timeSubtitle = bodyJson['timeSubtitle'] as String?;
final questions = bodyJson['questions'] as List<dynamic>?;
if (name == null || name.isEmpty) {
return _json(
{
'error': 'Invalid test data',
'message': 'Test name is required',
'field': 'name',
'details': 'Please provide a valid test name.',
},
statusCode: 400,
);
}
// Create or update test
if (testId != null && testId.isNotEmpty) {
// Update existing test
final existingTest = await _db.testDao.getTestById(testId);
if (existingTest == null) {
return _json(
{
'error': 'Test not found',
'message': 'Test with the provided ID does not exist',
'details': 'Cannot update a test that does not exist. Please create a new test instead.',
},
statusCode: 404,
);
}
// Update test
final testToUpdate = await _db.testDao.getTestById(testId);
if (testToUpdate != null) {
final updatedTest = testToUpdate.copyWith(
name: name,
color: color != null ? drift.Value(color) : const drift.Value.absent(),
cover: cover != null ? drift.Value(cover) : const drift.Value.absent(),
version: version != null ? drift.Value(version) : const drift.Value.absent(),
time: time != null ? drift.Value(time) : const drift.Value.absent(),
timeSubtitle: timeSubtitle != null ? drift.Value(timeSubtitle) : const drift.Value.absent(),
updatedAt: PgDateTime(DateTime.now()),
);
await _db.testDao.updateTest(updatedTest);
}
// Update questions if provided
if (questions != null) {
// Delete existing questions (soft delete)
final existingQuestions = await _db.testDao.getTestQuestions(testId);
for (final q in existingQuestions) {
await _db.testDao.softDeleteTestQuestion(q.id);
}
// Add new questions
for (final q in questions) {
final questionJson = q as Map<String, dynamic>;
final questionType = questionJson['questionType'] as String? ?? 'multiple_choice';
final questionBody = Map<String, dynamic>.from(questionJson);
questionBody.remove('questionType');
await _db.testDao.createTestQuestion(
TestQuestionsCompanion.insert(
testId: testId,
questionType: questionType,
body: jsonEncode(questionBody),
),
);
}
}
final updatedTest = await _db.testDao.getTestById(testId);
return _json({
'success': true,
'test': updatedTest != null ? {
'id': updatedTest.id,
'name': updatedTest.name,
'color': updatedTest.color,
'cover': updatedTest.cover,
'version': updatedTest.version,
'time': updatedTest.time,
'timeSubtitle': updatedTest.timeSubtitle,
} : {
'id': testId,
'name': name,
'color': color,
'cover': cover,
'version': version,
'time': time,
'timeSubtitle': timeSubtitle,
},
});
} else {
// Create new test
final newTest = await _db.testDao.createTest(
TestsCompanion.insert(
name: name,
color: color != null ? drift.Value(color) : const drift.Value.absent(),
cover: cover != null ? drift.Value(cover) : const drift.Value.absent(),
version: version != null ? drift.Value(version) : const drift.Value.absent(),
time: time != null ? drift.Value(time) : const drift.Value.absent(),
timeSubtitle: timeSubtitle != null ? drift.Value(timeSubtitle) : const drift.Value.absent(),
),
);
// Add questions if provided
if (questions != null) {
for (final q in questions) {
final questionJson = q as Map<String, dynamic>;
final questionType = questionJson['questionType'] as String? ?? 'multiple_choice';
final questionBody = Map<String, dynamic>.from(questionJson);
questionBody.remove('questionType');
await _db.testDao.createTestQuestion(
TestQuestionsCompanion.insert(
testId: newTest,
questionType: questionType,
body: jsonEncode(questionBody),
),
);
}
}
final createdTest = await _db.testDao.getTestById(newTest);
return _json({
'success': true,
'test': createdTest != null ? {
'id': createdTest.id,
'name': createdTest.name,
'color': createdTest.color,
'cover': createdTest.cover,
'version': createdTest.version,
'time': createdTest.time,
'timeSubtitle': createdTest.timeSubtitle,
} : {
'id': newTest,
'name': name,
'color': color,
'cover': cover,
'version': version,
'time': time,
'timeSubtitle': timeSubtitle,
},
}, statusCode: 201);
}
} catch (e, s) {
print('Error in upsertTest: $e\n$s');
return _json(
{
'error': 'Internal server error',
'message': 'Failed to save test',
'details': 'An unexpected error occurred while saving the test. Please try again later or contact support if the problem persists.',
},
statusCode: 500,
);
}
}
/// DELETE /api/v2/admin/tests/{testId}
/// Delete a test
@Route.delete('/admin/tests/<testId>')
Future<Response> deleteTest(Request request, String testId) async {
try {
final auth = await _ensureAdmin(request);
if (auth.statusCode != 200) {
return auth;
}
if (testId.isEmpty) {
return _json(
{
'error': 'Invalid test ID',
'message': 'Test ID cannot be empty',
'field': 'testId',
'details': 'Please provide a valid test ID to delete the test.',
},
statusCode: 400,
);
}
final test = await _db.testDao.getTestById(testId);
if (test == null) {
return _json(
{
'error': 'Test not found',
'message': 'The test you are trying to delete does not exist',
'details': 'Test with ID "$testId" was not found. It may have already been deleted.',
},
statusCode: 404,
);
}
// Soft delete test
await _db.testDao.softDeleteTest(testId);
return _json({
'success': true,
'message': 'Test deleted successfully',
});
} catch (e, s) {
print('Error in deleteTest: $e\n$s');
return _json(
{
'error': 'Internal server error',
'message': 'Failed to delete test',
'details': 'An unexpected error occurred while deleting the test. Please try again later or contact support if the problem persists.',
},
statusCode: 500,
);
}
}
Router get router => _$AdminTestsApiV2Router(this);
}

View file

@ -0,0 +1,16 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'admin_tests_api_v2.dart';
// **************************************************************************
// ShelfRouterGenerator
// **************************************************************************
Router _$AdminTestsApiV2Router(AdminTestsApiV2 service) {
final router = Router();
router.add('GET', r'/admin/tests', service.getAllTests);
router.add('GET', r'/admin/tests/<testId>', service.getTest);
router.add('POST', r'/admin/tests', service.upsertTest);
router.add('DELETE', r'/admin/tests/<testId>', service.deleteTest);
return router;
}

View file

@ -0,0 +1,195 @@
import 'dart:convert';
import 'package:injectable/injectable.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/database/database.dart';
import 'package:mnemo_cards_backend/user/user_drift_extension.dart';
import 'package:mnemo_cards_backend/user/user_model.dart' show UserModelExtension;
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
part 'admin_users_api_v2.g.dart';
/// Admin endpoints for managing users in API v2.
@injectable
class AdminUsersApiV2 {
final AppDatabase _db;
AdminUsersApiV2(this._db);
Response _json(
Object? data, {
int statusCode = 200,
Map<String, String> headers = const {},
}) {
return Response(
statusCode,
body: data == null ? null : jsonEncode(data),
headers: {
'Content-Type': 'application/json',
...headers,
},
);
}
Future<Response> _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);
}
}
/// GET /api/v2/admin/users
/// Get all users with pagination and search
@Route.get('/admin/users')
Future<Response> getAllUsers(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 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',
'details': 'Page numbers start from 1. Please provide a valid page number.',
},
statusCode: 400,
);
}
// Limit validation: if limit is invalid, set to default; if > 100, cap at 100
final validatedLimit = limit < 1 ? 20 : (limit > 100 ? 100 : limit);
// Get total count
final total = await _db.userDao.countUsers(includeDeleted: false);
// Calculate offset
final offset = (page - 1) * validatedLimit;
// Get users with pagination
final users = await _db.userDao.getAllUsers(
limit: validatedLimit,
offset: offset,
includeDeleted: false,
);
// Convert to DTOs
final userDtos = <Map<String, dynamic>>[];
for (final user in users) {
final userModel = await user.toUserModel();
final dto = await userModel.toDto();
userDtos.add(dto.toJson());
}
// Apply search filter if provided
List<Map<String, dynamic>> filteredUsers = userDtos;
if (search.isNotEmpty) {
final searchTerm = search.toLowerCase();
filteredUsers = userDtos.where((user) {
final email = user['email'] as String? ?? '';
final name = user['name'] as String? ?? '';
final id = user['id'] as String? ?? '';
return email.toLowerCase().contains(searchTerm) ||
name.toLowerCase().contains(searchTerm) ||
id.toLowerCase().contains(searchTerm);
}).toList();
}
// Recalculate total after search
final filteredTotal = search.isNotEmpty ? filteredUsers.length : total;
final totalPages = (filteredTotal / validatedLimit).ceil();
// Apply pagination to filtered results if search is active
final paginatedUsers = search.isNotEmpty
? filteredUsers.skip(offset).take(validatedLimit).toList()
: filteredUsers;
return _json({
'items': paginatedUsers,
'total': filteredTotal,
'page': page,
'limit': validatedLimit,
'totalPages': totalPages,
});
} catch (e, s) {
print('Error in getAllUsers: $e\n$s');
return _json(
{
'error': 'Internal server error',
'message': 'Failed to retrieve users',
'details': 'An unexpected error occurred while fetching users. Please try again later or contact support if the problem persists.',
},
statusCode: 500,
);
}
}
/// GET /api/v2/admin/users/{userId}
/// Get a specific user by ID
@Route.get('/admin/users/<userId>')
Future<Response> getUser(Request request, String userId) async {
try {
final auth = await _ensureAdmin(request);
if (auth.statusCode != 200) {
return auth;
}
if (userId.isEmpty) {
return _json(
{
'error': 'Invalid user ID',
'message': 'User ID cannot be empty',
'field': 'userId',
'details': 'Please provide a valid user ID to retrieve user details.',
},
statusCode: 400,
);
}
final user = await _db.userDao.getUserById(userId);
if (user == null) {
return _json(
{
'error': 'User not found',
'message': 'The requested user does not exist or has been deleted',
'details': 'User with ID "$userId" was not found. Please verify the user ID and try again.',
},
statusCode: 404,
);
}
final userModel = await user.toUserModel();
final dto = await userModel.toDto();
return _json(dto.toJson());
} catch (e, s) {
print('Error in getUser: $e\n$s');
return _json(
{
'error': 'Internal server error',
'message': 'Failed to retrieve user',
'details': 'An unexpected error occurred while fetching the user. Please try again later or contact support if the problem persists.',
},
statusCode: 500,
);
}
}
Router get router => _$AdminUsersApiV2Router(this);
}

View file

@ -0,0 +1,14 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'admin_users_api_v2.dart';
// **************************************************************************
// ShelfRouterGenerator
// **************************************************************************
Router _$AdminUsersApiV2Router(AdminUsersApiV2 service) {
final router = Router();
router.add('GET', r'/admin/users', service.getAllUsers);
router.add('GET', r'/admin/users/<userId>', service.getUser);
return router;
}