back
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions

This commit is contained in:
Dmitry 2025-12-14 21:25:31 +03:00
parent d122a73c1b
commit 3ead5ed139
3 changed files with 405 additions and 8 deletions

View file

@ -6,23 +6,17 @@ import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_router/shelf_router.dart';
// import 'v2/ads_api_v2.dart'; // disabled
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_users_api_v2.dart'; // disabled
import 'v2/auth_api_v2.dart';
import 'v2/discounts_api_v2.dart';
// import 'v2/games_api_v2.dart'; // disabled
import 'v2/packs_api_v2.dart';
// import 'v2/purchases_api_v2.dart'; // disabled
import 'v2/promocodes_api_v2.dart';
import 'v2/subscriptions_api_v2.dart';
import 'v2/tasks_api_v2.dart';
import 'v2/tests_api_v2.dart';
// import 'v2/users_api_v2.dart'; // disabled
// import 'v2/telegram_bot_api_v2.dart'; // disabled
import 'v2/authorize_v2.dart';
import 'v2/telegram_bot_auth_middleware.dart';
import 'v2/jwt_service.dart';
@ -58,8 +52,6 @@ class MnemoShelf {
v2Router.mount('/', getIt.get<AdminPacksApiV2>().router);
v2Router.mount('/', getIt.get<PacksApiV2>().router);
v2Router.mount('/', getIt.get<TestsApiV2>().router);
// v2Router.mount('/', getIt.get<GamesApiV2>().router); // disabled
// v2Router.mount('/', getIt.get<PurchasesApiV2>().router); // disabled
v2Router.mount('/', getIt.get<PromocodesApiV2>().router);
v2Router.mount('/', getIt.get<SubscriptionsApiV2>().router);
v2Router.mount('/', getIt.get<DiscountsApiV2>().router);

View file

@ -0,0 +1,389 @@
import 'dart:convert';
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/packs/card_pack_drift_extension.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
part 'admin_packs_api_v2.g.dart';
/// Admin endpoints for managing card packs in API v2.
@injectable
class AdminPacksApiV2 {
final AppDatabase _db;
AdminPacksApiV2(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/packs
/// Get all packs with pagination and search
@Route.get('/admin/packs')
Future<Response> getPacks(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'] ?? '';
final showDisabled = queryParams['showDisabled'] == 'true';
// Validate pagination
if (page < 1) {
return _json(
{'error': 'Page must be greater than 0'},
statusCode: 400,
);
}
if (limit < 1 || limit > 100) {
return _json(
{'error': 'Limit must be between 1 and 100'},
statusCode: 400,
);
}
// Get all packs
final allPacks = await _db.packDao.getAllPacks(
enabledOnly: !showDisabled,
orderByField: 'order',
);
// Apply search filter if provided
List<CardPack> filteredPacks = allPacks;
if (search.isNotEmpty) {
final searchTerm = search.toLowerCase();
filteredPacks = allPacks.where((pack) {
return pack.title.toLowerCase().contains(searchTerm) ||
pack.subtitle.toLowerCase().contains(searchTerm) ||
pack.id.toLowerCase().contains(searchTerm);
}).toList();
}
// Convert to preview DTOs
final previewDtos = await Future.wait(
filteredPacks.map((pack) async {
final dto = await pack.toPreviewDto(null);
return {
'id': dto.id,
'title': dto.title,
'subtitle': dto.subtitle,
'color': dto.color,
'cover': dto.imageBase64,
'cards': dto.cards,
'enabled': pack.enabled,
'order': pack.order,
};
}),
);
// Calculate pagination
final total = previewDtos.length;
final totalPages = (total / limit).ceil();
final offset = (page - 1) * limit;
final paginatedPacks = previewDtos.skip(offset).take(limit).toList();
return _json({
'items': paginatedPacks,
'total': total,
'page': page,
'limit': limit,
'totalPages': totalPages,
});
} catch (e, s) {
print('Error in getPacks: $e\n$s');
return _json(
{
'error': 'Internal server error',
'message': 'Failed to fetch packs',
},
statusCode: 500,
);
}
}
/// GET /api/v2/admin/packs/{packId}
/// Get pack details by ID for editing
@Route.get('/admin/packs/<packId>')
Future<Response> getPack(Request request, String packId) async {
try {
final auth = await _ensureAdmin(request);
if (auth.statusCode != 200) {
return auth;
}
final pack = await _db.packDao.getPackById(packId);
if (pack == null) {
return _json(
{'error': 'Pack not found'},
statusCode: 404,
);
}
final cards = await _db.packDao.getPackCards(packId);
// Get cards order - cards are already sorted by order from getPackCards
final cardsOrder = cards.map((c) => c.id).toList();
// Get preview cards
final previewCards = await _db.packDao.getPreviewCards(packId);
final previewCardIds = previewCards.map((c) => c.id).toList();
// Create EditCardPackDto
final editDto = EditCardPackDto(
id: pack.id,
title: pack.title,
subtitle: pack.subtitle,
color: pack.color,
cover: pack.cover,
size: pack.size,
googlePlayId: pack.googlePlayId,
rustoreId: pack.rustoreId,
appStoreId: pack.appStoreId,
price: pack.price,
description: pack.description,
enabled: pack.enabled,
version: pack.version,
order: pack.order,
addCardIds: cards.map((c) => c.id).toList(),
addTestIds: null,
removeCardIds: null,
removeTestIds: null,
cardsOrder: cardsOrder,
previewCards: previewCardIds,
);
return _json(editDto.toJson());
} catch (e, s) {
print('Error in getPack: $e\n$s');
return _json(
{
'error': 'Internal server error',
'message': 'Failed to fetch pack',
},
statusCode: 500,
);
}
}
/// POST /api/v2/admin/packs
/// Create or update pack
@Route.post('/admin/packs')
Future<Response> upsertPack(Request request) async {
try {
final auth = await _ensureAdmin(request);
if (auth.statusCode != 200) {
return auth;
}
final body = await request.readAsString();
final data = json.decode(body) as Map<String, dynamic>;
final editDto = EditCardPackDto.fromJson(data);
String packId;
if (editDto.id != null) {
// Update existing pack
packId = editDto.id!;
final existing = await _db.packDao.getPackById(packId);
if (existing == null) {
return _json(
{'error': 'Pack not found'},
statusCode: 404,
);
}
await _db.packDao.updatePackPartial(
CardPacksCompanion(
id: drift.Value(packId),
title: editDto.title != null ? drift.Value(editDto.title!) : const drift.Value.absent(),
subtitle: editDto.subtitle != null ? drift.Value(editDto.subtitle!) : const drift.Value.absent(),
color: editDto.color != null ? drift.Value(editDto.color) : const drift.Value.absent(),
cover: editDto.cover != null ? drift.Value(editDto.cover) : const drift.Value.absent(),
size: editDto.size != null ? drift.Value(editDto.size!) : const drift.Value.absent(),
googlePlayId: editDto.googlePlayId != null ? drift.Value(editDto.googlePlayId) : const drift.Value.absent(),
rustoreId: editDto.rustoreId != null ? drift.Value(editDto.rustoreId) : const drift.Value.absent(),
appStoreId: editDto.appStoreId != null ? drift.Value(editDto.appStoreId) : const drift.Value.absent(),
price: editDto.price != null ? drift.Value(editDto.price) : const drift.Value.absent(),
description: editDto.description != null ? drift.Value(editDto.description) : const drift.Value.absent(),
enabled: editDto.enabled != null ? drift.Value(editDto.enabled!) : const drift.Value.absent(),
version: editDto.version != null ? drift.Value(editDto.version) : const drift.Value.absent(),
order: editDto.order != null ? drift.Value(editDto.order!) : const drift.Value.absent(),
updatedAt: drift.Value(PgDateTime(DateTime.now())),
),
);
} else {
// Create new pack
packId = await _db.packDao.createPack(
CardPacksCompanion.insert(
title: editDto.title ?? '',
subtitle: editDto.subtitle ?? '',
color: editDto.color != null ? drift.Value(editDto.color) : const drift.Value.absent(),
cover: editDto.cover != null ? drift.Value(editDto.cover) : const drift.Value.absent(),
size: editDto.size ?? 0,
googlePlayId: editDto.googlePlayId != null ? drift.Value(editDto.googlePlayId) : const drift.Value.absent(),
rustoreId: editDto.rustoreId != null ? drift.Value(editDto.rustoreId) : const drift.Value.absent(),
appStoreId: editDto.appStoreId != null ? drift.Value(editDto.appStoreId) : const drift.Value.absent(),
price: editDto.price != null ? drift.Value(editDto.price) : const drift.Value.absent(),
description: editDto.description != null ? drift.Value(editDto.description) : const drift.Value.absent(),
enabled: drift.Value(editDto.enabled ?? true),
version: editDto.version != null ? drift.Value(editDto.version) : const drift.Value.absent(),
order: drift.Value(editDto.order ?? 0),
),
);
}
// Handle card associations if provided
if (editDto.addCardIds != null && editDto.addCardIds!.isNotEmpty) {
for (final cardId in editDto.addCardIds!) {
await _db.packDao.addCardToPack(
packId: packId,
cardId: cardId,
);
}
}
if (editDto.removeCardIds != null && editDto.removeCardIds!.isNotEmpty) {
for (final cardId in editDto.removeCardIds!) {
await _db.packDao.removeCardFromPack(packId, cardId);
}
}
// Handle cards order if provided
if (editDto.cardsOrder != null && editDto.cardsOrder!.isNotEmpty) {
await _db.packDao.updatePackCardsOrder(
packId,
editDto.cardsOrder!,
);
}
// Handle preview cards
if (editDto.previewCards != null) {
await _db.packDao.setPreviewCards(packId, editDto.previewCards!);
}
final updatedPack = await _db.packDao.getPackById(packId);
if (updatedPack == null) {
return _json(
{'error': 'Failed to retrieve updated pack'},
statusCode: 500,
);
}
final updatedCards = await _db.packDao.getPackCards(packId);
final cardsOrder = updatedCards.map((c) => c.id).toList();
final previewCards = await _db.packDao.getPreviewCards(packId);
final previewCardIds = previewCards.map((c) => c.id).toList();
final updatedDto = EditCardPackDto(
id: updatedPack.id,
title: updatedPack.title,
subtitle: updatedPack.subtitle,
color: updatedPack.color,
cover: updatedPack.cover,
size: updatedPack.size,
googlePlayId: updatedPack.googlePlayId,
rustoreId: updatedPack.rustoreId,
appStoreId: updatedPack.appStoreId,
price: updatedPack.price,
description: updatedPack.description,
enabled: updatedPack.enabled,
version: updatedPack.version,
order: updatedPack.order,
addCardIds: updatedCards.map((c) => c.id).toList(),
addTestIds: null,
removeCardIds: null,
removeTestIds: null,
cardsOrder: cardsOrder,
previewCards: previewCardIds,
);
return _json({
'success': true,
'pack': updatedDto.toJson(),
});
} catch (e, s) {
print('Error in upsertPack: $e\n$s');
return _json(
{
'error': 'Internal server error',
'message': 'Failed to save pack',
},
statusCode: 500,
);
}
}
/// DELETE /api/v2/admin/packs/{packId}
/// Delete pack (soft delete)
@Route.delete('/admin/packs/<packId>')
Future<Response> deletePack(Request request, String packId) async {
try {
final auth = await _ensureAdmin(request);
if (auth.statusCode != 200) {
return auth;
}
final pack = await _db.packDao.getPackById(packId);
if (pack == null) {
return _json(
{'error': 'Pack not found'},
statusCode: 404,
);
}
await _db.packDao.softDeletePack(packId);
return _json({
'success': true,
'message': 'Pack deleted successfully',
});
} catch (e, s) {
print('Error in deletePack: $e\n$s');
return _json(
{
'error': 'Internal server error',
'message': 'Failed to delete pack',
},
statusCode: 500,
);
}
}
Router get router => _$AdminPacksApiV2Router(this);
}

View file

@ -0,0 +1,16 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'admin_packs_api_v2.dart';
// **************************************************************************
// ShelfRouterGenerator
// **************************************************************************
Router _$AdminPacksApiV2Router(AdminPacksApiV2 service) {
final router = Router();
router.add('GET', r'/admin/packs', service.getPacks);
router.add('GET', r'/admin/packs/<packId>', service.getPack);
router.add('POST', r'/admin/packs', service.upsertPack);
router.add('DELETE', r'/admin/packs/<packId>', service.deletePack);
return router;
}