2025-11-10 23:55:41 +00:00
|
|
|
import 'dart:convert';
|
|
|
|
|
import 'dart:io';
|
|
|
|
|
import 'dart:math';
|
|
|
|
|
|
|
|
|
|
import 'package:args/args.dart';
|
|
|
|
|
import 'package:isar/isar.dart';
|
|
|
|
|
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
|
|
|
|
|
|
|
|
|
class DBManager {
|
|
|
|
|
late Isar isar;
|
|
|
|
|
final Function(String message) notify;
|
|
|
|
|
// Backend API URL for generating auth codes
|
|
|
|
|
final String backendUrl;
|
|
|
|
|
|
2025-11-16 16:31:22 +00:00
|
|
|
DBManager(this.notify, {this.backendUrl = 'http://localhost:8443'});
|
2025-11-10 23:55:41 +00:00
|
|
|
|
|
|
|
|
Future<void> init(ArgResults results) async {
|
|
|
|
|
final dir = results.option('isar') ?? '../mnemo_cards_backend/isar/';
|
|
|
|
|
final name = 'db';
|
|
|
|
|
|
|
|
|
|
// Try to get existing instance first
|
|
|
|
|
try {
|
|
|
|
|
isar = Isar.getInstance(name) ??
|
|
|
|
|
await _connectWithShareModel(
|
|
|
|
|
dir: dir,
|
|
|
|
|
inspector: results.flag('debug'),
|
|
|
|
|
);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// If instance exists but can't be obtained, try to close and reopen
|
|
|
|
|
try {
|
|
|
|
|
final existing = Isar.getInstance(name);
|
|
|
|
|
if (existing != null) {
|
|
|
|
|
await existing.close();
|
|
|
|
|
}
|
|
|
|
|
} catch (_) {
|
|
|
|
|
// Ignore errors when closing
|
|
|
|
|
}
|
|
|
|
|
isar = await _connectWithShareModel(
|
|
|
|
|
dir: dir,
|
|
|
|
|
inspector: results.flag('debug'),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var totalUsers = await isar.userModels.count();
|
|
|
|
|
// Removed automatic notification on startup
|
|
|
|
|
isar.userModels.watchLazy().listen((_) async {
|
|
|
|
|
final totalUsersUpd = await isar.userModels.count();
|
|
|
|
|
if (totalUsersUpd != totalUsers) {
|
|
|
|
|
notify('Users: $totalUsers -> $totalUsersUpd!');
|
|
|
|
|
totalUsers = totalUsersUpd;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Connect to Isar with ShareRequestModel schema included
|
|
|
|
|
Future<Isar> _connectWithShareModel({
|
|
|
|
|
required String dir,
|
|
|
|
|
required bool inspector,
|
|
|
|
|
}) async {
|
|
|
|
|
await Isar.initializeIsarCore(download: true);
|
|
|
|
|
return Isar.open(
|
|
|
|
|
[
|
|
|
|
|
CardPackModelSchema,
|
|
|
|
|
GameCardModelSchema,
|
|
|
|
|
UserModelSchema,
|
|
|
|
|
UserSubscriptionModelSchema,
|
|
|
|
|
SubscriptionPlanModelSchema,
|
|
|
|
|
TokenModelSchema,
|
|
|
|
|
RefreshTokenModelSchema,
|
|
|
|
|
TelegramAuthCodeModelSchema,
|
|
|
|
|
TestModelSchema,
|
|
|
|
|
TestQuestionModelSchema,
|
|
|
|
|
PaymentModelSchema,
|
|
|
|
|
TaskModelSchema,
|
|
|
|
|
TestStatisticsModelSchema,
|
|
|
|
|
UserDataModelSchema,
|
|
|
|
|
PromoCodesCampaignModelSchema,
|
|
|
|
|
PromoCodeModelSchema,
|
|
|
|
|
DiscountCampaignModelSchema,
|
|
|
|
|
DiscountModelSchema,
|
|
|
|
|
UserTaskModelSchema,
|
|
|
|
|
UserTaskProgressModelSchema,
|
|
|
|
|
UserTaskResultModelSchema,
|
|
|
|
|
ShareRequestModelSchema,
|
|
|
|
|
],
|
|
|
|
|
directory: dir,
|
|
|
|
|
name: 'db',
|
|
|
|
|
inspector: inspector,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<String> info() async {
|
|
|
|
|
final users = await isar.txn(() => isar.userModels.where().findAll());
|
|
|
|
|
for (final user in users) {
|
|
|
|
|
await user.userData.load();
|
|
|
|
|
}
|
|
|
|
|
return 'Users (${users.length}):\n'
|
|
|
|
|
'${users.map((user) => '${user.email ?? '${user.id} ${user.name}'} ${user.userData.value?.lastTimeOnline?.toShortString() ?? ''}').join('\n')}';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<String> userInfo(String userId) async {
|
|
|
|
|
final id = int.tryParse(userId);
|
|
|
|
|
final users = await isar.txn(
|
|
|
|
|
() => isar.userModels
|
|
|
|
|
.filter()
|
|
|
|
|
.optional(id != null, (q) => q.idEqualTo(id))
|
|
|
|
|
.or()
|
|
|
|
|
.emailContains(userId)
|
|
|
|
|
.or()
|
|
|
|
|
.nameContains(userId)
|
|
|
|
|
.findAll(),
|
|
|
|
|
);
|
|
|
|
|
if (users.isEmpty) {
|
|
|
|
|
return 'Not found';
|
|
|
|
|
}
|
|
|
|
|
if (users.length > 1) {
|
|
|
|
|
return 'Found multiple:\n${users.map((u) => u.basicInfo).join('\n')}';
|
|
|
|
|
}
|
|
|
|
|
final user = users.first;
|
|
|
|
|
await user.packs.load();
|
|
|
|
|
await user.subscriptionModel.load();
|
|
|
|
|
final sub = user.subscriptionModel.value;
|
|
|
|
|
return '''
|
|
|
|
|
${user.basicInfo}
|
|
|
|
|
Tags: ${user.userData.value?.tags.join(',') ?? ''}
|
|
|
|
|
Packs (${user.packs.length}):
|
|
|
|
|
${user.packs.map((p) => '${p.id} ${p.title}').join('\n')}
|
|
|
|
|
Purchases: ${user.purchases.length}
|
|
|
|
|
Subscription ${sub == null ? '' : '(${sub.start} - ${sub.finish})'}:
|
|
|
|
|
${sub?.features.join(' ') ?? ''}
|
|
|
|
|
'''
|
|
|
|
|
.trim();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<List<String>> words() async {
|
|
|
|
|
final packs = await isar.txn(() => isar.cardPackModels.where().findAll());
|
|
|
|
|
final words = packs
|
|
|
|
|
.expand((p) => p.cards.map((c) => c.original))
|
|
|
|
|
.whereType<String>()
|
|
|
|
|
.toList();
|
|
|
|
|
return words;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Generate a Telegram auth code for a user
|
|
|
|
|
/// Calls backend API to generate the code
|
|
|
|
|
Future<String?> generateAuthCode({
|
|
|
|
|
required String telegramUserId,
|
|
|
|
|
String? telegramUsername,
|
|
|
|
|
String? firstName,
|
|
|
|
|
String? lastName,
|
|
|
|
|
}) async {
|
|
|
|
|
try {
|
|
|
|
|
final client = HttpClient();
|
|
|
|
|
try {
|
|
|
|
|
final uri = Uri.parse('$backendUrl/api/v2/auth/telegram/generate-code');
|
|
|
|
|
final request = await client.postUrl(uri);
|
|
|
|
|
request.headers.contentType = ContentType.json;
|
|
|
|
|
request.write(jsonEncode({
|
|
|
|
|
'telegramUserId': telegramUserId,
|
|
|
|
|
if (telegramUsername != null) 'telegramUsername': telegramUsername,
|
|
|
|
|
if (firstName != null) 'firstName': firstName,
|
|
|
|
|
if (lastName != null) 'lastName': lastName,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
final response = await request.close();
|
|
|
|
|
final responseBody = await response.transform(utf8.decoder).join();
|
|
|
|
|
|
|
|
|
|
if (response.statusCode != 200) {
|
|
|
|
|
print('Failed to generate auth code: '
|
|
|
|
|
'${response.statusCode} - $responseBody');
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final json = jsonDecode(responseBody) as Map<String, dynamic>;
|
|
|
|
|
return json['code'] as String?;
|
|
|
|
|
} finally {
|
|
|
|
|
client.close();
|
|
|
|
|
}
|
|
|
|
|
} catch (e, s) {
|
|
|
|
|
print('Error generating Telegram auth code: $e\n$s');
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Claim a web-generated Telegram auth code for a user.
|
|
|
|
|
/// Returns true when the backend confirmed the claim.
|
|
|
|
|
Future<bool> claimWebAuthCode({
|
|
|
|
|
required String code,
|
|
|
|
|
required String telegramUserId,
|
|
|
|
|
String? telegramUsername,
|
|
|
|
|
String? firstName,
|
|
|
|
|
String? lastName,
|
|
|
|
|
}) async {
|
|
|
|
|
try {
|
|
|
|
|
final client = HttpClient();
|
|
|
|
|
try {
|
|
|
|
|
final uri = Uri.parse('$backendUrl/api/v2/auth/telegram/claim-code');
|
|
|
|
|
final request = await client.postUrl(uri);
|
|
|
|
|
request.headers.contentType = ContentType.json;
|
|
|
|
|
request.write(jsonEncode({
|
|
|
|
|
'code': code,
|
|
|
|
|
'telegramUserId': telegramUserId,
|
|
|
|
|
if (telegramUsername != null) 'telegramUsername': telegramUsername,
|
|
|
|
|
if (firstName != null) 'firstName': firstName,
|
|
|
|
|
if (lastName != null) 'lastName': lastName,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
final response = await request.close();
|
|
|
|
|
final responseBody = await response.transform(utf8.decoder).join();
|
|
|
|
|
|
|
|
|
|
if (response.statusCode != 200) {
|
|
|
|
|
print('Failed to claim auth code: '
|
|
|
|
|
'${response.statusCode} - $responseBody');
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
} finally {
|
|
|
|
|
client.close();
|
|
|
|
|
}
|
|
|
|
|
} catch (e, s) {
|
|
|
|
|
print('Error claiming Telegram auth code: $e\n$s');
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Check if user can share today (rate limiting)
|
|
|
|
|
/// Returns true if user has not exceeded daily limit
|
|
|
|
|
Future<bool> canShareToday(String telegramUserId, int dailyLimit) async {
|
|
|
|
|
try {
|
|
|
|
|
final today = DateTime.now();
|
|
|
|
|
final todayStart = DateTime(today.year, today.month, today.day);
|
|
|
|
|
final todayEnd = DateTime(today.year, today.month, today.day + 1); // Start of next day
|
|
|
|
|
|
|
|
|
|
final sharesCount = await isar.shareRequestModels
|
|
|
|
|
.filter()
|
|
|
|
|
.telegramUserIdEqualTo(telegramUserId)
|
|
|
|
|
.requestedAtBetween(todayStart, todayEnd)
|
|
|
|
|
.count();
|
|
|
|
|
|
|
|
|
|
print('[SHARE_LIMIT] User $telegramUserId: $sharesCount shares today (limit: $dailyLimit)');
|
|
|
|
|
return sharesCount < dailyLimit;
|
|
|
|
|
} catch (e, s) {
|
|
|
|
|
print('Error checking share limit: $e\n$s');
|
|
|
|
|
return true; // Allow on error to prevent blocking users
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Record a share request for a user
|
|
|
|
|
Future<void> recordShareRequest({
|
|
|
|
|
required String telegramUserId,
|
|
|
|
|
String? telegramUsername,
|
|
|
|
|
int? sharedCardId,
|
|
|
|
|
}) async {
|
|
|
|
|
try {
|
|
|
|
|
final request = ShareRequestModel(
|
|
|
|
|
telegramUserId: telegramUserId,
|
|
|
|
|
telegramUsername: telegramUsername,
|
|
|
|
|
requestedAt: DateTime.now(),
|
|
|
|
|
sharedCardId: sharedCardId,
|
|
|
|
|
);
|
|
|
|
|
await isar.shareRequestModels.put(request);
|
|
|
|
|
print('[SHARE_RECORDED] User $telegramUserId shared card $sharedCardId at ${request.requestedAt}');
|
|
|
|
|
} catch (e, s) {
|
|
|
|
|
print('Error recording share request: $e\n$s');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Get a random card from the database
|
|
|
|
|
/// Returns null if no cards are found
|
|
|
|
|
Future<GameCardModel?> getRandomCard() async {
|
|
|
|
|
try {
|
|
|
|
|
final cards =
|
|
|
|
|
await isar.gameCardModels.where().findAll();
|
|
|
|
|
if (cards.isEmpty) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
final random = Random();
|
|
|
|
|
return cards[random.nextInt(cards.length)];
|
|
|
|
|
} catch (e, s) {
|
|
|
|
|
print('Error getting random card: $e\n$s');
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> dispose() async {
|
|
|
|
|
await isar.close();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
extension on UserModel {
|
|
|
|
|
String get basicInfo => '$id ${email ?? ''} $name';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
extension on DateTime {
|
|
|
|
|
String toShortString() => '$day.$month';
|
|
|
|
|
}
|