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
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:
parent
aec56c2fd8
commit
5a8c55c1ef
6 changed files with 677 additions and 0 deletions
|
|
@ -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_auth_api_v2.dart' as _i483;
|
||||||
import '../v2/admin_cards_api_v2.dart' as _i922;
|
import '../v2/admin_cards_api_v2.dart' as _i922;
|
||||||
import '../v2/admin_packs_api_v2.dart' as _i1015;
|
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/auth_api_v2.dart' as _i52;
|
||||||
import '../v2/discounts_api_v2.dart' as _i858;
|
import '../v2/discounts_api_v2.dart' as _i858;
|
||||||
import '../v2/jwt_service.dart' as _i108;
|
import '../v2/jwt_service.dart' as _i108;
|
||||||
|
|
@ -112,6 +114,12 @@ extension GetItInjectableX on _i174.GetIt {
|
||||||
gh.factory<_i1015.AdminPacksApiV2>(
|
gh.factory<_i1015.AdminPacksApiV2>(
|
||||||
() => _i1015.AdminPacksApiV2(gh<_i1072.AppDatabase>()),
|
() => _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>(
|
gh.lazySingleton<_i964.SubscriptionsApiV2>(
|
||||||
() => _i964.SubscriptionsApiV2(gh<_i377.SubscriptionManager>()),
|
() => _i964.SubscriptionsApiV2(gh<_i377.SubscriptionManager>()),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import 'v2/admin_analytics_api_v2.dart';
|
||||||
import 'v2/admin_auth_api_v2.dart';
|
import 'v2/admin_auth_api_v2.dart';
|
||||||
import 'v2/admin_cards_api_v2.dart';
|
import 'v2/admin_cards_api_v2.dart';
|
||||||
import 'v2/admin_packs_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/auth_api_v2.dart';
|
||||||
import 'v2/discounts_api_v2.dart';
|
import 'v2/discounts_api_v2.dart';
|
||||||
import 'v2/packs_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<AdminAnalyticsApiV2>().router);
|
||||||
v2Router.mount('/', getIt.get<AdminCardsApiV2>().router);
|
v2Router.mount('/', getIt.get<AdminCardsApiV2>().router);
|
||||||
v2Router.mount('/', getIt.get<AdminPacksApiV2>().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<PacksApiV2>().router);
|
||||||
v2Router.mount('/', getIt.get<TestsApiV2>().router);
|
v2Router.mount('/', getIt.get<TestsApiV2>().router);
|
||||||
v2Router.mount('/', getIt.get<PromocodesApiV2>().router);
|
v2Router.mount('/', getIt.get<PromocodesApiV2>().router);
|
||||||
|
|
|
||||||
440
mnemo_cards_backend/lib/api/v2/admin_tests_api_v2.dart
Normal file
440
mnemo_cards_backend/lib/api/v2/admin_tests_api_v2.dart
Normal 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);
|
||||||
|
}
|
||||||
16
mnemo_cards_backend/lib/api/v2/admin_tests_api_v2.g.dart
Normal file
16
mnemo_cards_backend/lib/api/v2/admin_tests_api_v2.g.dart
Normal 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;
|
||||||
|
}
|
||||||
195
mnemo_cards_backend/lib/api/v2/admin_users_api_v2.dart
Normal file
195
mnemo_cards_backend/lib/api/v2/admin_users_api_v2.dart
Normal 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);
|
||||||
|
}
|
||||||
14
mnemo_cards_backend/lib/api/v2/admin_users_api_v2.g.dart
Normal file
14
mnemo_cards_backend/lib/api/v2/admin_users_api_v2.g.dart
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue