repo
Some checks failed
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Web App CI / test (push) Waiting to run
Web App 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
Mobile App CI / test (push) Has been cancelled
Deploy Telegram Bot / Deploy Telegram Bot (push) Has been cancelled
Mobile App CI / build-android (push) Has been cancelled
Mobile App CI / build-ios (push) Has been cancelled

This commit is contained in:
Dmitry 2026-01-08 19:07:56 +03:00
parent df2f5d5740
commit a6abcfa7e6
44 changed files with 900 additions and 2185 deletions

View file

@ -1,359 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:mnemo_cards_chat/domain/models/chat_message.dart';
void main() {
group('ChatMessage', () {
group('MessageStatus', () {
test('has correct display names', () {
expect(MessageStatus.sending.displayName, 'Отправляется...');
expect(MessageStatus.sent.displayName, 'Отправлено');
expect(MessageStatus.delivered.displayName, 'Доставлено');
expect(MessageStatus.error.displayName, 'Ошибка');
});
test('has correct status checks', () {
expect(MessageStatus.sending.isSending, isTrue);
expect(MessageStatus.sent.isSending, isFalse);
expect(MessageStatus.delivered.isSending, isFalse);
expect(MessageStatus.error.isSending, isFalse);
expect(MessageStatus.error.isError, isTrue);
expect(MessageStatus.sent.isError, isFalse);
expect(MessageStatus.sent.isDelivered, isTrue);
expect(MessageStatus.delivered.isDelivered, isTrue);
expect(MessageStatus.sending.isDelivered, isFalse);
expect(MessageStatus.error.isDelivered, isFalse);
});
});
group('ChatParticipant', () {
test('creates user participant correctly', () {
final participant = ChatParticipant.user(
id: 'user_123',
name: 'John Doe',
avatarUrl: 'https://example.com/avatar.jpg',
);
expect(participant, isA<ChatParticipantUser>());
final user = participant as ChatParticipantUser;
expect(user.id, 'user_123');
expect(user.name, 'John Doe');
expect(user.avatarUrl, 'https://example.com/avatar.jpg');
});
test('creates assistant participant correctly', () {
final participant = ChatParticipant.assistant(
id: 'assistant_123',
name: 'AI Assistant',
avatarUrl: 'https://example.com/bot.jpg',
model: 'gpt-4',
);
expect(participant, isA<ChatParticipantAssistant>());
final assistant = participant as ChatParticipantAssistant;
expect(assistant.id, 'assistant_123');
expect(assistant.name, 'AI Assistant');
expect(assistant.avatarUrl, 'https://example.com/bot.jpg');
expect(assistant.model, 'gpt-4');
});
test('user participant supports optional avatar', () {
final participant = ChatParticipant.user(
id: 'user_123',
name: 'John Doe',
);
final user = participant as ChatParticipantUser;
expect(user.avatarUrl, isNull);
});
test('assistant participant supports optional model', () {
final participant = ChatParticipant.assistant(
id: 'assistant_123',
name: 'AI Assistant',
);
final assistant = participant as ChatParticipantAssistant;
expect(assistant.model, isNull);
});
});
group('TextMessage', () {
test('creates text message correctly', () {
final timestamp = DateTime.now();
final sender = ChatParticipant.user(id: 'user_123', name: 'User');
final message = TextMessage(
id: 'msg_123',
sessionId: 'session_123',
content: 'Hello, world!',
sender: sender,
timestamp: timestamp,
status: MessageStatus.sent,
metadata: {'source': 'test'},
);
expect(message.id, 'msg_123');
expect(message.sessionId, 'session_123');
expect(message.content, 'Hello, world!');
expect(message.sender, sender);
expect(message.timestamp, timestamp);
expect(message.status, MessageStatus.sent);
expect(message.metadata, {'source': 'test'});
});
test('supports optional metadata', () {
final message = TextMessage(
id: 'msg_123',
sessionId: 'session_123',
content: 'Hello',
sender: ChatParticipant.user(id: 'user_123', name: 'User'),
timestamp: DateTime.now(),
);
expect(message.metadata, isNull);
});
test('has default status', () {
final message = TextMessage(
id: 'msg_123',
sessionId: 'session_123',
content: 'Hello',
sender: ChatParticipant.user(id: 'user_123', name: 'User'),
timestamp: DateTime.now(),
);
expect(message.status, MessageStatus.sent);
});
});
group('AudioMessage', () {
test('creates audio message correctly', () {
final timestamp = DateTime.now();
final sender = ChatParticipant.user(id: 'user_123', name: 'User');
const duration = Duration(seconds: 30);
final message = AudioMessage(
id: 'msg_123',
sessionId: 'session_123',
audioUrl: 'https://example.com/audio.mp3',
sender: sender,
timestamp: timestamp,
duration: duration,
fileSize: 1024000,
status: MessageStatus.sent,
transcription: 'Hello from audio',
metadata: {'format': 'mp3'},
);
expect(message.id, 'msg_123');
expect(message.sessionId, 'session_123');
expect(message.audioUrl, 'https://example.com/audio.mp3');
expect(message.sender, sender);
expect(message.timestamp, timestamp);
expect(message.duration, duration);
expect(message.fileSize, 1024000);
expect(message.status, MessageStatus.sent);
expect(message.transcription, 'Hello from audio');
expect(message.metadata, {'format': 'mp3'});
});
test('supports optional transcription and metadata', () {
const duration = Duration(seconds: 10);
final message = AudioMessage(
id: 'msg_123',
sessionId: 'session_123',
audioUrl: 'https://example.com/audio.wav',
sender: ChatParticipant.user(id: 'user_123', name: 'User'),
timestamp: DateTime.now(),
duration: duration,
fileSize: 512000,
);
expect(message.transcription, isNull);
expect(message.metadata, isNull);
});
test('has default status', () {
const duration = Duration(seconds: 5);
final message = AudioMessage(
id: 'msg_123',
sessionId: 'session_123',
audioUrl: 'https://example.com/audio.webm',
sender: ChatParticipant.user(id: 'user_123', name: 'User'),
timestamp: DateTime.now(),
duration: duration,
fileSize: 256000,
);
expect(message.status, MessageStatus.sent);
});
});
group('ChatMessage union', () {
test('creates text message variant', () {
final textMessage = TextMessage(
id: 'msg_123',
sessionId: 'session_123',
content: 'Hello',
sender: ChatParticipant.user(id: 'user_123', name: 'User'),
timestamp: DateTime.now(),
);
final chatMessage = ChatMessage.text(textMessage);
expect(chatMessage, isA<ChatMessageText>());
});
test('creates audio message variant', () {
const duration = Duration(seconds: 10);
final audioMessage = AudioMessage(
id: 'msg_123',
sessionId: 'session_123',
audioUrl: 'https://example.com/audio.mp3',
sender: ChatParticipant.user(id: 'user_123', name: 'User'),
timestamp: DateTime.now(),
duration: duration,
fileSize: 1024000,
);
final chatMessage = ChatMessage.audio(audioMessage);
expect(chatMessage, isA<ChatMessageAudio>());
});
});
group('JSON serialization', () {
test('ChatParticipant serializes and deserializes correctly', () {
final user = ChatParticipant.user(
id: 'user_123',
name: 'John Doe',
avatarUrl: 'https://example.com/avatar.jpg',
);
final json = user.toJson();
final deserialized = ChatParticipant.fromJson(json);
expect(deserialized, equals(user));
});
test('TextMessage serializes and deserializes correctly', () {
final message = TextMessage(
id: 'msg_123',
sessionId: 'session_123',
content: 'Hello, world!',
sender: ChatParticipant.user(id: 'user_123', name: 'User'),
timestamp: DateTime.parse('2024-01-01T12:00:00Z'),
status: MessageStatus.sent,
metadata: {'source': 'test'},
);
final json = message.toJson();
final deserialized = TextMessage.fromJson(json);
expect(deserialized, equals(message));
});
test('AudioMessage serializes and deserializes correctly', () {
final message = AudioMessage(
id: 'msg_123',
sessionId: 'session_123',
audioUrl: 'https://example.com/audio.mp3',
sender: ChatParticipant.user(id: 'user_123', name: 'User'),
timestamp: DateTime.parse('2024-01-01T12:00:00Z'),
duration: const Duration(seconds: 30),
fileSize: 1024000,
status: MessageStatus.sent,
transcription: 'Hello from audio',
);
final json = message.toJson();
final deserialized = AudioMessage.fromJson(json);
expect(deserialized, equals(message));
});
});
group('Equality and hashCode', () {
test('TextMessage equality works correctly', () {
final message1 = TextMessage(
id: 'msg_123',
sessionId: 'session_123',
content: 'Hello',
sender: ChatParticipant.user(id: 'user_123', name: 'User'),
timestamp: DateTime.parse('2024-01-01T12:00:00Z'),
);
final message2 = TextMessage(
id: 'msg_123',
sessionId: 'session_123',
content: 'Hello',
sender: ChatParticipant.user(id: 'user_123', name: 'User'),
timestamp: DateTime.parse('2024-01-01T12:00:00Z'),
);
final message3 = TextMessage(
id: 'msg_456',
sessionId: 'session_123',
content: 'Hello',
sender: ChatParticipant.user(id: 'user_123', name: 'User'),
timestamp: DateTime.parse('2024-01-01T12:00:00Z'),
);
expect(message1, equals(message2));
expect(message1, isNot(equals(message3)));
expect(message1.hashCode, equals(message2.hashCode));
expect(message1.hashCode, isNot(equals(message3.hashCode)));
});
test('AudioMessage equality works correctly', () {
const duration = Duration(seconds: 30);
final message1 = AudioMessage(
id: 'msg_123',
sessionId: 'session_123',
audioUrl: 'https://example.com/audio.mp3',
sender: ChatParticipant.user(id: 'user_123', name: 'User'),
timestamp: DateTime.parse('2024-01-01T12:00:00Z'),
duration: duration,
fileSize: 1024000,
);
final message2 = AudioMessage(
id: 'msg_123',
sessionId: 'session_123',
audioUrl: 'https://example.com/audio.mp3',
sender: ChatParticipant.user(id: 'user_123', name: 'User'),
timestamp: DateTime.parse('2024-01-01T12:00:00Z'),
duration: duration,
fileSize: 1024000,
);
expect(message1, equals(message2));
expect(message1.hashCode, equals(message2.hashCode));
});
test('ChatParticipant equality works correctly', () {
final user1 = ChatParticipant.user(
id: 'user_123',
name: 'John',
avatarUrl: 'https://example.com/avatar.jpg',
);
final user2 = ChatParticipant.user(
id: 'user_123',
name: 'John',
avatarUrl: 'https://example.com/avatar.jpg',
);
final user3 = ChatParticipant.user(
id: 'user_456',
name: 'John',
avatarUrl: 'https://example.com/avatar.jpg',
);
expect(user1, equals(user2));
expect(user1, isNot(equals(user3)));
expect(user1.hashCode, equals(user2.hashCode));
});
});
});
}

View file

@ -1,255 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:mnemo_cards_chat/domain/models/chat_session.dart';
void main() {
group('ChatSession', () {
group('ChatSessionStatus', () {
test('has correct display names', () {
expect(ChatSessionStatus.active.displayName, 'Активный');
expect(ChatSessionStatus.archived.displayName, 'Архивирован');
expect(ChatSessionStatus.deleted.displayName, 'Удален');
});
test('has correct status checks', () {
expect(ChatSessionStatus.active.isActive, isTrue);
expect(ChatSessionStatus.archived.isActive, isFalse);
expect(ChatSessionStatus.deleted.isActive, isFalse);
expect(ChatSessionStatus.archived.isArchived, isTrue);
expect(ChatSessionStatus.active.isArchived, isFalse);
expect(ChatSessionStatus.deleted.isArchived, isFalse);
expect(ChatSessionStatus.deleted.isDeleted, isTrue);
expect(ChatSessionStatus.active.isDeleted, isFalse);
expect(ChatSessionStatus.archived.isDeleted, isFalse);
});
});
group('ChatSession', () {
test('creates chat session correctly', () {
final createdAt = DateTime.parse('2024-01-01T10:00:00Z');
final updatedAt = DateTime.parse('2024-01-01T11:00:00Z');
final session = ChatSession(
id: 'session_123',
userId: 'user_456',
title: 'Test Chat Session',
createdAt: createdAt,
updatedAt: updatedAt,
status: ChatSessionStatus.active,
description: 'A test chat session',
tags: ['test', 'ai'],
messageCount: 42,
lastMessagePreview: 'Hello, how are you?',
lastMessageAt: DateTime.parse('2024-01-01T11:30:00Z'),
);
expect(session.id, 'session_123');
expect(session.userId, 'user_456');
expect(session.title, 'Test Chat Session');
expect(session.createdAt, createdAt);
expect(session.updatedAt, updatedAt);
expect(session.status, ChatSessionStatus.active);
expect(session.description, 'A test chat session');
expect(session.tags, ['test', 'ai']);
expect(session.messageCount, 42);
expect(session.lastMessagePreview, 'Hello, how are you?');
expect(session.lastMessageAt, DateTime.parse('2024-01-01T11:30:00Z'));
});
test('supports optional fields', () {
final session = ChatSession(
id: 'session_123',
userId: 'user_456',
title: 'Minimal Session',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
expect(session.description, isNull);
expect(session.tags, isEmpty);
expect(session.messageCount, 0);
expect(session.lastMessagePreview, isNull);
expect(session.lastMessageAt, isNull);
expect(session.status, ChatSessionStatus.active);
});
test('has default values', () {
final session = ChatSession(
id: 'session_123',
userId: 'user_456',
title: 'Test',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
expect(session.status, ChatSessionStatus.active);
expect(session.tags, isEmpty);
expect(session.messageCount, 0);
});
});
group('CreateChatSessionRequest', () {
test('creates request correctly', () {
final request = CreateChatSessionRequest(
title: 'New Chat Session',
description: 'A new chat',
tags: ['ai', 'assistant'],
);
expect(request.title, 'New Chat Session');
expect(request.description, 'A new chat');
expect(request.tags, ['ai', 'assistant']);
});
test('supports optional fields', () {
final request = CreateChatSessionRequest(title: 'Simple Chat');
expect(request.title, 'Simple Chat');
expect(request.description, isNull);
expect(request.tags, isEmpty);
});
});
group('UpdateChatSessionRequest', () {
test('creates update request correctly', () {
final request = UpdateChatSessionRequest(
title: 'Updated Title',
description: 'Updated description',
tags: ['updated'],
status: ChatSessionStatus.archived,
);
expect(request.title, 'Updated Title');
expect(request.description, 'Updated description');
expect(request.tags, ['updated']);
expect(request.status, ChatSessionStatus.archived);
});
test('supports partial updates', () {
final request = UpdateChatSessionRequest(
title: 'New Title',
// Other fields null for partial update
);
expect(request.title, 'New Title');
expect(request.description, isNull);
expect(request.tags, isNull);
expect(request.status, isNull);
});
});
group('JSON serialization', () {
test('ChatSession serializes and deserializes correctly', () {
final session = ChatSession(
id: 'session_123',
userId: 'user_456',
title: 'Test Session',
createdAt: DateTime.parse('2024-01-01T10:00:00Z'),
updatedAt: DateTime.parse('2024-01-01T11:00:00Z'),
status: ChatSessionStatus.active,
description: 'Test description',
tags: ['test'],
messageCount: 5,
lastMessagePreview: 'Hello',
lastMessageAt: DateTime.parse('2024-01-01T11:30:00Z'),
);
final json = session.toJson();
final deserialized = ChatSession.fromJson(json);
expect(deserialized, equals(session));
});
test('CreateChatSessionRequest serializes and deserializes correctly', () {
final request = CreateChatSessionRequest(
title: 'New Session',
description: 'Description',
tags: ['tag1', 'tag2'],
);
final json = request.toJson();
final deserialized = CreateChatSessionRequest.fromJson(json);
expect(deserialized, equals(request));
});
test('UpdateChatSessionRequest serializes and deserializes correctly', () {
final request = UpdateChatSessionRequest(
title: 'Updated',
status: ChatSessionStatus.archived,
);
final json = request.toJson();
final deserialized = UpdateChatSessionRequest.fromJson(json);
expect(deserialized, equals(request));
});
});
group('Equality and hashCode', () {
test('ChatSession equality works correctly', () {
final session1 = ChatSession(
id: 'session_123',
userId: 'user_456',
title: 'Test',
createdAt: DateTime.parse('2024-01-01T10:00:00Z'),
updatedAt: DateTime.parse('2024-01-01T11:00:00Z'),
status: ChatSessionStatus.active,
description: 'Desc',
tags: ['tag'],
messageCount: 1,
);
final session2 = ChatSession(
id: 'session_123',
userId: 'user_456',
title: 'Test',
createdAt: DateTime.parse('2024-01-01T10:00:00Z'),
updatedAt: DateTime.parse('2024-01-01T11:00:00Z'),
status: ChatSessionStatus.active,
description: 'Desc',
tags: ['tag'],
messageCount: 1,
);
final session3 = ChatSession(
id: 'session_456',
userId: 'user_456',
title: 'Test',
createdAt: DateTime.parse('2024-01-01T10:00:00Z'),
updatedAt: DateTime.parse('2024-01-01T11:00:00Z'),
);
expect(session1, equals(session2));
expect(session1, isNot(equals(session3)));
expect(session1.hashCode, equals(session2.hashCode));
expect(session1.hashCode, isNot(equals(session3.hashCode)));
});
test('CreateChatSessionRequest equality works correctly', () {
final request1 = CreateChatSessionRequest(
title: 'Test',
description: 'Desc',
tags: ['tag'],
);
final request2 = CreateChatSessionRequest(
title: 'Test',
description: 'Desc',
tags: ['tag'],
);
final request3 = CreateChatSessionRequest(
title: 'Different',
description: 'Desc',
tags: ['tag'],
);
expect(request1, equals(request2));
expect(request1, isNot(equals(request3)));
expect(request1.hashCode, equals(request2.hashCode));
});
});
});
}

View file

@ -1,345 +0,0 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:mnemo_cards_chat/mnemo_cards_chat.dart';
// Mock classes
class MockChatRepository extends Mock implements ChatRepository {}
// Fallback values for mocktail
class CreateChatSessionRequestFake extends Fake implements CreateChatSessionRequest {}
class SendTextMessageRequestFake extends Fake implements SendTextMessageRequest {}
void main() {
setUpAll(() {
registerFallbackValue(CreateChatSessionRequestFake());
registerFallbackValue(SendTextMessageRequestFake());
});
group('ChatService', () {
late ChatService chatService;
late MockChatRepository mockChatRepository;
setUp(() {
mockChatRepository = MockChatRepository();
chatService = ChatService(chatRepository: mockChatRepository);
});
test('can be instantiated', () {
expect(chatService, isNotNull);
expect(chatService, isA<ChatService>());
});
test('has proper constructor signature', () {
expect(
() => ChatService(httpRepository: mockChatRepository),
returnsNormally,
);
});
group('createSession', () {
test('creates session successfully', () async {
const title = 'Test Chat';
const sessionId = 'session_123';
final expectedSession = ChatBasicSession(
id: sessionId,
userId: 'user_123',
title: title,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
when(() => mockChatRepository.createChatSession(any()))
.thenAnswer((_) async => expectedSession);
final result = await chatService.createSession(title: title);
expect(result, equals(expectedSession));
verify(() => mockChatRepository.createChatSession(
any(that: isA<CreateChatSessionRequest>()
.having((r) => r.title, 'title', title))
)).called(1);
});
test('creates session with description', () async {
const title = 'Test Chat';
const description = 'Test Description';
when(() => mockChatRepository.createChatSession(any()))
.thenAnswer((_) async => ChatBasicSession(
id: 'session_123',
userId: 'user_123',
title: title,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
));
await chatService.createSession(title: title, description: description);
verify(() => mockChatRepository.createChatSession(
any(that: isA<CreateChatSessionRequest>()
.having((r) => r.title, 'title', title))
)).called(1);
});
});
group('getSession', () {
test('gets session successfully', () async {
const sessionId = 'session_123';
final expectedSession = ChatBasicSession(
id: sessionId,
userId: 'user_123',
title: 'Test Chat',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
when(() => mockChatRepository.getChatSession(sessionId))
.thenAnswer((_) async => expectedSession);
final result = await chatService.getSession(sessionId);
expect(result, equals(expectedSession));
verify(() => mockChatRepository.getChatSession(sessionId)).called(1);
});
});
group('getSessions', () {
test('gets sessions with default parameters', () async {
final sessions = [
ChatBasicSession(
id: 'session_1',
userId: 'user_123',
title: 'Chat 1',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
];
when(() => mockChatRepository.getChatSessions(limit: 20, afterSessionId: null))
.thenAnswer((_) async => sessions);
final result = await chatService.getSessions();
expect(result, equals(sessions));
verify(() => mockChatRepository.getChatSessions(limit: 20, afterSessionId: null)).called(1);
});
test('gets sessions with custom parameters', () async {
const limit = 10;
const afterSessionId = 'session_123';
final sessions = <ChatBasicSession>[];
when(() => mockChatRepository.getChatSessions(limit: limit, afterSessionId: afterSessionId))
.thenAnswer((_) async => sessions);
final result = await chatService.getSessions(
limit: limit,
afterSessionId: afterSessionId,
);
expect(result, equals(sessions));
verify(() => mockChatRepository.getChatSessions(
limit: limit,
afterSessionId: afterSessionId,
)).called(1);
});
});
group('sendTextMessage', () {
test('sends text message successfully', () async {
const sessionId = 'session_123';
const content = 'Hello, world!';
final expectedResponse = ChatMessageResponse(
messageId: 'msg_123',
sessionId: sessionId,
content: 'Assistant response',
senderId: 'assistant',
timestamp: DateTime.now(),
);
when(() => mockChatRepository.sendTextMessage(any()))
.thenAnswer((_) async => expectedResponse);
final result = await chatService.sendTextMessage(sessionId, content);
expect(result, equals(expectedResponse));
verify(() => mockChatRepository.sendTextMessage(
any(that: isA<SendTextMessageRequest>()
.having((r) => r.sessionId, 'sessionId', sessionId)
.having((r) => r.content, 'content', content))
)).called(1);
});
});
group('sendAudioMessage', () {
test('sends audio message successfully', () async {
const sessionId = 'session_123';
final audioData = Uint8List(1024);
const duration = Duration(seconds: 5);
final expectedResponse = ChatMessageResponse(
messageId: 'msg_123',
sessionId: sessionId,
content: 'Audio processed',
senderId: 'assistant',
timestamp: DateTime.now(),
);
when(() => mockChatRepository.sendAudioMessage(
sessionId,
audioData,
duration,
fileName: null,
mimeType: null,
)).thenAnswer((_) async => expectedResponse);
final result = await chatService.sendAudioMessage(
sessionId,
audioData,
duration,
);
expect(result, equals(expectedResponse));
verify(() => mockChatRepository.sendAudioMessage(
sessionId,
audioData,
duration,
fileName: null,
mimeType: null,
)).called(1);
});
test('sends audio message with metadata', () async {
const sessionId = 'session_123';
final audioData = Uint8List(2048);
const duration = Duration(seconds: 10);
const transcription = 'Hello world';
const fileName = 'recording.webm';
const mimeType = 'audio/webm';
when(() => mockChatRepository.sendAudioMessage(
sessionId,
audioData,
duration,
fileName: fileName,
mimeType: mimeType,
)).thenAnswer((_) async => ChatMessageResponse(
messageId: 'msg_123',
sessionId: sessionId,
content: 'Response',
senderId: 'assistant',
timestamp: DateTime.now(),
));
await chatService.sendAudioMessage(
sessionId,
audioData,
duration,
transcription: transcription,
fileName: fileName,
mimeType: mimeType,
);
verify(() => mockChatRepository.sendAudioMessage(
sessionId,
audioData,
duration,
fileName: fileName,
mimeType: mimeType,
)).called(1);
});
});
group('getMessages', () {
test('gets messages with default parameters', () async {
const sessionId = 'session_123';
final messages = [
ChatMessageResponse(
messageId: 'msg_1',
sessionId: sessionId,
content: 'Hello',
senderId: 'user',
timestamp: DateTime.now(),
),
];
when(() => mockChatRepository.getChatMessages(
sessionId,
limit: 50,
beforeMessageId: null,
)).thenAnswer((_) async => messages);
final result = await chatService.getMessages(sessionId);
expect(result, equals(messages));
verify(() => mockChatRepository.getChatMessages(
sessionId,
limit: 50,
beforeMessageId: null,
)).called(1);
});
test('gets messages with custom parameters', () async {
const sessionId = 'session_123';
const limit = 20;
const beforeMessageId = 'msg_123';
final messages = <ChatMessageResponse>[];
when(() => mockChatRepository.getChatMessages(
sessionId,
limit: limit,
beforeMessageId: beforeMessageId,
)).thenAnswer((_) async => messages);
final result = await chatService.getMessages(
sessionId,
limit: limit,
beforeMessageId: beforeMessageId,
);
expect(result, equals(messages));
verify(() => mockChatRepository.getChatMessages(
sessionId,
limit: limit,
beforeMessageId: beforeMessageId,
)).called(1);
});
});
group('updateSession', () {
test('updates session successfully', () async {
const sessionId = 'session_123';
final updates = {'title': 'New Title'};
final expectedSession = ChatBasicSession(
id: sessionId,
userId: 'user_123',
title: 'New Title',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
when(() => mockChatRepository.updateChatSession(sessionId, updates))
.thenAnswer((_) async => expectedSession);
final result = await chatService.updateSession(sessionId, updates);
expect(result, equals(expectedSession));
verify(() => mockChatRepository.updateChatSession(sessionId, updates)).called(1);
});
});
group('deleteSession', () {
test('deletes session successfully', () async {
const sessionId = 'session_123';
when(() => mockChatRepository.deleteChatSession(sessionId))
.thenAnswer((_) async => {});
await chatService.deleteSession(sessionId);
verify(() => mockChatRepository.deleteChatSession(sessionId)).called(1);
});
});
});
}

View file

@ -1,449 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:mnemo_cards_chat/domain/models/chat_api_simple.dart';
import 'package:mnemo_cards_chat/domain/models/chat_basic.dart';
import 'package:mnemo_cards_chat/domain/models/chat_message.dart';
import 'package:mnemo_cards_chat/domain/services/chat_service.dart';
import 'package:mnemo_cards_chat/domain/state/chat_state_manager.dart';
// Mock classes
class MockChatService extends Mock implements ChatService {}
void main() {
group('ChatStateManager', () {
late ChatStateManager stateManager;
late MockChatService mockChatService;
setUp(() {
mockChatService = MockChatService();
stateManager = ChatStateManager(chatService: mockChatService);
});
test('should initialize with loading state', () {
expect(stateManager.state, equals(const ChatState.loading()));
});
test('can be instantiated with proper dependencies', () {
expect(stateManager, isNotNull);
expect(stateManager, isA<ChatStateManager>());
});
test('has proper constructor signature', () {
expect(
() => ChatStateManager(chatService: mockChatService),
returnsNormally,
);
});
test('state is accessible', () {
expect(stateManager.state, isA<ChatState>());
});
test('has initializeChat method', () {
expect(stateManager.initializeChat, isA<Function>());
});
test('has sendTextMessage method', () {
expect(stateManager.sendTextMessage, isA<Function>());
});
test('has sendAudioMessage method', () {
expect(stateManager.sendAudioMessage, isA<Function>());
});
test('has loadMoreMessages method', () {
expect(stateManager.loadMoreMessages, isA<Function>());
});
test('has clearError method', () {
expect(stateManager.clearError, isA<Function>());
});
test('has currentSession getter', () {
expect(stateManager.currentSession, isNull);
});
test('has messagesCount getter', () {
expect(stateManager.messagesCount, equals(0));
});
group('initializeChat', () {
test('initializes chat successfully', () async {
const sessionId = 'session_123';
final session = ChatBasicSession(
id: sessionId,
userId: 'user_123',
title: 'Test Chat',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
final messages = [
ChatMessageResponse(
messageId: 'msg_1',
sessionId: sessionId,
content: 'Hello',
senderId: 'user',
timestamp: DateTime.now(),
),
];
when(() => mockChatService.getSession(sessionId))
.thenAnswer((_) async => session);
when(() => mockChatService.getMessages(sessionId, limit: 50))
.thenAnswer((_) async => messages);
await stateManager.initializeChat(sessionId);
expect(stateManager.state, isA<ChatStateLoaded>());
final loadedState = stateManager.state as ChatStateLoaded;
expect(loadedState.session, equals(session));
expect(loadedState.messages.length, equals(1));
expect(loadedState.isSendingMessage, isFalse);
expect(loadedState.hasMoreMessages, isFalse);
expect(stateManager.currentSession, equals(session));
expect(stateManager.messagesCount, equals(1));
verify(() => mockChatService.getSession(sessionId)).called(1);
verify(() => mockChatService.getMessages(sessionId, limit: 50)).called(1);
});
test('handles initialization error', () async {
const sessionId = 'session_123';
final error = Exception('Network error');
when(() => mockChatService.getSession(sessionId))
.thenThrow(error);
await stateManager.initializeChat(sessionId);
expect(stateManager.state, isA<ChatStateError>());
final errorState = stateManager.state as ChatStateError;
expect(errorState.message, contains('Failed to load chat'));
expect(stateManager.currentSession, isNull);
});
});
group('sendTextMessage', () {
late ChatBasicSession session;
setUp(() {
session = ChatBasicSession(
id: 'session_123',
userId: 'user_123',
title: 'Test Chat',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
});
test('sends text message successfully when chat is initialized', () async {
// First initialize chat
when(() => mockChatService.getSession(any()))
.thenAnswer((_) async => session);
when(() => mockChatService.getMessages(any(), limit: any(named: 'limit')))
.thenAnswer((_) async => []);
await stateManager.initializeChat(session.id);
// Now send message
const content = 'Hello, world!';
final response = ChatMessageResponse(
messageId: 'msg_123',
sessionId: session.id,
content: 'Assistant response',
senderId: 'assistant',
timestamp: DateTime.now(),
);
when(() => mockChatService.sendTextMessage(session.id, content))
.thenAnswer((_) async => response);
await stateManager.sendTextMessage(content);
expect(stateManager.state, isA<ChatStateLoaded>());
final loadedState = stateManager.state as ChatStateLoaded;
expect(loadedState.messages.length, equals(2)); // User + Assistant messages
expect(loadedState.isSendingMessage, isFalse);
// Check user message
final userMessage = loadedState.messages[0] as TextMessage;
expect(userMessage.sender.id, equals('current_user'));
expect(userMessage.content, equals(content));
expect(userMessage.status, equals(MessageStatus.sent));
// Check assistant message
final assistantMessage = loadedState.messages[1] as TextMessage;
expect(assistantMessage.sender.id, equals('assistant'));
expect(assistantMessage.content, equals('Assistant response'));
verify(() => mockChatService.sendTextMessage(session.id, content)).called(1);
});
test('handles send text message error', () async {
// Initialize chat first
when(() => mockChatService.getSession(any()))
.thenAnswer((_) async => session);
when(() => mockChatService.getMessages(any(), limit: any(named: 'limit')))
.thenAnswer((_) async => []);
await stateManager.initializeChat(session.id);
// Send message with error
const content = 'Hello';
final error = Exception('Network error');
when(() => mockChatService.sendTextMessage(session.id, content))
.thenThrow(error);
await stateManager.sendTextMessage(content);
expect(stateManager.state, isA<ChatStateLoaded>());
final loadedState = stateManager.state as ChatStateLoaded;
expect(loadedState.isSendingMessage, isFalse);
expect(loadedState.errorMessage, contains('Failed to send message'));
expect(loadedState.messages.length, equals(1)); // Only user message
// Check that user message has error status
final userMessage = loadedState.messages[0] as AudioMessage;
expect(userMessage.status, equals(MessageStatus.error));
});
test('does nothing if chat not initialized', () async {
await stateManager.sendTextMessage('Hello');
verifyNever(() => mockChatService.sendTextMessage(any(), any()));
});
});
group('sendAudioMessage', () {
late ChatBasicSession session;
setUp(() {
session = ChatBasicSession(
id: 'session_123',
userId: 'user_123',
title: 'Test Chat',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
});
test('sends audio message successfully', () async {
// Initialize chat
when(() => mockChatService.getSession(any()))
.thenAnswer((_) async => session);
when(() => mockChatService.getMessages(any(), limit: any(named: 'limit')))
.thenAnswer((_) async => []);
await stateManager.initializeChat(session.id);
// Send audio message
final audioData = Uint8List(1024);
const duration = Duration(seconds: 5);
final response = ChatMessageResponse(
messageId: 'msg_123',
sessionId: session.id,
content: 'Audio processed',
senderId: 'assistant',
timestamp: DateTime.now(),
audioUrl: 'https://example.com/audio.mp3',
durationMs: 5000,
fileSize: 1024,
);
when(() => mockChatService.sendAudioMessage(
session.id,
audioData,
duration,
transcription: null,
)).thenAnswer((_) async => response);
await stateManager.sendAudioMessage(audioData, duration);
expect(stateManager.state, isA<ChatStateLoaded>());
final loadedState = stateManager.state as ChatStateLoaded;
expect(loadedState.messages.length, equals(2)); // User + Assistant messages
// Check user audio message
final userMessage = loadedState.messages[0] as AudioMessage;
expect(userMessage.sender.id, equals('current_user'));
expect(userMessage.duration, equals(duration));
expect(userMessage.status, equals(MessageStatus.sent));
// Check assistant message
final assistantMessage = loadedState.messages[1] as TextMessage;
expect(assistantMessage.sender.id, equals('assistant'));
expect(assistantMessage.content, equals('Audio processed'));
verify(() => mockChatService.sendAudioMessage(
session.id,
audioData,
duration,
transcription: null,
)).called(1);
});
test('sends audio message with transcription', () async {
// Initialize chat
when(() => mockChatService.getSession(any()))
.thenAnswer((_) async => session);
when(() => mockChatService.getMessages(any(), limit: any(named: 'limit')))
.thenAnswer((_) async => []);
await stateManager.initializeChat(session.id);
// Send audio with transcription
final audioData = Uint8List(2048);
const duration = Duration(seconds: 3);
const transcription = 'Hello from audio';
when(() => mockChatService.sendAudioMessage(
session.id,
audioData,
duration,
transcription: transcription,
)).thenAnswer((_) async => ChatMessageResponse(
messageId: 'msg_123',
sessionId: session.id,
content: 'Response',
senderId: 'assistant',
timestamp: DateTime.now(),
));
await stateManager.sendAudioMessage(audioData, duration, transcription: transcription);
verify(() => mockChatService.sendAudioMessage(
session.id,
audioData,
duration,
transcription: transcription,
)).called(1);
});
test('does nothing if chat not initialized', () async {
final audioData = Uint8List(1024);
await stateManager.sendAudioMessage(audioData, const Duration(seconds: 1));
verifyNever(() => mockChatService.sendAudioMessage(any(), any(), any()));
});
});
group('loadMoreMessages', () {
late ChatBasicSession session;
setUp(() {
session = ChatBasicSession(
id: 'session_123',
userId: 'user_123',
title: 'Test Chat',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
});
test('loads more messages successfully', () async {
// Initialize with one message
final initialMessages = [
ChatMessageResponse(
messageId: 'msg_1',
sessionId: session.id,
content: 'Initial message',
senderId: 'user',
timestamp: DateTime.now(),
),
];
when(() => mockChatService.getSession(any()))
.thenAnswer((_) async => session);
when(() => mockChatService.getMessages(any(), limit: 50))
.thenAnswer((_) async => initialMessages);
await stateManager.initializeChat(session.id);
// Load more messages
final moreMessages = [
ChatMessageResponse(
messageId: 'msg_0',
sessionId: session.id,
content: 'Older message',
senderId: 'assistant',
timestamp: DateTime.now().subtract(const Duration(hours: 1)),
),
];
when(() => mockChatService.getMessages(
session.id,
limit: 50,
beforeMessageId: 'msg_1',
)).thenAnswer((_) async => moreMessages);
await stateManager.loadMoreMessages();
expect(stateManager.state, isA<ChatStateLoaded>());
final loadedState = stateManager.state as ChatStateLoaded;
expect(loadedState.messages.length, equals(2));
expect(loadedState.isLoadingMore, isFalse);
expect(loadedState.hasMoreMessages, isFalse); // Only 1 more message loaded
// Messages should be in chronological order (oldest first)
expect(loadedState.messages[0].maybeMap(
text: (msg) => msg.content,
audio: (msg) => msg.transcription,
), equals('Older message'));
});
test('does nothing if already loading more', () async {
// Initialize chat
when(() => mockChatService.getSession(any()))
.thenAnswer((_) async => session);
when(() => mockChatService.getMessages(any(), limit: any(named: 'limit')))
.thenAnswer((_) async => []);
await stateManager.initializeChat(session.id);
// Start loading more
stateManager.state = (stateManager.state as ChatStateLoaded).copyWith(isLoadingMore: true);
await stateManager.loadMoreMessages();
verifyNever(() => mockChatService.getMessages(any(), limit: any(named: 'limit'), beforeMessageId: any(named: 'beforeMessageId')));
});
test('does nothing if no more messages', () async {
// Initialize chat
when(() => mockChatService.getSession(any()))
.thenAnswer((_) async => session);
when(() => mockChatService.getMessages(any(), limit: any(named: 'limit')))
.thenAnswer((_) async => []);
await stateManager.initializeChat(session.id);
// Set hasMoreMessages to false
stateManager.state = (stateManager.state as ChatStateLoaded).copyWith(hasMoreMessages: false);
await stateManager.loadMoreMessages();
verifyNever(() => mockChatService.getMessages(any(), limit: any(named: 'limit'), beforeMessageId: any(named: 'beforeMessageId')));
});
});
group('clearError', () {
test('clears error message from loaded state', () {
stateManager.state = ChatState.loaded(
session: ChatBasicSession(
id: 'session_123',
userId: 'user_123',
title: 'Test',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
messages: [],
errorMessage: 'Some error',
);
stateManager.clearError();
final loadedState = stateManager.state as ChatStateLoaded;
expect(loadedState.errorMessage, isNull);
});
test('does nothing for non-loaded states', () {
stateManager.clearError();
expect(stateManager.state, isA<ChatState>()); // Should not crash
});
});
});
}

View file

@ -19,6 +19,7 @@ import '../../discounts/discounts_manager.dart' as _i891;
import '../../packs/free_packs_distributor.dart' as _i1062;
import '../../packs/pack_dto_converter.dart' as _i433;
import '../../packs/pack_manager.dart' as _i833;
import '../../packs/pack_repository.dart' as _i258;
import '../../packs/products_price_resolver.dart' as _i908;
import '../../promo_codes/promo_codes_manager.dart' as _i151;
import '../../statistics/achievement_manager.dart' as _i802;
@ -31,6 +32,7 @@ import '../../tasks/task_manager.dart' as _i586;
import '../../tests/test_manager.dart' as _i259;
import '../../user/user_manager.dart' as _i280;
import '../../user/user_manager_drift.dart' as _i560;
import '../../user/user_repository.dart' as _i950;
import '../ads/ads_manager.dart' as _i846;
import '../mnemo_shelf.dart' as _i561;
import '../purchase/payment_manager.dart' as _i1009;
@ -105,14 +107,11 @@ extension GetItInjectableX on _i174.GetIt {
gh.lazySingleton<_i108.JwtService>(
() => _i108.JwtService(gh<_i1072.AppDatabase>()),
);
gh.lazySingleton<_i296.TelegramBotApiV2>(
() => _i296.TelegramBotApiV2(gh<_i1072.AppDatabase>()),
);
gh.lazySingleton<_i891.DiscountsManager>(
() => _i891.DiscountsManager(gh<_i1072.AppDatabase>()),
);
gh.lazySingleton<_i1062.FreePacksDistributor>(
() => _i1062.FreePacksDistributor(gh<_i1072.AppDatabase>()),
gh.lazySingleton<_i258.PackRepository>(
() => _i258.PackRepository(gh<_i1072.AppDatabase>()),
);
gh.lazySingleton<_i802.AchievementManager>(
() => _i802.AchievementManager(gh<_i1072.AppDatabase>()),
@ -129,46 +128,42 @@ extension GetItInjectableX on _i174.GetIt {
gh.lazySingleton<_i586.TaskManager>(
() => _i586.TaskManager(gh<_i1072.AppDatabase>()),
);
gh.lazySingleton<_i950.UserRepository>(
() => _i950.UserRepository(gh<_i1072.AppDatabase>()),
);
gh.factory<_i1015.AdminPacksApiV2>(
() => _i1015.AdminPacksApiV2(gh<_i1072.AppDatabase>()),
);
gh.factory<_i895.AdminUsersApiV2>(
() => _i895.AdminUsersApiV2(gh<_i1072.AppDatabase>()),
);
gh.lazySingleton<_i964.SubscriptionsApiV2>(
() => _i964.SubscriptionsApiV2(gh<_i377.SubscriptionManager>()),
);
gh.lazySingleton<_i858.DiscountsApiV2>(
() => _i858.DiscountsApiV2(gh<_i891.DiscountsManager>()),
);
gh.lazySingleton<_i1009.PaymentManager>(
() => _i1009.PaymentManager(
gh.lazySingleton<_i296.TelegramBotApiV2>(
() => _i296.TelegramBotApiV2(
gh<_i1072.AppDatabase>(),
gh<_i988.YooMoneyHandler>(),
gh<_i222.RustorePurchaseHandler>(),
gh<_i950.UserRepository>(),
),
);
gh.factory<_i895.AdminUsersApiV2>(
() => _i895.AdminUsersApiV2(
gh<_i1072.AppDatabase>(),
gh<_i950.UserRepository>(),
),
);
gh.lazySingleton<_i365.MediaApiV2>(
() => _i365.MediaApiV2(gh<_i747.MinioService>()),
);
gh.lazySingleton<_i280.UserManager>(
() => _i280.UserManager(
gh<_i1072.AppDatabase>(),
gh<_i1062.FreePacksDistributor>(),
gh<_i71.SessionTracker>(),
gh<_i1029.StatisticsCalculator>(),
gh<_i802.AchievementManager>(),
gh<_i909.WordStatisticsManager>(),
),
);
gh.lazySingleton<_i985.TasksApiV2>(
() => _i985.TasksApiV2(gh<_i586.TaskManager>()),
);
gh.lazySingleton<_i483.AdminAuthApiV2>(
() => _i483.AdminAuthApiV2(
gh<_i240.TelegramAuthCodeService>(),
gh<_i280.UserManager>(),
gh<_i108.JwtService>(),
gh.lazySingleton<_i1009.PaymentManager>(
() => _i1009.PaymentManager(
gh<_i1072.AppDatabase>(),
gh<_i950.UserRepository>(),
gh<_i988.YooMoneyHandler>(),
gh<_i222.RustorePurchaseHandler>(),
),
);
gh.lazySingleton<_i151.PromoCodesManager>(
@ -177,6 +172,12 @@ extension GetItInjectableX on _i174.GetIt {
gh<_i1009.PaymentManager>(),
),
);
gh.lazySingleton<_i1062.FreePacksDistributor>(
() => _i1062.FreePacksDistributor(
gh<_i1072.AppDatabase>(),
gh<_i258.PackRepository>(),
),
);
gh.lazySingleton<_i560.UserManager>(
() => _i560.UserManager(
gh<_i1072.AppDatabase>(),
@ -186,17 +187,6 @@ extension GetItInjectableX on _i174.GetIt {
gh<_i802.AchievementManager>(),
),
);
gh.lazySingleton<_i561.MnemoShelf>(
() => _i561.MnemoShelf(gh<_i280.UserManager>()),
);
gh.lazySingleton<_i247.UsersApiV2>(
() => _i247.UsersApiV2(
gh<_i280.UserManager>(),
gh<_i1009.PaymentManager>(),
gh<_i1029.StatisticsCalculator>(),
gh<_i1072.AppDatabase>(),
),
);
gh.lazySingleton<_i273.PromocodesApiV2>(
() => _i273.PromocodesApiV2(gh<_i151.PromoCodesManager>()),
);
@ -212,6 +202,55 @@ extension GetItInjectableX on _i174.GetIt {
gh<_i1072.AppDatabase>(),
),
);
gh.lazySingleton<_i280.UserManager>(
() => _i280.UserManager(
gh<_i1072.AppDatabase>(),
gh<_i950.UserRepository>(),
gh<_i1062.FreePacksDistributor>(),
gh<_i71.SessionTracker>(),
gh<_i1029.StatisticsCalculator>(),
gh<_i802.AchievementManager>(),
gh<_i909.WordStatisticsManager>(),
),
);
gh.lazySingleton<_i483.AdminAuthApiV2>(
() => _i483.AdminAuthApiV2(
gh<_i240.TelegramAuthCodeService>(),
gh<_i280.UserManager>(),
gh<_i108.JwtService>(),
),
);
gh.lazySingleton<_i433.PackDtoConverter>(
() => _i433.PackDtoConverter(
gh<_i908.ProductsPriceResolver>(),
gh<_i846.AdsManager>(),
),
);
gh.lazySingleton<_i833.PackManager>(
() => _i833.PackManager(
gh<_i1072.AppDatabase>(),
gh<_i258.PackRepository>(),
gh<_i433.PackDtoConverter>(),
),
);
gh.lazySingleton<_i69.PurchasesApiV2>(
() => _i69.PurchasesApiV2(
gh<_i1009.PaymentManager>(),
gh<_i833.PackManager>(),
gh<_i1072.AppDatabase>(),
),
);
gh.lazySingleton<_i561.MnemoShelf>(
() => _i561.MnemoShelf(gh<_i280.UserManager>()),
);
gh.lazySingleton<_i247.UsersApiV2>(
() => _i247.UsersApiV2(
gh<_i280.UserManager>(),
gh<_i1009.PaymentManager>(),
gh<_i1029.StatisticsCalculator>(),
gh<_i1072.AppDatabase>(),
),
);
gh.lazySingleton<_i170.TestsApiV2>(
() => _i170.TestsApiV2(
gh<_i259.TestManager>(),
@ -228,25 +267,6 @@ extension GetItInjectableX on _i174.GetIt {
gh<_i240.TelegramAuthCodeService>(),
),
);
gh.lazySingleton<_i433.PackDtoConverter>(
() => _i433.PackDtoConverter(
gh<_i908.ProductsPriceResolver>(),
gh<_i846.AdsManager>(),
),
);
gh.lazySingleton<_i833.PackManager>(
() => _i833.PackManager(
gh<_i1072.AppDatabase>(),
gh<_i433.PackDtoConverter>(),
),
);
gh.lazySingleton<_i69.PurchasesApiV2>(
() => _i69.PurchasesApiV2(
gh<_i1009.PaymentManager>(),
gh<_i833.PackManager>(),
gh<_i1072.AppDatabase>(),
),
);
gh.lazySingleton<_i800.PacksApiV2>(
() => _i800.PacksApiV2(
gh<_i833.PackManager>(),

View file

@ -10,7 +10,7 @@ import 'package:drift/drift.dart' as drift;
import 'payment_drift_extension.dart';
import 'rustore/rustore_purchase_handler.dart';
import 'yoo_money.dart';
import '../../user/user_drift_extension.dart';
import '../../user/user_repository.dart';
/// Result of creating YooKassa payment URL
class YookassaPaymentResult {
@ -26,11 +26,17 @@ class YookassaPaymentResult {
@lazySingleton
class PaymentManager {
final AppDatabase _db;
final UserRepository _userRepository;
late final GooglePlayPurchaseHandler googlePurchaseHandler;
final YooMoneyHandler _yooMoneyHandler;
final RustorePurchaseHandler _rustorePurchaseHandler;
PaymentManager(this._db, this._yooMoneyHandler, this._rustorePurchaseHandler);
PaymentManager(
this._db,
this._userRepository,
this._yooMoneyHandler,
this._rustorePurchaseHandler,
);
/// Создать платеж в базе данных
Future<PaymentDto> createPayment(PaymentDto paymentDto, String userId) async {
@ -74,7 +80,7 @@ class PaymentManager {
await _db.transaction(() async {
if (product.type == MnemoCardsProductType.pack && product.id != null) {
final packId = product.id!;
await _db.userDao.grantPackAccess(
await _userRepository.grantPackAccess(
userId: userId,
packId: packId,
grantType: 'promo_code',
@ -114,8 +120,8 @@ class PaymentManager {
}
// Получить пользователя
final user = await _db.userDao.getUserWithDataById(payment.userId);
if (user == null) {
final userModel = await _userRepository.getUserWithDataById(payment.userId);
if (userModel == null) {
log('User not found: ${payment.userId}');
return;
}
@ -154,7 +160,7 @@ class PaymentManager {
await _db.transaction(() async {
// Дать доступ к пакетам
for (final packId in packIds) {
await _db.userDao.grantPackAccess(
await _userRepository.grantPackAccess(
userId: payment.userId,
packId: packId,
grantType: 'purchase',
@ -422,11 +428,11 @@ class PaymentManager {
final firstProduct = payment.products.first;
final productId = firstProduct['id']?.toString();
if (productId != null && payment.externalToken != null) {
final user = await _db.userDao.getUserById(payment.userId);
final user = await _userRepository.getUserById(payment.userId);
await checkRustorePayment(
productId: productId,
subscriptionToken: payment.externalToken!,
user: user != null ? await user.toUserModel() : null,
user: user,
);
}
}

View file

@ -4,8 +4,8 @@ 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/main.dart' as backend_main;
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/user/user_repository.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
@ -14,7 +14,10 @@ part 'admin_analytics_api_v2.g.dart';
/// Admin endpoints for analytics and statistics in API v2.
@lazySingleton
class AdminAnalyticsApiV2 {
AdminAnalyticsApiV2();
final UserRepository _userRepository;
final AppDatabase _db;
AdminAnalyticsApiV2(this._userRepository, this._db);
Response _json(
Object? data, {
@ -51,23 +54,22 @@ class AdminAnalyticsApiV2 {
}
// Get basic statistics
final userCount = await backend_main.database.userDao.countUsers();
final userCount = await _userRepository.countUsers();
final cardCount = await backend_main.database.packDao.countCards();
final cardCount = await _db.packDao.countCards();
final packCount = await backend_main.database.packDao.countPacks();
final packCount = await _db.packDao.countPacks();
// Get all packs and filter in memory
final enabledPacks = await backend_main.database.packDao.getAllPacks(
final enabledPacks = await _db.packDao.getAllPacks(
enabledOnly: true,
);
final enabledPackCount = enabledPacks.length;
final paymentCount = await backend_main.database.paymentDao
.countAllPayments();
final paymentCount = await _db.paymentDao.countAllPayments();
// Get recent users (last 10)
final allUsers = await backend_main.database.userDao.getAllUsers(
final allUsers = await _userRepository.getAllUsers(
limit: 10,
);
final recentUsers = allUsers
@ -76,17 +78,16 @@ class AdminAnalyticsApiV2 {
'id': u.id,
'name': u.name,
'email': u.email,
'createdAt': u.createdAt.dateTime.toIso8601String(),
},
)
.toList();
// Get top packs by user count
final allPacks = await backend_main.database.packDao.getAllPacks();
final allPacks = await _db.packDao.getAllPacks();
final topPacksList = allPacks.take(5);
final topPacks = await Future.wait(
topPacksList.map((p) async {
final cards = await backend_main.database.packDao.getPackCards(p.id);
final cards = await _db.packDao.getPackCards(p.id);
return {
'id': p.id,
'title': p.title,

View file

@ -9,7 +9,9 @@ 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_backend/packs/card_image_storage.dart';
import 'package:mnemo_cards_backend/packs/pack_repository.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
@ -19,8 +21,9 @@ part 'admin_packs_api_v2.g.dart';
@injectable
class AdminPacksApiV2 {
final AppDatabase _db;
final PackRepository _packRepository;
AdminPacksApiV2(this._db);
AdminPacksApiV2(this._db, this._packRepository);
static final _uuidRegex = RegExp(
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
@ -47,9 +50,9 @@ class AdminPacksApiV2 {
required String cardId,
}) async {
try {
final card = await _db.packDao.getCardById(cardId);
final card = await _packRepository.getCardById(cardId);
if (card == null) return;
await _db.packDao.addCardToPack(packId: packId, cardId: cardId);
await _packRepository.addCardToPack(packId: packId, cardId: cardId);
} catch (_) {
// Best-effort only.
}
@ -67,6 +70,7 @@ class AdminPacksApiV2 {
mnemo: const drift.Value('test_image'),
);
// Note: createCard is not in PackRepository, keeping direct DAO call for now
final cardId = await _db.packDao.createCard(companion);
await _tryLinkCardToPack(packId: packId, cardId: cardId);
@ -78,11 +82,16 @@ class AdminPacksApiV2 {
);
if (stored == null) return null;
final created = await _db.packDao.getCardById(cardId);
final created = await _packRepository.getCardById(cardId);
if (created == null) return null;
// Note: updateCard is not in PackRepository, keeping direct DAO call for now
// We need to get the Drift GameCard to update it
final driftCard = await _db.packDao.getCardById(cardId);
if (driftCard == null) return null;
await _db.packDao.updateCard(
created.copyWith(
driftCard.copyWith(
image: stored.fileName,
updatedAt: PgDateTime(DateTime.now()),
),
@ -278,25 +287,41 @@ class AdminPacksApiV2 {
final validatedLimit = limit < 1 ? 20 : (limit > 100 ? 100 : limit);
// Get all packs
final allPacks = await _db.packDao.getAllPacks(
final allPacks = await _packRepository.getAllPacks(
enabledOnly: !showDisabled,
orderByField: 'order',
);
// Apply search filter if provided
List<CardPack> filteredPacks = allPacks;
List<CardPackModel> 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);
(pack.id?.toLowerCase().contains(searchTerm) ?? false);
}).toList();
}
// Convert to preview DTOs
// Note: We need to convert CardPackModel back to CardPack for toPreviewDto
// This is a limitation - we should add a method to PackRepository for preview DTOs
final previewDtos = await Future.wait(
filteredPacks.map((pack) async {
filteredPacks.where((p) => p.id != null).map((packModel) async {
// Get the Drift CardPack for toPreviewDto extension
final pack = await _db.packDao.getPackById(packModel.id!);
if (pack == null) {
return {
'id': packModel.id,
'title': packModel.title,
'subtitle': packModel.subtitle,
'color': packModel.color,
'cover': null,
'cards': packModel.size,
'enabled': packModel.enabled,
'order': packModel.order,
};
}
final dto = await pack.toPreviewDto(null);
return {
'id': dto.id,
@ -357,7 +382,7 @@ class AdminPacksApiV2 {
}, statusCode: 400);
}
final pack = await _db.packDao.getPackById(packId);
final pack = await _packRepository.getPackById(packId);
if (pack == null) {
return _json({
'error': 'Pack not found',
@ -367,13 +392,13 @@ class AdminPacksApiV2 {
}, statusCode: 404);
}
final cards = await _db.packDao.getPackCards(packId);
final cards = await _packRepository.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 previewCards = await _packRepository.getPreviewCards(packId);
final previewCardIds = previewCards.map((c) => c.id).toList();
// Get tests for this pack
@ -447,7 +472,7 @@ class AdminPacksApiV2 {
if (editDto.id != null && editDto.id!.isNotEmpty) {
// Update existing pack
packId = editDto.id!;
final existing = await _db.packDao.getPackById(packId);
final existing = await _packRepository.getPackById(packId);
if (existing == null) {
return _json({
'error': 'Pack not found',
@ -457,7 +482,7 @@ class AdminPacksApiV2 {
}, statusCode: 404);
}
await _db.packDao.updatePackPartial(
await _packRepository.updatePackPartial(
CardPacksCompanion(
id: drift.Value(packId),
title: editDto.title != null
@ -504,39 +529,26 @@ class AdminPacksApiV2 {
);
} 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),
),
// Note: We need to create a CardPackModel first
final packModel = CardPackModel(
id: null,
title: editDto.title ?? '',
subtitle: editDto.subtitle ?? '',
size: editDto.size ?? 0,
color: editDto.color,
version: editDto.version,
cover: editDto.cover,
description: editDto.description,
googlePlayId: editDto.googlePlayId,
rustoreId: editDto.rustoreId,
appStoreId: editDto.appStoreId,
price: editDto.price,
currency: null,
enabled: editDto.enabled ?? true,
order: editDto.order ?? 0,
cardsOrder: [],
);
packId = await _packRepository.createPack(packModel);
}
// Handle card associations if provided
@ -544,7 +556,7 @@ class AdminPacksApiV2 {
try {
for (final cardId in editDto.addCardIds!) {
// Verify card exists before adding
final card = await _db.packDao.getCardById(cardId);
final card = await _packRepository.getCardById(cardId);
if (card == null) {
return _json({
'error': 'Card not found',
@ -555,7 +567,7 @@ class AdminPacksApiV2 {
'Card with ID "$cardId" was not found. Please verify all card IDs before adding them to the pack.',
}, statusCode: 404);
}
await _db.packDao.addCardToPack(packId: packId, cardId: cardId);
await _packRepository.addCardToPack(packId: packId, cardId: cardId);
}
} catch (e) {
// Handle duplicate or constraint errors
@ -575,7 +587,7 @@ class AdminPacksApiV2 {
if (editDto.removeCardIds != null && editDto.removeCardIds!.isNotEmpty) {
for (final cardId in editDto.removeCardIds!) {
await _db.packDao.removeCardFromPack(packId, cardId);
await _packRepository.removeCardFromPack(packId, cardId);
}
}
@ -625,15 +637,15 @@ class AdminPacksApiV2 {
// Handle cards order if provided
if (editDto.cardsOrder != null && editDto.cardsOrder!.isNotEmpty) {
await _db.packDao.updatePackCardsOrder(packId, editDto.cardsOrder!);
await _packRepository.updatePackCardsOrder(packId, editDto.cardsOrder!);
}
// Handle preview cards
if (editDto.previewCards != null) {
await _db.packDao.setPreviewCards(packId, editDto.previewCards!);
await _packRepository.setPreviewCards(packId, editDto.previewCards!);
}
final updatedPack = await _db.packDao.getPackById(packId);
final updatedPack = await _packRepository.getPackById(packId);
if (updatedPack == null) {
return _json({
'error': 'Database error',
@ -643,9 +655,9 @@ class AdminPacksApiV2 {
}, statusCode: 500);
}
final updatedCards = await _db.packDao.getPackCards(packId);
final updatedCards = await _packRepository.getPackCards(packId);
final cardsOrder = updatedCards.map((c) => c.id).toList();
final previewCards = await _db.packDao.getPreviewCards(packId);
final previewCards = await _packRepository.getPreviewCards(packId);
final previewCardIds = previewCards.map((c) => c.id).toList();
// Get updated tests for this pack
@ -726,7 +738,7 @@ class AdminPacksApiV2 {
}, statusCode: 400);
}
final pack = await _db.packDao.getPackById(packId);
final pack = await _packRepository.getPackById(packId);
if (pack == null) {
return _json({
'error': 'Pack not found',
@ -736,7 +748,7 @@ class AdminPacksApiV2 {
}, statusCode: 404);
}
await _db.packDao.softDeletePack(packId);
await _packRepository.softDeletePack(packId);
return _json({'success': true, 'message': 'Pack deleted successfully'});
} catch (e, s) {

View file

@ -7,10 +7,11 @@ 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:mnemo_cards_backend/user/user_repository.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
@ -20,8 +21,9 @@ part 'admin_users_api_v2.g.dart';
@injectable
class AdminUsersApiV2 {
final AppDatabase _db;
final UserRepository _userRepository;
AdminUsersApiV2(this._db);
AdminUsersApiV2(this._db, this._userRepository);
Response _json(
Object? data, {
@ -78,22 +80,22 @@ class AdminUsersApiV2 {
final validatedLimit = limit < 1 ? 20 : (limit > 100 ? 100 : limit);
// Get total count
final total = await _db.userDao.countUsers(includeDeleted: false);
final total = await _userRepository.countUsers(includeDeleted: false);
// Calculate offset
final offset = (page - 1) * validatedLimit;
// Get users with pagination
final users = await _db.userDao.getAllUsers(
final userModels = await _userRepository.getAllUsers(
limit: validatedLimit,
offset: offset,
includeDeleted: false,
includePacks: true,
);
// Convert to DTOs
final userDtos = <Map<String, dynamic>>[];
for (final user in users) {
final userModel = await user.toUserModel();
for (final userModel in userModels) {
final dto = await userModel.toDto();
userDtos.add(dto.toJson());
}
@ -158,8 +160,8 @@ class AdminUsersApiV2 {
}, statusCode: 400);
}
final user = await _db.userDao.getUserById(userId);
if (user == null) {
final userModel = await _userRepository.getUserById(userId, includePacks: true);
if (userModel == null) {
return _json({
'error': 'User not found',
'message': 'The requested user does not exist or has been deleted',
@ -168,7 +170,6 @@ class AdminUsersApiV2 {
}, statusCode: 404);
}
final userModel = await user.toUserModel();
final dto = await userModel.toDto();
return _json(dto.toJson());
@ -218,8 +219,8 @@ class AdminUsersApiV2 {
if (isUpdate) {
// Update existing user
final existingUser = await _db.userDao.getUserById(userDto.id!);
if (existingUser == null) {
final existingUserModel = await _userRepository.getUserById(userDto.id!);
if (existingUserModel == null) {
return _json({
'error': 'User not found',
'message': 'The user you are trying to update does not exist',
@ -250,11 +251,11 @@ class AdminUsersApiV2 {
updatedAt: drift.Value(PgDateTime(DateTime.now())),
);
await _db.userDao.updateUserPartial(updates);
await _userRepository.updateUserPartial(updates);
// Get updated user
final updatedUser = await _db.userDao.getUserById(userDto.id!);
if (updatedUser == null) {
final updatedUserModel = await _userRepository.getUserById(userDto.id!, includePacks: true);
if (updatedUserModel == null) {
return _json({
'error': 'Database error',
'message': 'Failed to retrieve updated user',
@ -263,8 +264,7 @@ class AdminUsersApiV2 {
}, statusCode: 500);
}
final userModel = await updatedUser.toUserModel();
final dto = await userModel.toDto();
final dto = await updatedUserModel.toDto();
return _json({'result': true, 'user': dto.toJson()});
} else {
@ -275,10 +275,8 @@ class AdminUsersApiV2 {
: 'admin_created_${DateTime.now().millisecondsSinceEpoch}';
// Check if user with this externalUserId already exists
final existingUser = await _db.userDao.getUserByExternalId(
externalUserId,
);
if (existingUser != null) {
final existingUserModel = await _userRepository.getUserByExternalId(externalUserId);
if (existingUserModel != null) {
return _json({
'error': 'User already exists',
'message': 'A user with this email already exists',
@ -288,30 +286,29 @@ class AdminUsersApiV2 {
}
final now = DateTime.now();
final userCompanion = UsersCompanion.insert(
externalUserId: externalUserId,
name: drift.Value(userDto.name),
email: drift.Value(userDto.email),
telegram: drift.Value(userDto.telegram),
admin: drift.Value(userDto.admin),
purchases: drift.Value(userDto.purchases),
createdAt: drift.Value(PgDateTime(now)),
updatedAt: drift.Value(PgDateTime(now)),
isDeleted: drift.Value(false),
);
final userDataCompanion = UserDatasCompanion.insert(
userId: '', // Will be set by createUserWithData
registrationDate: drift.Value(PgDateTime(now)),
);
final userId = await _db.userDao.createUserWithData(
user: userCompanion,
final userModel = UserModel(
id: null,
name: userDto.name,
email: userDto.email,
telegram: userDto.telegram,
admin: userDto.admin,
purchases: userDto.purchases,
userSettings: null,
packs: [],
);
final userId = await _userRepository.createUserWithData(
userModel: userModel,
userData: userDataCompanion,
);
final createdUser = await _db.userDao.getUserById(userId);
if (createdUser == null) {
final createdUserModel = await _userRepository.getUserById(userId, includePacks: true);
if (createdUserModel == null) {
return _json({
'error': 'Database error',
'message': 'Failed to retrieve created user',
@ -320,8 +317,7 @@ class AdminUsersApiV2 {
}, statusCode: 500);
}
final userModel = await createdUser.toUserModel();
final dto = await userModel.toDto();
final dto = await createdUserModel.toDto();
return _json({'result': true, 'user': dto.toJson()});
}
@ -355,8 +351,8 @@ class AdminUsersApiV2 {
}, statusCode: 400);
}
final user = await _db.userDao.getUserById(userId);
if (user == null) {
final userModel = await _userRepository.getUserById(userId);
if (userModel == null) {
return _json({
'error': 'User not found',
'message': 'The user you are trying to delete does not exist',
@ -365,7 +361,9 @@ class AdminUsersApiV2 {
}, statusCode: 404);
}
if (user.isDeleted) {
// Check if already deleted via DAO (isDeleted is not in UserModel)
final user = await _db.userDao.getUserById(userId);
if (user != null && user.isDeleted) {
return _json({
'error': 'User already deleted',
'message': 'The user has already been deleted',
@ -373,7 +371,7 @@ class AdminUsersApiV2 {
}, statusCode: 409);
}
await _db.userDao.softDeleteUser(userId);
await _userRepository.softDeleteUser(userId);
return _json({'result': true});
} catch (e, s) {

View file

@ -8,13 +8,15 @@ import 'package:mnemo_cards_backend/database/database.dart' hide VoiceModel;
import 'package:mnemo_cards_backend/database/database.dart'
as drift
show VoiceModel;
import 'package:mnemo_cards_backend/packs/card_pack_drift_extension.dart';
import 'package:mnemo_cards_backend/packs/card_image_storage.dart';
import 'package:mnemo_cards_backend/packs/card_model_extension.dart';
import 'package:mnemo_cards_backend/packs/pack_manager.dart' show PackManager;
import 'package:mnemo_cards_backend/packs/pack_repository.dart';
import 'package:mnemo_cards_backend/packs/voice_storage.dart';
import 'package:mnemo_cards_backend/storage/minio_config.dart';
import 'package:mnemo_cards_backend/storage/minio_service.dart';
import 'package:mnemo_cards_backend/tests/test_manager.dart';
import 'package:mnemo_cards_backend/user/user_repository.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:shelf/shelf.dart';
@ -30,12 +32,16 @@ part 'packs_api_v2.g.dart';
class PacksApiV2 {
final PackManager _packManager;
final TestManager _testManager;
final PackRepository _packRepository;
final UserRepository _userRepository;
final AppDatabase _db;
final MinioService _minioService;
PacksApiV2(
this._packManager,
this._testManager,
this._packRepository,
this._userRepository,
this._db,
this._minioService,
);
@ -366,13 +372,13 @@ class PacksApiV2 {
user: request.user,
);
final pack = await _db.packDao.getPackById(packId);
final pack = await _packRepository.getPackById(packId);
if (pack == null) {
return _notFound('Pack not found');
}
// Get cards for pack
final cards = await _db.packDao.getPackCards(packId);
final cards = await _packRepository.getPackCards(packId);
// Parse pagination
final queryParams = request.requestedUri.queryParameters;
@ -396,7 +402,7 @@ class PacksApiV2 {
final cardIds = paginatedCards.map((c) => c.id).toList();
final allVoices = <drift.VoiceModel>[];
for (final cardId in cardIds) {
final voices = await _db.packDao.getCardVoices(cardId);
final voices = await _packRepository.getCardVoices(cardId);
allVoices.addAll(voices);
}
@ -404,9 +410,7 @@ class PacksApiV2 {
// Presigned URLs now have 7 days expiration to avoid 403 errors on cached data
final cardDtos = await Future.wait(
paginatedCards.map((card) async {
final dto = await card.toDto(
allVoices.where((v) => v.cardId == card.id).toList(),
);
final dto = card.toDto();
// Generate presigned URLs for images if they are object IDs (UUIDs)
final imageUrl = await _getPresignedUrlIfUuid(
@ -468,7 +472,7 @@ class PacksApiV2 {
// Check if pack exists and is enabled
// We allow access to images for enabled packs even without auth
// to support image previews in public listings
final pack = await _db.packDao.getPackById(packId);
final pack = await _packRepository.getPackById(packId);
print('Pack lookup result: pack=$pack, enabled=${pack?.enabled}');
if (pack == null || !pack.enabled) {
print('Pack not found or not enabled');
@ -476,7 +480,7 @@ class PacksApiV2 {
}
// Get card and verify it belongs to the pack
final card = await _db.packDao.getCardById(cardId);
final card = await _packRepository.getCardById(cardId);
print('Card lookup result: card=$card, image=${card?.image}');
if (card == null) {
print('Card not found');
@ -484,7 +488,7 @@ class PacksApiV2 {
}
// Verify card belongs to this pack
final packCards = await _db.packDao.getPackCards(packId);
final packCards = await _packRepository.getPackCards(packId);
print('Pack cards count: ${packCards.length}');
final belongsToPack = packCards.any((c) => c.id == cardId);
print('Card belongs to pack: $belongsToPack');
@ -546,19 +550,19 @@ class PacksApiV2 {
// Check if pack exists and is enabled
// We allow access to images for enabled packs even without auth
// to support image previews in public listings
final pack = await _db.packDao.getPackById(packId);
final pack = await _packRepository.getPackById(packId);
if (pack == null || !pack.enabled) {
return _notFound('Pack not found or not enabled');
}
// Get card and verify it belongs to the pack
final card = await _db.packDao.getCardById(cardId);
final card = await _packRepository.getCardById(cardId);
if (card == null || card.imageBack == null || card.imageBack!.isEmpty) {
return _notFound('Card or back image not found');
}
// Verify card belongs to this pack
final packCards = await _db.packDao.getPackCards(packId);
final packCards = await _packRepository.getPackCards(packId);
final belongsToPack = packCards.any((c) => c.id == cardId);
if (!belongsToPack) {
return _notFound('Card does not belong to this pack');
@ -611,7 +615,7 @@ class PacksApiV2 {
// Check if pack exists and is enabled
// We allow access to covers for enabled packs even without auth
// to support image previews in public listings
final pack = await _db.packDao.getPackById(packId);
final pack = await _packRepository.getPackById(packId);
if (pack == null || !pack.enabled) {
return _notFound('Pack not found or not enabled');
}
@ -665,25 +669,25 @@ class PacksApiV2 {
return _badRequest('Invalid pack ID');
}
final pack = await _db.packDao.getPackById(packId);
final pack = await _packRepository.getPackById(packId);
if (pack == null || !pack.enabled) {
return _notFound('Pack not found or not enabled');
}
final card = await _db.packDao.getCardById(cardId);
final card = await _packRepository.getCardById(cardId);
if (card == null) {
return _notFound('Card not found');
}
// Verify card belongs to this pack
final packCards = await _db.packDao.getPackCards(packId);
final packCards = await _packRepository.getPackCards(packId);
final belongsToPack = packCards.any((c) => c.id == cardId);
if (!belongsToPack) {
return _notFound('Card does not belong to this pack');
}
// Get voices for card
final voices = await _db.packDao.getCardVoices(cardId);
final voices = await _packRepository.getCardVoices(cardId);
print('🔍 getCardVoices: Found ${voices.length} voices for card $cardId');
if (voices.isEmpty) {
print('⚠️ getCardVoices: No voices found for card $cardId');
@ -742,7 +746,7 @@ class PacksApiV2 {
);
if (stored != null) {
voicePath = stored.fileName;
await _db.packDao.updateVoiceUrl(voice.id, stored.fileName);
await _packRepository.updateVoiceUrl(voice.id, stored.fileName);
} else {
voicePath = '';
}
@ -776,20 +780,21 @@ class PacksApiV2 {
return _badRequest('Invalid voice ID');
}
final voice = await _db.packDao.getVoiceById(voiceId);
final voice = await _packRepository.getVoiceById(voiceId);
if (voice == null || voice.voiceUrl.isEmpty) {
return _notFound('Voice not found');
}
// Get cards that use this voice by checking CardVoices junction table
// We need to find cards that have this voice and belong to enabled packs
final allPacks = await _db.packDao.getAllPacks(enabledOnly: true);
final allPacks = await _packRepository.getAllPacks(enabledOnly: true);
bool hasEnabledPack = false;
for (final pack in allPacks) {
final packCards = await _db.packDao.getPackCards(pack.id);
if (pack.id == null) continue;
final packCards = await _packRepository.getPackCards(pack.id!);
for (final card in packCards) {
final cardVoices = await _db.packDao.getCardVoices(card.id);
final cardVoices = await _packRepository.getCardVoices(card.id);
if (cardVoices.any((v) => v.id == voiceId)) {
hasEnabledPack = true;
break;
@ -831,7 +836,7 @@ class PacksApiV2 {
/// Check if user purchased a pack
Future<bool> _isPackPurchased(String userId, String packId) async {
return await _db.userDao.hasPackAccess(userId, packId);
return await _userRepository.hasPackAccess(userId, packId);
}
/// GET /api/v2/packs/{packId}/tests

View file

@ -7,6 +7,7 @@ import 'package:mnemo_cards_backend/api/purchase/payment_manager.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/packs/pack_manager.dart';
import 'package:mnemo_cards_backend/packs/products_price_resolver.dart';
import 'package:mnemo_cards_backend/user/user_repository.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:shelf/shelf.dart';
@ -21,9 +22,15 @@ part 'purchases_api_v2.g.dart';
class PurchasesApiV2 {
final PaymentManager _paymentManager;
final PackManager _packManager;
final UserRepository _userRepository;
final AppDatabase _db;
PurchasesApiV2(this._paymentManager, this._packManager, this._db);
PurchasesApiV2(
this._paymentManager,
this._packManager,
this._userRepository,
this._db,
);
Response _ok(Object? object, {Map<String, String> headers = const {}}) =>
Response.ok(
@ -95,7 +102,7 @@ class PurchasesApiV2 {
'🔍 createPackPurchase: Checking pack access for userId=${user.id}, packId=$packId',
);
// Check if already purchased
final hasAccess = await _db.userDao.hasPackAccess(user.id!, packId);
final hasAccess = await _userRepository.hasPackAccess(user.id!, packId);
if (hasAccess) {
print('❌ createPackPurchase: Pack already purchased');
return _badRequest('Pack is already purchased');
@ -106,7 +113,7 @@ class PurchasesApiV2 {
String? price = pack.price;
if (price != null && user.id != null) {
print('🔍 createPackPurchase: Getting user data for userId=${user.id}');
final userData = await _db.userDao.getUserData(user.id!);
final userData = await _userRepository.getUserData(user.id!);
if (userData != null) {
print('✅ createPackPurchase: User data found');
// Apply discounts if any
@ -201,7 +208,7 @@ class PurchasesApiV2 {
}
// Check if already purchased
final hasAccess = await _db.userDao.hasPackAccess(user.id!, productId);
final hasAccess = await _userRepository.hasPackAccess(user.id!, productId);
if (hasAccess) {
return _badRequest('Pack is already purchased');
}
@ -209,7 +216,7 @@ class PurchasesApiV2 {
// Get price (with discounts if applicable)
price = pack.price;
if (price != null && user.id != null) {
final userData = await _db.userDao.getUserData(user.id!);
final userData = await _userRepository.getUserData(user.id!);
if (userData != null) {
// Apply discounts if any
// For now, use price as-is. Discounts can be added later if needed.
@ -252,7 +259,7 @@ class PurchasesApiV2 {
// Get price (with discounts if applicable)
price = plan.price;
if (user.id != null) {
final userData = await _db.userDao.getUserData(user.id!);
final userData = await _userRepository.getUserData(user.id!);
if (userData != null) {
// Apply discounts if any
// For now, use price as-is. Discounts can be added later if needed.
@ -364,7 +371,7 @@ class PurchasesApiV2 {
}
// Check purchase status
final isPurchased = await _db.userDao.hasPackAccess(user.id!, packId);
final isPurchased = await _userRepository.hasPackAccess(user.id!, packId);
// Check subscription access
final activeSubscription = await _db.subscriptionDao

View file

@ -5,8 +5,7 @@ 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/main.dart' as backend_main;
import 'package:mnemo_cards_backend/user/user_drift_extension.dart';
import 'package:mnemo_cards_backend/user/user_repository.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf_open_api/shelf_open_api.dart';
@ -21,8 +20,9 @@ part 'telegram_bot_api_v2.g.dart';
@lazySingleton
class TelegramBotApiV2 {
final AppDatabase _db;
final UserRepository _userRepository;
TelegramBotApiV2(this._db);
TelegramBotApiV2(this._db, this._userRepository);
Response _ok(Object? object, {Map<String, String> headers = const {}}) =>
Response.ok(
@ -193,9 +193,9 @@ class TelegramBotApiV2 {
if (userId != null && userId.isNotEmpty) {
// Get specific user info
final users = <UserModel>[];
final user = await backend_main.database.userDao.getUserById(userId);
final user = await _userRepository.getUserById(userId, includePacks: true);
if (user != null) {
users.add(await user.toUserModel());
users.add(user);
}
if (users.isEmpty) {
@ -224,10 +224,10 @@ class TelegramBotApiV2 {
});
} else {
// Get all users summary
final driftUsers = await backend_main.database.userDao.getAllUsers(
final users = await _userRepository.getAllUsers(
limit: 100,
includePacks: false,
);
final users = await Future.wait(driftUsers.map((u) => u.toUserModel()));
return _ok({
'total': users.length,

View file

@ -10,6 +10,7 @@ import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/statistics/statistics_calculator.dart';
import 'package:mnemo_cards_backend/user/user_manager.dart';
import 'package:mnemo_cards_backend/user/user_model.dart';
import 'package:mnemo_cards_backend/user/user_repository.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
@ -22,12 +23,14 @@ class UsersApiV2 {
final UserManager _userManager;
final PaymentManager _paymentManager;
final StatisticsCalculator _statisticsCalculator;
final UserRepository _userRepository;
final AppDatabase _db;
UsersApiV2(
this._userManager,
this._paymentManager,
this._statisticsCalculator,
this._userRepository,
this._db,
);
@ -142,7 +145,7 @@ class UsersApiV2 {
}, statusCode: 400);
}
await _db.userDao.updateUserPartial(
await _userRepository.updateUserPartial(
UsersCompanion(
id: drift.Value(user.id!),
name: name != null ? drift.Value(name) : const drift.Value.absent(),
@ -249,13 +252,13 @@ class UsersApiV2 {
return _json({'error': 'user_id_not_found'}, statusCode: 400);
}
final userData = await _db.userDao.getUserData(user.id!);
final userData = await _userRepository.getUserData(user.id!);
if (userData == null) {
return _json({'error': 'user_data_not_found'}, statusCode: 404);
}
// Convert UserData to UserDataDto
final userDataModel = await _db.userDao.getUserWithDataById(user.id!);
final userDataModel = await _userRepository.getUserWithDataById(user.id!);
if (userDataModel?.userData == null) {
return _json({'error': 'user_data_not_found'}, statusCode: 404);
}
@ -502,7 +505,7 @@ class UsersApiV2 {
}
await _db.transaction(() async {
final userData = await _db.userDao.getUserData(user.id!);
final userData = await _userRepository.getUserData(user.id!);
if (userData == null) {
throw StateError('User data not found');
}
@ -520,7 +523,7 @@ class UsersApiV2 {
final longestStreak = max(userData.longestStreak, currentStreak);
// Update user data (без studyDates - это поле удалено, рассчитывается на лету)
await _db.userDao.updateUserDataPartial(
await _userRepository.updateUserDataPartial(
UserDatasCompanion(
userId: drift.Value(user.id!),
totalStudyTimeMinutes: drift.Value(totalStudyTime),

View file

@ -1,14 +1,13 @@
import 'package:mnemo_cards_backend/cron/task.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/packs/free_packs_distributor.dart';
import 'package:mnemo_cards_backend/user/user_repository.dart';
import 'task.dart' as task;
class AddFreePacks with task.Task {
final FreePacksDistributor _freePacksDistributor;
final AppDatabase _db;
final UserRepository _userRepository;
AddFreePacks(this._freePacksDistributor, this._db);
AddFreePacks(this._freePacksDistributor, this._userRepository);
@override
String get name => 'add_free_packs';
@ -26,17 +25,18 @@ class AddFreePacks with task.Task {
final freePackIds = freePacks.map((p) => p.id).toSet();
// Получаем всех пользователей
final allUsers = await _db.userDao.getAllUsers();
final allUsers = await _userRepository.getAllUsers();
// Фильтруем пользователей, у которых нет хотя бы одного из freePacks
final usersToUpdate = <String>[];
for (final user in allUsers) {
final userPacks = await _db.userDao.getUserPacks(user.id);
final userPackIds = userPacks.map((p) => p.id).toSet();
if (user.id == null) continue;
final userPacks = await _userRepository.getUserPacks(user.id!);
final userPackIds = userPacks.map((p) => p.id).whereType<String>().toSet();
// Если у пользователя нет хотя бы одного из freePacks
if (!freePackIds.every((packId) => userPackIds.contains(packId))) {
usersToUpdate.add(user.id);
usersToUpdate.add(user.id!);
}
}
@ -48,9 +48,10 @@ class AddFreePacks with task.Task {
for (final userId in usersToUpdate) {
try {
for (final pack in freePacks) {
await _db.userDao.grantPackAccess(
if (pack.id == null) continue;
await _userRepository.grantPackAccess(
userId: userId,
packId: pack.id,
packId: pack.id!,
grantType: 'free',
);
}

View file

@ -1,6 +1,7 @@
import 'package:drift/drift.dart';
import 'package:drift_postgres/drift_postgres.dart';
import 'package:mnemo_cards_backend/user/admin_ids_service.dart';
import 'package:mnemo_cards_backend/user/user_repository.dart';
import '../database/database.dart';
import 'task.dart' as task;
@ -10,8 +11,9 @@ class CheckAdminsTask with task.Task {
String get name => 'check_admins';
final AppDatabase _db;
final UserRepository _userRepository;
CheckAdminsTask(this._db);
CheckAdminsTask(this._db, this._userRepository);
@override
Future<void> task() async {
@ -35,14 +37,14 @@ class CheckAdminsTask with task.Task {
}
// Получить пользователя по userId из токена
final user = await _db.userDao.getUserById(token.userId);
if (user != null) {
adminIds.add(user.id);
final user = await _userRepository.getUserById(token.userId);
if (user != null && user.id != null) {
adminIds.add(user.id!);
if (!user.admin) {
// Установить admin = true
await _db.userDao.updateUserPartial(
await _userRepository.updateUserPartial(
UsersCompanion(
id: Value(user.id),
id: Value(user.id!),
admin: const Value(true),
updatedAt: Value(PgDateTime(DateTime.now())),
),

View file

@ -2,9 +2,9 @@ import 'dart:developer';
import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/database/daos/discount_dao.dart';
import 'package:mnemo_cards_backend/database/daos/user_dao.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/discounts/discount_drift_extension.dart';
import 'package:mnemo_cards_backend/user/user_repository.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart' hide DateTimeExt;
import 'package:mnemo_cards_common/src/utils/utils.dart';
@ -13,11 +13,10 @@ import 'package:mnemo_cards_common/src/utils/utils.dart';
class DiscountsManager {
final AppDatabase _db;
final DiscountDao _discountDao;
final UserDao _userDao;
final UserRepository _userRepository;
DiscountsManager(this._db)
: _discountDao = _db.discountDao,
_userDao = _db.userDao;
DiscountsManager(this._db, this._userRepository)
: _discountDao = _db.discountDao;
Future<void> applyDiscount({
required Iterable<DiscountModel> discounts,
@ -103,10 +102,10 @@ class DiscountsManager {
}
// Получаем активные кампании
final user = await _userDao.getUserById(userData.userId);
final user = await _userRepository.getUserById(userData.userId);
if (user == null) return maxDiscount;
final userDataModel = await _userDao.getUserData(userData.userId);
final userDataModel = await _userRepository.getUserData(userData.userId);
final userTags = userDataModel?.tags ?? [];
final activeCampaigns = await _discountDao.getActiveCampaignsForUser(

View file

@ -9,7 +9,7 @@ import 'package:mnemo_cards_common/mnemo_cards_common.dart';
extension CardPackToDto on CardPack {
Future<CardPackPreviewDto> toPreviewDto(UserModel? user) async {
final hasAccess =
user != null && (user.purchases.contains(id.toString()) || user.admin);
user != null && (user.packs.contains(id.toString()) || user.admin);
// Generate cover image URL
String? coverUrl;
@ -64,6 +64,30 @@ extension CardPackToDto on CardPack {
}
}
extension CardPackToModel on CardPack {
Future<CardPackModel> toModel() async {
return CardPackModel(
id: id,
title: title,
subtitle: subtitle,
size: size,
color: color,
version: version,
cover: cover,
description: description,
googlePlayId: googlePlayId,
rustoreId: rustoreId,
appStoreId: appStoreId,
price: price,
currency: currency,
enabled: enabled,
order: order,
cardsOrder: cardsOrder,
);
}
}
/// Extension для конвертации GameCard (Drift) в DTO
extension GameCardToDto on GameCard {
Future<GameCardDto> toDto(List<VoiceModel> voices) async {

View file

@ -1,18 +1,25 @@
import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/user/user_repository.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'pack_repository.dart';
@lazySingleton
class FreePacksDistributor {
final AppDatabase _db;
final UserRepository _userRepository;
final PackRepository _packRepository;
FreePacksDistributor(this._db);
FreePacksDistributor(this._userRepository, this._packRepository);
Future<List<CardPack>> getFreePacks() async {
final allPacks = await _db.packDao.getAllPacks(enabledOnly: true);
Future<List<CardPackModel>> getFreePacks() async {
final allPacks = await _packRepository.getAllPacks(enabledOnly: true);
// Фильтруем паки с нулевой ценой или без цены
return allPacks
.where((pack) => pack.price == null || pack.price == 0)
.where((pack) {
final priceStr = pack.price;
if (priceStr == null || priceStr.isEmpty) return true;
final price = double.tryParse(priceStr);
return price == null || price == 0;
})
.toList();
}
@ -22,13 +29,14 @@ class FreePacksDistributor {
await givePacksToUser(user, packs);
}
Future<void> givePacksToUser(UserModel user, List<CardPack> packs) async {
Future<void> givePacksToUser(UserModel user, List<CardPackModel> packs) async {
if (user.id == null) return;
for (final pack in packs) {
await _db.userDao.grantPackAccess(
if (pack.id == null) continue;
await _userRepository.grantPackAccess(
userId: user.id!,
packId: pack.id,
packId: pack.id!,
grantType: 'free',
);
}

View file

@ -20,7 +20,11 @@ class PackDtoConverter {
CardPackModel model,
UserModel? userModel,
) async {
bool available = model.users.contains(userModel);
// Check access via purchases list (source of truth is user_packs table in DB)
// user.purchases is a cached list of pack IDs the user has access to
bool available = userModel != null &&
(userModel.purchases.contains(model.id?.toString()) ||
userModel.admin);
if (userModel != null && !available) {
available =
userModel.subscriptionModel?.features.contains(
@ -214,6 +218,7 @@ class PackDtoConverter {
enabled: dto.enabled ?? model?.enabled ?? false,
order: dto.order ?? model?.order ?? 0,
cardsOrder: dto.cardsOrder ?? model?.cardsOrder ?? const [],
currency: model?.currency ?? 'RUB',
);
}
}

View file

@ -8,24 +8,37 @@ import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'pack_dto_converter.dart';
import 'card_pack_drift_extension.dart';
import 'pack_repository.dart';
@lazySingleton
class PackManager {
final AppDatabase _db;
final PackRepository _packRepository;
final PackDtoConverter packDtoConverter;
PackManager(this._db, this.packDtoConverter);
PackManager(this._db, this._packRepository, this.packDtoConverter);
Future<List<CardPackPreviewDto>> listPacksPreviews(
UserModel? userModel,
Map<String, String>? params,
) async {
// Get packs from Drift database
final packs = await _db.packDao.getAllPacks(
// Get packs from Repository
final packModels = await _packRepository.getAllPacks(
enabledOnly: true,
orderByField: 'order',
);
// Convert to Drift models for toPreviewDto (temporary, until we refactor DTO conversion)
final packs = await Future.wait(
packModels.map((packModel) async {
final pack = await _db.packDao.getPackById(packModel.id!);
if (pack == null) {
throw StateError('Pack not found: ${packModel.id}');
}
return pack;
}),
);
return (await Future.wait(
packs.map((pack) async {
final dto = await pack.toPreviewDto(userModel);
@ -37,16 +50,16 @@ class PackManager {
)).toList();
}
Future<CardPack?> getPack(String id) async {
return await _db.packDao.getPackById(id);
Future<CardPackModel?> getPack(String id) async {
return await _packRepository.getPackById(id);
}
Future<List<GameCard>> getCards(String packId) async {
return await _db.packDao.getPackCards(packId);
Future<List<GameCardModel>> getCards(String packId) async {
return await _packRepository.getPackCards(packId);
}
Future<GameCard?> getCard(String id) async {
return await _db.packDao.getCardById(id);
Future<GameCardModel?> getCard(String id) async {
return await _packRepository.getCardById(id);
}
Future<VoiceModel?> getVoice(String id) async {
@ -58,12 +71,18 @@ class PackManager {
}
Future<CardPackDto> getPackDto(String id, UserModel? userModel) async {
final pack = await getPack(id);
final packModel = await getPack(id);
if (packModel == null) {
throw StateError('Pack not found');
}
// Get pack from DAO for DTO conversion (temporary, until we refactor)
final pack = await _db.packDao.getPackById(id);
if (pack == null) {
throw StateError('Pack not found');
}
final cards = await getCards(id);
final cards = await _db.packDao.getPackCards(id);
final voices = <VoiceModel>[];
for (final card in cards) {

View file

@ -0,0 +1,238 @@
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_common_backend/mnemo_cards_common_backend.dart'
hide VoiceModel;
import 'card_pack_drift_extension.dart';
/// Extension для конвертации GameCard (Drift) в GameCardModel
extension GameCardToModel on GameCard {
GameCardModel toModel() {
return GameCardModel(
id: id,
image: image,
mnemo: mnemo ?? '',
original: original,
translation: translation,
transcription: transcription,
transcriptionMnemo: transcriptionMnemo,
imageBack: imageBack,
back: back,
);
}
}
/// Repository для работы с паками
/// Работает с доменными моделями (CardPackModel), использует DAO для доступа к БД
@lazySingleton
class PackRepository {
final AppDatabase _db;
PackRepository(this._db);
/// Получить пак по ID
/// [includeCards] - загружать ли связанные карточки
/// [includePreviewCards] - загружать ли превью карточки
Future<CardPackModel?> 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 pack.toModel();
if (includeCards) {
final cards = await _db.packDao.getPackCards(id);
packModel.cards.addAll(cards.map((c) => c.toModel()));
}
if (includePreviewCards) {
final previewCards = await _db.packDao.getPreviewCards(id);
packModel.previewCards.addAll(previewCards.map((c) => c.toModel()));
}
return packModel;
}
/// Получить все паки
Future<List<CardPackModel>> 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 = <CardPackModel>[];
for (final pack in packs) {
final packModel = await pack.toModel();
if (includeCards) {
final cards = await _db.packDao.getPackCards(pack.id);
packModel.cards.addAll(cards.map((c) => c.toModel()));
}
if (includePreviewCards) {
final previewCards = await _db.packDao.getPreviewCards(pack.id);
packModel.previewCards.addAll(previewCards.map((c) => c.toModel()));
}
packModels.add(packModel);
}
return packModels;
}
return await Future.wait(packs.map((p) => p.toModel()));
}
/// Создать пак
Future<String> 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<void> updatePack(CardPackModel packModel) async {
if (packModel.id == null) {
throw ArgumentError('Pack ID is required');
}
await _db.packDao.updatePackPartial(
CardPacksCompanion(
id: Value(packModel.id!),
title: Value(packModel.title),
subtitle: Value(packModel.subtitle),
size: Value(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),
updatedAt: Value(PgDateTime(DateTime.now())),
),
);
}
/// Обновить пак частично
Future<void> updatePackPartial(CardPacksCompanion updates) async {
await _db.packDao.updatePackPartial(updates);
}
/// Удалить пак (soft delete)
Future<void> softDeletePack(String packId) async {
await _db.packDao.softDeletePack(packId);
}
/// Подсчитать паки
Future<int> countPacks({bool enabledOnly = false}) async {
return await _db.packDao.countPacks(enabledOnly: enabledOnly);
}
/// Получить карточки пака
Future<List<GameCardModel>> getPackCards(String packId) async {
final cards = await _db.packDao.getPackCards(packId);
return cards.map((c) => c.toModel()).toList();
}
/// Получить превью карточки пака
Future<List<GameCardModel>> getPreviewCards(String packId) async {
final cards = await _db.packDao.getPreviewCards(packId);
return cards.map((c) => c.toModel()).toList();
}
/// Получить карточку по ID
Future<GameCardModel?> getCardById(String id) async {
final card = await _db.packDao.getCardById(id);
return card?.toModel();
}
/// Получить паки для карточки
Future<List<CardPackModel>> getPacksForCard(String cardId) async {
final packs = await _db.packDao.getPacksForCard(cardId);
return await Future.wait(packs.map((p) => p.toModel()));
}
/// Добавить карточку в пак
Future<void> addCardToPack({
required String packId,
required String cardId,
int order = 0,
}) async {
await _db.packDao.addCardToPack(
packId: packId,
cardId: cardId,
order: order,
);
}
/// Удалить карточку из пака
Future<void> removeCardFromPack(String packId, String cardId) async {
await _db.packDao.removeCardFromPack(packId, cardId);
}
/// Обновить порядок карточек в паке
Future<void> updatePackCardsOrder(String packId, List<String> cardIds) async {
await _db.packDao.updatePackCardsOrder(packId, cardIds);
}
/// Установить preview карточки для пака
Future<void> setPreviewCards(String packId, List<String> cardIds) async {
await _db.packDao.setPreviewCards(packId, cardIds);
}
// ==================== VoiceModels ====================
/// Получить голосовые модели карточки
Future<List<VoiceModel>> getCardVoices(String cardId) async {
return await _db.packDao.getCardVoices(cardId);
}
/// Получить голосовую модель по ID
Future<VoiceModel?> getVoiceById(String id) async {
return await _db.packDao.getVoiceById(id);
}
/// Обновить путь/URL аудиофайла голосовой модели
Future<void> updateVoiceUrl(String voiceId, String voiceUrl) async {
await _db.packDao.updateVoiceUrl(voiceId, voiceUrl);
}
}

View file

@ -1,14 +1,14 @@
import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/discounts/discounts_manager.dart';
import 'package:mnemo_cards_backend/user/user_repository.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
@LazySingleton()
class ProductsPriceResolver {
final DiscountsManager _discountsManager;
final AppDatabase _db;
final UserRepository _userRepository;
ProductsPriceResolver(this._discountsManager, this._db);
ProductsPriceResolver(this._discountsManager, this._userRepository);
Future<String> _userProductPrice(
UserModel userModel,
@ -20,7 +20,7 @@ class ProductsPriceResolver {
return price;
}
if (userModel.id != null) {
final userData = await _db.userDao.getUserData(userModel.id!);
final userData = await _userRepository.getUserData(userModel.id!);
if (userData != null) {
final maxDiscount = await _discountsManager.getProductDiscount(
product,

View file

@ -2,14 +2,16 @@ 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/user/user_repository.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
@lazySingleton
class AchievementManager {
final AppDatabase _db;
final UserRepository _userRepository;
AchievementManager(this._db);
AchievementManager(this._db, this._userRepository);
/// Get all available achievements with their definitions
List<AchievementDto> get allAchievementDefinitions =>
@ -146,7 +148,7 @@ class AchievementManager {
// return userData.totalTests > 0;
case AchievementType.firstPackCompleted:
final userPacks = await _db.userDao.getUserPacks(userId);
final userPacks = await _userRepository.getUserPacks(userId);
return userPacks.isNotEmpty;
// case AchievementType.words10Learned:

View file

@ -1,6 +1,7 @@
import 'dart:math';
import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/user/user_repository.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_backend/database/database.dart';
@ -9,8 +10,9 @@ import 'package:mnemo_cards_backend/database/database.dart';
@lazySingleton
class StatisticsCalculator {
final AppDatabase _db;
final UserRepository _userRepository;
StatisticsCalculator(this._db);
StatisticsCalculator(this._db, this._userRepository);
/// Calculate pack progress for a specific user and pack
///
@ -104,12 +106,13 @@ class StatisticsCalculator {
/// Calculate pack progress for all user packs
Future<List<PackProgressDto>> calculateAllPackProgress(String userId) async {
// Получить все паки пользователя
final userPacks = await _db.userDao.getUserPacks(userId);
final userPacks = await _userRepository.getUserPacks(userId);
// Рассчитать прогресс для каждого пака
final progressList = <PackProgressDto>[];
for (final pack in userPacks) {
final progress = await calculatePackProgress(userId, pack.id);
if (pack.id == null) continue;
final progress = await calculatePackProgress(userId, pack.id!);
progressList.add(progress);
}

View file

@ -1,10 +1,14 @@
import 'package:drift/drift.dart' as drift;
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/packs/card_pack_drift_extension.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
/// Extension для конвертации User (Drift) в UserModel
extension UserToUserModel on User {
Future<UserModel> toUserModel() async {
Future<UserModel> toUserModel([List<CardPack>? packs]) async {
final packModels = packs != null
? await Future.wait(packs.map((p) => p.toModel()))
: <CardPackModel>[];
final userModel = UserModel(
id: id,
name: name,
@ -13,6 +17,7 @@ extension UserToUserModel on User {
admin: admin,
purchases: purchases,
userSettings: userSettings,
packs: packModels,
);
return userModel;

View file

@ -15,13 +15,14 @@ import '../statistics/statistics_calculator.dart';
import '../statistics/achievement_manager.dart';
import '../statistics/word_statistics_manager.dart';
import 'secure.dart';
import 'user_drift_extension.dart';
import 'user_repository.dart';
Map<String?, DateTime> _onlineUsers = {};
@lazySingleton
class UserManager {
final AppDatabase _db;
final UserRepository _userRepository;
final FreePacksDistributor _freePacksDistributor;
final SessionTracker _sessionTracker;
final StatisticsCalculator _statisticsCalculator;
@ -30,6 +31,7 @@ class UserManager {
UserManager(
this._db,
this._userRepository,
this._freePacksDistributor,
this._sessionTracker,
this._statisticsCalculator,
@ -38,9 +40,7 @@ class UserManager {
);
Future<UserModel?> fetchUser(String id) async {
final user = await _db.userDao.getUserById(id);
if (user == null) return null;
return await user.toUserModel();
return await _userRepository.getUserById(id, includePacks: true);
}
DateTime? lastOnline(String id) => _onlineUsers[id];
@ -119,77 +119,68 @@ class UserManager {
String? name,
}) async {
// Check if user exists by externalId
final existingUser = await _db.userDao.getUserByExternalId(externalId);
if (existingUser != null) {
var existingUserModel = await _userRepository.getUserByExternalId(externalId);
if (existingUserModel != null) {
print('User found $name $email $telegram');
// Обновляем контактные данные, если они пришли впервые/изменились
final shouldUpdateEmail =
email != null && email.isNotEmpty && existingUser.email != email;
email != null && email.isNotEmpty && existingUserModel.email != email;
final shouldUpdateTelegram =
telegram != null &&
telegram.isNotEmpty &&
existingUser.telegram != telegram;
existingUserModel.telegram != telegram;
if (shouldUpdateEmail || shouldUpdateTelegram) {
await _db.userDao.updateUserPartial(
UsersCompanion(
id: drift.Value(existingUser.id),
email: shouldUpdateEmail
? drift.Value(email)
: const drift.Value.absent(),
telegram: shouldUpdateTelegram
? drift.Value(telegram)
: const drift.Value.absent(),
updatedAt: drift.Value(PgDateTime(DateTime.now())),
),
final updatedModel = existingUserModel.copyWith(
email: shouldUpdateEmail ? email : existingUserModel.email,
telegram: shouldUpdateTelegram ? telegram : existingUserModel.telegram,
);
await _userRepository.updateUser(updatedModel);
existingUserModel = updatedModel;
}
final userModel = await existingUser.toUserModel();
final token = await createOrGetAuthToken(userModel, externalId);
return (userModel, token);
final token = await createOrGetAuthToken(existingUserModel, externalId);
return (existingUserModel, token);
}
print('Creating new user $name $email $telegram');
// Create new user with user data in transaction
final now = DateTime.now();
final userCompanion = UsersCompanion.insert(
externalUserId: externalId,
name: drift.Value(name),
email: drift.Value(email),
telegram: drift.Value(telegram),
admin: drift.Value(false),
purchases: drift.Value([]),
createdAt: drift.Value(PgDateTime(now)),
updatedAt: drift.Value(PgDateTime(now)),
isDeleted: drift.Value(false),
);
final userDataCompanion = UserDatasCompanion.insert(
userId: '', // Will be set by createUserWithData via copyWith
registrationDate: drift.Value(PgDateTime(now)),
);
final userId = await _db.userDao.createUserWithData(
user: userCompanion,
// Создаем пользователя через UserRepository
final newUserModel = UserModel(
id: null,
name: name,
email: email,
telegram: telegram,
admin: false,
purchases: [],
userSettings: null,
packs: [],
);
final userId = await _userRepository.createUserWithData(
userModel: newUserModel,
userData: userDataCompanion,
);
final user = await _db.userDao.getUserById(userId);
if (user == null) {
final createdUserModel = await _userRepository.getUserById(userId, includePacks: true);
if (createdUserModel == null) {
throw Exception('Failed to create user');
}
final userModel = await user.toUserModel();
final token = await createOrGetAuthToken(userModel, externalId);
final token = await createOrGetAuthToken(createdUserModel, externalId);
// Give free packs to new user
await _freePacksDistributor.giveFreePacksToUser(userModel);
await _freePacksDistributor.giveFreePacksToUser(createdUserModel);
print('User $name $email $telegram created successfully');
return (userModel, token);
return (createdUserModel, token);
}
/// Обновить настройки пользователя
@ -201,13 +192,10 @@ class UserManager {
throw Exception('User ID is required');
}
await _db.userDao.updateUserPartial(
UsersCompanion(
id: drift.Value(user.id!),
userSettings: drift.Value(jsonEncode(settings.toJson())),
updatedAt: drift.Value(PgDateTime(DateTime.now())),
),
final updatedUser = user.copyWith(
userSettings: jsonEncode(settings.toJson()),
);
await _userRepository.updateUser(updatedUser);
}
/// Добавить статистику теста
@ -220,12 +208,12 @@ class UserManager {
}
// Получить или создать UserData
var userData = await _db.userDao.getUserData(user.id!);
var userData = await _userRepository.getUserData(user.id!);
if (userData == null) {
await _db.userDao.createUserData(
UserDatasCompanion.insert(userId: user.id!),
);
userData = await _db.userDao.getUserData(user.id!);
userData = await _userRepository.getUserData(user.id!);
if (userData == null) {
throw Exception('Failed to create user data');
}

View file

@ -33,7 +33,8 @@ class UserManager {
Future<UserModel?> fetchUser(String id) async {
final user = await _db.userDao.getUserById(id);
if (user == null) return null;
return await user.toUserModel();
final packs = await _db.userDao.getUserPacks(user.id);
return await user.toUserModel(packs);
}
DateTime? lastOnline(String id) => _onlineUsers[id];

View file

@ -0,0 +1,181 @@
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/packs/card_pack_drift_extension.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'user_drift_extension.dart';
/// Repository для работы с пользователями
/// Работает с доменными моделями (UserModel), использует DAO для доступа к БД
@lazySingleton
class UserRepository {
final AppDatabase _db;
UserRepository(this._db);
/// Получить пользователя по ID
/// [includePacks] - загружать ли связанные паки пользователя
Future<UserModel?> 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 user.toUserModel(packs);
}
return await user.toUserModel();
}
/// Получить пользователя по email
Future<UserModel?> getUserByEmail(String email) async {
final user = await _db.userDao.getUserByEmail(email);
if (user == null) return null;
return await user.toUserModel();
}
/// Получить пользователя по externalUserId
Future<UserModel?> getUserByExternalId(String externalId) async {
final user = await _db.userDao.getUserByExternalId(externalId);
if (user == null) return null;
return await user.toUserModel();
}
/// Получить пользователя с UserData
Future<UserModel?> getUserWithDataById(String id) async {
final userWithData = await _db.userDao.getUserWithDataById(id);
if (userWithData == null) return null;
final packs = await _db.userDao.getUserPacks(id);
final userModel = await userWithData.user.toUserModel(packs);
// TODO: загрузить UserDataModel если нужно
// userModel.userData = userWithData.userData?.toUserDataModel();
return userModel;
}
/// Создать пользователя
Future<String> createUser(UserModel userModel) async {
final userCompanion = userModel.toUsersCompanion();
return await _db.userDao.createUser(userCompanion);
}
/// Создать пользователя с UserData
Future<String> createUserWithData({
required UserModel userModel,
required UserDatasCompanion userData,
}) async {
final userCompanion = userModel.toUsersCompanion();
return await _db.userDao.createUserWithData(
user: userCompanion,
userData: userData,
);
}
/// Обновить пользователя
Future<void> updateUser(UserModel userModel) async {
if (userModel.id == null) {
throw ArgumentError('User ID is required');
}
await _db.userDao.updateUserPartial(
userModel.toUsersCompanion().copyWith(
updatedAt: Value(PgDateTime(DateTime.now())),
),
);
}
/// Обновить пользователя частично
Future<void> updateUserPartial(UsersCompanion updates) async {
await _db.userDao.updateUserPartial(updates);
}
/// Удалить пользователя (soft delete)
Future<void> softDeleteUser(String userId) async {
await _db.userDao.softDeleteUser(userId);
}
/// Получить всех пользователей (для админки)
Future<List<UserModel>> 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 = <UserModel>[];
for (final user in users) {
final packs = await _db.userDao.getUserPacks(user.id);
final userModel = await user.toUserModel(packs);
userModels.add(userModel);
}
return userModels;
}
return await Future.wait(users.map((u) => u.toUserModel()));
}
/// Подсчитать пользователей
Future<int> countUsers({bool includeDeleted = false}) async {
return await _db.userDao.countUsers(includeDeleted: includeDeleted);
}
/// Получить паки пользователя
Future<List<CardPackModel>> getUserPacks(String userId) async {
final packs = await _db.userDao.getUserPacks(userId);
return await Future.wait(packs.map((p) => p.toModel()));
}
/// Проверить, есть ли у пользователя доступ к паку
Future<bool> hasPackAccess(String userId, String packId) async {
return await _db.userDao.hasPackAccess(userId, packId);
}
/// Дать пользователю доступ к паку
Future<void> grantPackAccess({
required String userId,
required String packId,
String grantType = 'purchase',
}) async {
await _db.userDao.grantPackAccess(
userId: userId,
packId: packId,
grantType: grantType,
);
}
/// Отозвать доступ к паку
Future<void> revokePackAccess(String userId, String packId) async {
await _db.userDao.revokePackAccess(userId, packId);
}
/// Получить UserData пользователя
Future<UserData?> getUserData(String userId) async {
return await _db.userDao.getUserData(userId);
}
/// Обновить UserData частично
Future<void> updateUserDataPartial(UserDatasCompanion updates) async {
await _db.userDao.updateUserDataPartial(updates);
}
/// Обновить время последнего визита
Future<void> updateLastOnline(String userId) async {
await _db.userDao.updateLastOnline(userId);
}
}

View file

@ -166,30 +166,30 @@ class MockPackManager extends _i1.Mock implements _i8.PackManager {
as _i5.Future<List<_i3.CardPackPreviewDto>>);
@override
_i5.Future<_i10.CardPack?> getPack(String? id) =>
_i5.Future<_i9.CardPackModel?> getPack(String? id) =>
(super.noSuchMethod(
Invocation.method(#getPack, [id]),
returnValue: _i5.Future<_i10.CardPack?>.value(),
returnValue: _i5.Future<_i9.CardPackModel?>.value(),
)
as _i5.Future<_i10.CardPack?>);
as _i5.Future<_i9.CardPackModel?>);
@override
_i5.Future<List<_i10.GameCard>> getCards(String? packId) =>
_i5.Future<List<_i9.GameCardModel>> getCards(String? packId) =>
(super.noSuchMethod(
Invocation.method(#getCards, [packId]),
returnValue: _i5.Future<List<_i10.GameCard>>.value(
<_i10.GameCard>[],
returnValue: _i5.Future<List<_i9.GameCardModel>>.value(
<_i9.GameCardModel>[],
),
)
as _i5.Future<List<_i10.GameCard>>);
as _i5.Future<List<_i9.GameCardModel>>);
@override
_i5.Future<_i10.GameCard?> getCard(String? id) =>
_i5.Future<_i9.GameCardModel?> getCard(String? id) =>
(super.noSuchMethod(
Invocation.method(#getCard, [id]),
returnValue: _i5.Future<_i10.GameCard?>.value(),
returnValue: _i5.Future<_i9.GameCardModel?>.value(),
)
as _i5.Future<_i10.GameCard?>);
as _i5.Future<_i9.GameCardModel?>);
@override
_i5.Future<_i10.VoiceModel?> getVoice(String? id) =>

View file

@ -2,13 +2,11 @@ import 'package:json_annotation/json_annotation.dart';
import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:mnemo_cards_common_backend/src/models/game_card_model.dart';
import 'package:mnemo_cards_common_backend/src/models/game_tests/test_model.dart';
import 'package:mnemo_cards_common_backend/src/models/user/user_model.dart';
import 'product_model.dart';
part 'card_pack_model.g.dart';
@JsonSerializable()
@CopyWith()
class CardPackModel implements MnemoCardsProductModel {
String? id;
@ -28,37 +26,30 @@ class CardPackModel implements MnemoCardsProductModel {
final bool enabled;
final int order;
final List<String> cardsOrder;
// Relations - loaded separately from database
// Relations - loaded separately from database (when needed)
final List<GameCardModel> previewCards = [];
final List<GameCardModel> cards = [];
final List<TestModel> tests = [];
final List<UserModel> users = [];
CardPackModel({
required this.title,
required this.subtitle,
required this.size,
this.color,
this.cover,
this.description,
this.version,
this.id,
this.googlePlayId,
this.rustoreId,
this.appStoreId,
this.price,
this.cardsOrder = const [],
this.enabled = true,
this.order = 0,
this.currency = 'RUB',
required this.color,
required this.cover,
required this.description,
required this.version,
required this.id,
required this.googlePlayId,
required this.rustoreId,
required this.appStoreId,
required this.price,
required this.cardsOrder,
required this.enabled,
required this.order,
required this.currency,
});
factory CardPackModel.fromJson(Map<String, dynamic> json) =>
_$CardPackModelFromJson(json);
Map<String, Object?> toJson() => _$CardPackModelToJson(this);
@override
@JsonKey(includeFromJson: false, includeToJson: false)
final type = MnemoCardsProductModelType.pack;
}

View file

@ -226,51 +226,3 @@ extension $CardPackModelCopyWith on CardPackModel {
// ignore: library_private_types_in_public_api
_$CardPackModelCWProxy get copyWith => _$CardPackModelCWProxyImpl(this);
}
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
CardPackModel _$CardPackModelFromJson(Map<String, dynamic> json) =>
CardPackModel(
title: json['title'] as String,
subtitle: json['subtitle'] as String,
size: (json['size'] as num).toInt(),
color: json['color'] as String?,
cover: json['cover'] as String?,
description: json['description'] as String?,
version: json['version'] as String?,
id: json['id'] as String?,
googlePlayId: json['googlePlayId'] as String?,
rustoreId: json['rustoreId'] as String?,
appStoreId: json['appStoreId'] as String?,
price: json['price'] as String?,
cardsOrder:
(json['cardsOrder'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
const [],
enabled: json['enabled'] as bool? ?? true,
order: (json['order'] as num?)?.toInt() ?? 0,
currency: json['currency'] as String? ?? 'RUB',
);
Map<String, dynamic> _$CardPackModelToJson(CardPackModel instance) =>
<String, dynamic>{
'id': instance.id,
'title': instance.title,
'subtitle': instance.subtitle,
'color': instance.color,
'cover': instance.cover,
'size': instance.size,
'version': instance.version,
'googlePlayId': instance.googlePlayId,
'rustoreId': instance.rustoreId,
'appStoreId': instance.appStoreId,
'price': instance.price,
'currency': instance.currency,
'description': instance.description,
'enabled': instance.enabled,
'order': instance.order,
'cardsOrder': instance.cardsOrder,
};

View file

@ -7,13 +7,12 @@ import '../statistics/word_statistics_model.dart';
part 'test_statistics_model.g.dart';
@JsonSerializable()
@CopyWith()
class TestStatisticsModel {
final String id;
// Relations - loaded separately from database
TestModel? test;
UserDataModel? userData;
String? testId;
String? userId;
final List<TestAttempt> attempts;
TestStatisticsModel({
@ -24,13 +23,8 @@ class TestStatisticsModel {
@JsonKey(includeFromJson: false, includeToJson: false)
TestAttempt? get lastAttempt => attempts.lastOrNull;
factory TestStatisticsModel.fromJson(Map<String, dynamic> json) =>
_$TestStatisticsModelFromJson(json);
Map<String, Object?> toJson() => _$TestStatisticsModelToJson(this);
}
@JsonSerializable()
@CopyWith()
class TestAttempt {
final List<WordStatisticsModel> words;
@ -41,8 +35,4 @@ class TestAttempt {
this.sessionToken,
});
factory TestAttempt.fromJson(Map<String, dynamic> json) =>
_$TestAttemptFromJson(json);
Map<String, Object?> toJson() => _$TestAttemptToJson(this);
}

View file

@ -128,47 +128,3 @@ extension $TestAttemptCopyWith on TestAttempt {
// ignore: library_private_types_in_public_api
_$TestAttemptCWProxy get copyWith => _$TestAttemptCWProxyImpl(this);
}
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
TestStatisticsModel _$TestStatisticsModelFromJson(Map<String, dynamic> json) =>
TestStatisticsModel(
attempts:
(json['attempts'] as List<dynamic>?)
?.map((e) => TestAttempt.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
id: json['id'] as String,
)
..test = json['test'] == null
? null
: TestModel.fromJson(json['test'] as Map<String, dynamic>)
..userData = json['userData'] == null
? null
: UserDataModel.fromJson(json['userData'] as Map<String, dynamic>);
Map<String, dynamic> _$TestStatisticsModelToJson(
TestStatisticsModel instance,
) => <String, dynamic>{
'id': instance.id,
'test': instance.test,
'userData': instance.userData,
'attempts': instance.attempts,
};
TestAttempt _$TestAttemptFromJson(Map<String, dynamic> json) => TestAttempt(
words:
(json['words'] as List<dynamic>?)
?.map((e) => WordStatisticsModel.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
sessionToken: json['sessionToken'] as String?,
);
Map<String, dynamic> _$TestAttemptToJson(TestAttempt instance) =>
<String, dynamic>{
'words': instance.words,
'sessionToken': instance.sessionToken,
};

View file

@ -3,18 +3,14 @@ import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'product_model.dart';
import 'user/user_model.dart';
part 'payment.g.dart';
@JsonSerializable()
@CopyWith()
class PaymentModel {
String? id;
final String amount;
final String currency;
// Relations - loaded separately from database
UserModel? user;
@JsonKey(unknownEnumValue: PaymentStatus.unknown)
final PaymentStatus status;
@JsonKey(unknownEnumValue: PaymentSystem.unknown)
@ -24,10 +20,6 @@ class PaymentModel {
final DateTime date;
final String userId;
@deprecated
final List<String> packs;
@deprecated
final bool subscription;
final List<MnemoCardsProductModelBase> products;
PaymentModel({
@ -38,8 +30,6 @@ class PaymentModel {
required this.userId,
required this.date,
required this.products,
this.packs = const [],
this.subscription = false,
this.externalToken,
this.meta,
this.id,
@ -97,8 +87,6 @@ class PaymentModel {
required this.externalToken,
required this.date,
required this.products,
this.packs = const [],
this.subscription = false,
this.meta,
this.id,
}) : paymentSystem = PaymentSystem.yookassa,
@ -111,8 +99,6 @@ class PaymentModel {
required this.externalToken,
required this.date,
required this.products,
this.packs = const [],
this.subscription = false,
this.meta,
this.id,
this.status = PaymentStatus.created,
@ -125,15 +111,9 @@ class PaymentModel {
required this.externalToken,
required this.date,
required this.products,
this.packs = const [],
this.subscription = false,
this.meta,
this.id,
this.status = PaymentStatus.created,
}) : paymentSystem = PaymentSystem.rustore;
factory PaymentModel.fromJson(Map<String, dynamic> json) =>
_$PaymentModelFromJson(json);
Map<String, Object?> toJson() => _$PaymentModelToJson(this);
}

View file

@ -21,10 +21,6 @@ abstract class _$PaymentModelCWProxy {
PaymentModel products(List<MnemoCardsProductModelBase> products);
PaymentModel packs(@deprecated List<String> packs);
PaymentModel subscription(@deprecated bool subscription);
PaymentModel externalToken(String? externalToken);
PaymentModel meta(String? meta);
@ -46,8 +42,6 @@ abstract class _$PaymentModelCWProxy {
String userId,
DateTime date,
List<MnemoCardsProductModelBase> products,
@deprecated List<String> packs,
@deprecated bool subscription,
String? externalToken,
String? meta,
String? id,
@ -84,13 +78,6 @@ class _$PaymentModelCWProxyImpl implements _$PaymentModelCWProxy {
PaymentModel products(List<MnemoCardsProductModelBase> products) =>
call(products: products);
@override
PaymentModel packs(@deprecated List<String> packs) => call(packs: packs);
@override
PaymentModel subscription(@deprecated bool subscription) =>
call(subscription: subscription);
@override
PaymentModel externalToken(String? externalToken) =>
call(externalToken: externalToken);
@ -117,8 +104,6 @@ class _$PaymentModelCWProxyImpl implements _$PaymentModelCWProxy {
Object? userId = const $CopyWithPlaceholder(),
Object? date = const $CopyWithPlaceholder(),
Object? products = const $CopyWithPlaceholder(),
@deprecated Object? packs = const $CopyWithPlaceholder(),
@deprecated Object? subscription = const $CopyWithPlaceholder(),
Object? externalToken = const $CopyWithPlaceholder(),
Object? meta = const $CopyWithPlaceholder(),
Object? id = const $CopyWithPlaceholder(),
@ -153,15 +138,6 @@ class _$PaymentModelCWProxyImpl implements _$PaymentModelCWProxy {
? _value.products
// ignore: cast_nullable_to_non_nullable
: products as List<MnemoCardsProductModelBase>,
packs: packs == const $CopyWithPlaceholder() || packs == null
? _value.packs
// ignore: cast_nullable_to_non_nullable
: packs as List<String>,
subscription:
subscription == const $CopyWithPlaceholder() || subscription == null
? _value.subscription
// ignore: cast_nullable_to_non_nullable
: subscription as bool,
externalToken: externalToken == const $CopyWithPlaceholder()
? _value.externalToken
// ignore: cast_nullable_to_non_nullable
@ -184,80 +160,3 @@ extension $PaymentModelCopyWith on PaymentModel {
// ignore: library_private_types_in_public_api
_$PaymentModelCWProxy get copyWith => _$PaymentModelCWProxyImpl(this);
}
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
PaymentModel _$PaymentModelFromJson(Map<String, dynamic> json) =>
PaymentModel(
amount: json['amount'] as String,
currency: json['currency'] as String,
status: $enumDecode(
_$PaymentStatusEnumMap,
json['status'],
unknownValue: PaymentStatus.unknown,
),
paymentSystem: $enumDecode(
_$PaymentSystemEnumMap,
json['paymentSystem'],
unknownValue: PaymentSystem.unknown,
),
userId: json['userId'] as String,
date: DateTime.parse(json['date'] as String),
products: (json['products'] as List<dynamic>)
.map(
(e) => MnemoCardsProductModelBase.fromJson(
e as Map<String, dynamic>,
),
)
.toList(),
packs:
(json['packs'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
const [],
subscription: json['subscription'] as bool? ?? false,
externalToken: json['externalToken'] as String?,
meta: json['meta'] as String?,
id: json['id'] as String?,
)
..user = json['user'] == null
? null
: UserModel.fromJson(json['user'] as Map<String, dynamic>);
Map<String, dynamic> _$PaymentModelToJson(PaymentModel instance) =>
<String, dynamic>{
'id': instance.id,
'amount': instance.amount,
'currency': instance.currency,
'user': instance.user,
'status': _$PaymentStatusEnumMap[instance.status]!,
'paymentSystem': _$PaymentSystemEnumMap[instance.paymentSystem]!,
'externalToken': instance.externalToken,
'meta': instance.meta,
'date': instance.date.toIso8601String(),
'userId': instance.userId,
'packs': instance.packs,
'subscription': instance.subscription,
'products': instance.products,
};
const _$PaymentStatusEnumMap = {
PaymentStatus.created: 'created',
PaymentStatus.waiting: 'waiting',
PaymentStatus.succeeded: 'succeeded',
PaymentStatus.canceled: 'canceled',
PaymentStatus.processed: 'processed',
PaymentStatus.unknown: 'unknown',
};
const _$PaymentSystemEnumMap = {
PaymentSystem.google: 'google',
PaymentSystem.apple: 'apple',
PaymentSystem.rustore: 'rustore',
PaymentSystem.yookassa: 'yookassa',
PaymentSystem.promoCode: 'promoCode',
PaymentSystem.adView: 'adView',
PaymentSystem.unknown: 'unknown',
};

View file

@ -2,5 +2,5 @@ import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
extension PromoCodeExt on PromoCodeModel {
Future<bool> isReserved() async =>
userData != null;
userId != null;
}

View file

@ -6,7 +6,6 @@ import 'promo_codes_campaign_model.dart';
part 'promo_code_model.g.dart';
@JsonSerializable()
@CopyWith()
class PromoCodeModel {
String? id;
@ -15,16 +14,13 @@ class PromoCodeModel {
// Relations - loaded separately from database
PromoCodesCampaignModel? campaign;
// Individual promo code
UserDataModel? userData;
String? userId;
PromoCodeModel({
required this.code,
this.activations = 0,
this.id,
this.userId,
});
factory PromoCodeModel.fromJson(Map<String, dynamic> json) =>
_$PromoCodeModelFromJson(json);
Map<String, Object?> toJson() => _$PromoCodeModelToJson(this);
}

View file

@ -13,6 +13,8 @@ abstract class _$PromoCodeModelCWProxy {
PromoCodeModel id(String? id);
PromoCodeModel userId(String? userId);
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `PromoCodeModel(...).copyWith.fieldName(value)`.
///
@ -20,7 +22,12 @@ abstract class _$PromoCodeModelCWProxy {
/// ```dart
/// PromoCodeModel(...).copyWith(id: 12, name: "My name")
/// ```
PromoCodeModel call({String code, int activations, String? id});
PromoCodeModel call({
String code,
int activations,
String? id,
String? userId,
});
}
/// Callable proxy for `copyWith` functionality.
@ -39,6 +46,9 @@ class _$PromoCodeModelCWProxyImpl implements _$PromoCodeModelCWProxy {
@override
PromoCodeModel id(String? id) => call(id: id);
@override
PromoCodeModel userId(String? userId) => call(userId: userId);
@override
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `PromoCodeModel(...).copyWith.fieldName(value)`.
@ -51,6 +61,7 @@ class _$PromoCodeModelCWProxyImpl implements _$PromoCodeModelCWProxy {
Object? code = const $CopyWithPlaceholder(),
Object? activations = const $CopyWithPlaceholder(),
Object? id = const $CopyWithPlaceholder(),
Object? userId = const $CopyWithPlaceholder(),
}) {
return PromoCodeModel(
code: code == const $CopyWithPlaceholder() || code == null
@ -66,6 +77,10 @@ class _$PromoCodeModelCWProxyImpl implements _$PromoCodeModelCWProxy {
? _value.id
// ignore: cast_nullable_to_non_nullable
: id as String?,
userId: userId == const $CopyWithPlaceholder()
? _value.userId
// ignore: cast_nullable_to_non_nullable
: userId as String?,
);
}
}
@ -76,31 +91,3 @@ extension $PromoCodeModelCopyWith on PromoCodeModel {
// ignore: library_private_types_in_public_api
_$PromoCodeModelCWProxy get copyWith => _$PromoCodeModelCWProxyImpl(this);
}
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
PromoCodeModel _$PromoCodeModelFromJson(Map<String, dynamic> json) =>
PromoCodeModel(
code: json['code'] as String,
activations: (json['activations'] as num?)?.toInt() ?? 0,
id: json['id'] as String?,
)
..campaign = json['campaign'] == null
? null
: PromoCodesCampaignModel.fromJson(
json['campaign'] as Map<String, dynamic>,
)
..userData = json['userData'] == null
? null
: UserDataModel.fromJson(json['userData'] as Map<String, dynamic>);
Map<String, dynamic> _$PromoCodeModelToJson(PromoCodeModel instance) =>
<String, dynamic>{
'id': instance.id,
'code': instance.code,
'activations': instance.activations,
'campaign': instance.campaign,
'userData': instance.userData,
};

View file

@ -5,7 +5,6 @@ import 'package:mnemo_cards_common/mnemo_cards_common.dart';
part 'user_subscription_model.g.dart';
@JsonSerializable()
@CopyWith()
class UserSubscriptionModel {
String? id;
@ -24,10 +23,6 @@ class UserSubscriptionModel {
required this.features,
});
factory UserSubscriptionModel.fromJson(Map<String, dynamic> json) =>
_$UserSubscriptionModelFromJson(json);
Map<String, Object?> toJson() => _$UserSubscriptionModelToJson(this);
}
extension UserSubscriptionExt on UserSubscriptionModel {

View file

@ -93,40 +93,3 @@ extension $UserSubscriptionModelCopyWith on UserSubscriptionModel {
_$UserSubscriptionModelCWProxy get copyWith =>
_$UserSubscriptionModelCWProxyImpl(this);
}
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
UserSubscriptionModel _$UserSubscriptionModelFromJson(
Map<String, dynamic> json,
) =>
UserSubscriptionModel(
id: json['id'] as String?,
start: DateTime.parse(json['start'] as String),
finish: DateTime.parse(json['finish'] as String),
features: (json['features'] as List<dynamic>)
.map((e) => $enumDecode(_$SubscriptionFeatureEnumEnumMap, e))
.toList(),
)
..user = json['user'] == null
? null
: UserModel.fromJson(json['user'] as Map<String, dynamic>);
Map<String, dynamic> _$UserSubscriptionModelToJson(
UserSubscriptionModel instance,
) => <String, dynamic>{
'id': instance.id,
'user': instance.user,
'start': instance.start.toIso8601String(),
'finish': instance.finish.toIso8601String(),
'features': instance.features
.map((e) => _$SubscriptionFeatureEnumEnumMap[e]!)
.toList(),
};
const _$SubscriptionFeatureEnumEnumMap = {
SubscriptionFeatureEnum.ads: 'ads',
SubscriptionFeatureEnum.packs: 'packs',
SubscriptionFeatureEnum.unknown: 'unknown',
};

View file

@ -1,7 +1,6 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:mnemo_cards_common_backend/src/models/user/user_model.dart';
import '../discount/discount_model.dart';
import '../game_tests/test_statistics_model.dart';
@ -12,7 +11,6 @@ import '../statistics/achievement_model.dart';
part 'user_data_model.g.dart';
@JsonSerializable()
@CopyWith()
class UserDataModel {
String? id;
@ -21,7 +19,6 @@ class UserDataModel {
final List<PromoCodeModel> activatedPromoCodes = [];
final List<PromoCodeModel> individualPromoCodes = [];
final List<DiscountModel> activeDiscounts = [];
UserModel? user;
final List<WordStatisticsModel> words;
final String? lastTestSessionToken;
@ -68,10 +65,6 @@ class UserDataModel {
this.achievements = const [],
});
factory UserDataModel.fromJson(Map<String, dynamic> json) =>
_$UserDataModelFromJson(json);
Map<String, Object?> toJson() => _$UserDataModelToJson(this);
}
// Helper functions for PackProgressModel serialization

View file

@ -196,64 +196,3 @@ extension $UserDataModelCopyWith on UserDataModel {
// ignore: library_private_types_in_public_api
_$UserDataModelCWProxy get copyWith => _$UserDataModelCWProxyImpl(this);
}
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
UserDataModel _$UserDataModelFromJson(Map<String, dynamic> json) =>
UserDataModel(
id: json['id'] as String?,
lastTestSessionToken: json['lastTestSessionToken'] as String?,
lastTimeOnline: json['lastTimeOnline'] == null
? null
: DateTime.parse(json['lastTimeOnline'] as String),
words:
(json['words'] as List<dynamic>?)
?.map(
(e) =>
WordStatisticsModel.fromJson(e as Map<String, dynamic>),
)
.toList() ??
const [],
tags:
(json['tags'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
const [],
totalStudyTimeMinutes:
(json['totalStudyTimeMinutes'] as num?)?.toInt() ?? 0,
currentStreak: (json['currentStreak'] as num?)?.toInt() ?? 0,
longestStreak: (json['longestStreak'] as num?)?.toInt() ?? 0,
packProgress: json['packProgress'] == null
? const []
: _packProgressFromJson(json['packProgress'] as List?),
studyDates:
(json['studyDates'] as List<dynamic>?)
?.map((e) => DateTime.parse(e as String))
.toList() ??
const [],
achievements: json['achievements'] == null
? const []
: _achievementsFromJson(json['achievements'] as List?),
)
..user = json['user'] == null
? null
: UserModel.fromJson(json['user'] as Map<String, dynamic>);
Map<String, dynamic> _$UserDataModelToJson(
UserDataModel instance,
) => <String, dynamic>{
'id': instance.id,
'user': instance.user,
'words': instance.words,
'lastTestSessionToken': instance.lastTestSessionToken,
'lastTimeOnline': instance.lastTimeOnline?.toIso8601String(),
'tags': instance.tags,
'totalStudyTimeMinutes': instance.totalStudyTimeMinutes,
'currentStreak': instance.currentStreak,
'longestStreak': instance.longestStreak,
'packProgress': _packProgressToJson(instance.packProgress),
'studyDates': instance.studyDates.map((e) => e.toIso8601String()).toList(),
'achievements': _achievementsToJson(instance.achievements),
};

View file

@ -7,7 +7,6 @@ import '../subscription/user_subscription_model.dart';
part 'user_model.g.dart';
@JsonSerializable()
@CopyWith()
class UserModel {
String? id;
@ -16,11 +15,10 @@ class UserModel {
final String? telegram;
@JsonKey(defaultValue: false)
final bool admin;
// Relations - loaded separately from database
final List<CardPackModel> packs = [];
UserDataModel? userData;
@JsonKey(defaultValue: [])
final List<String> purchases;
final List<CardPackModel> packs;
UserDataModel? userData;
UserSubscriptionModel? subscriptionModel;
// UserSettingsDto
@ -33,15 +31,10 @@ class UserModel {
this.telegram,
this.admin = false,
this.purchases = const [],
this.packs = const [],
this.userSettings,
});
factory UserModel.fromJson(Map<String, dynamic> json) =>
_$UserModelFromJson(json);
Map<String, Object?> toJson() => _$UserModelToJson(this);
@JsonKey(includeFromJson: false, includeToJson: false)
static UserModel get empty => UserModel(
admin: false,
purchases: [],

View file

@ -124,42 +124,3 @@ extension $UserModelCopyWith on UserModel {
// ignore: library_private_types_in_public_api
_$UserModelCWProxy get copyWith => _$UserModelCWProxyImpl(this);
}
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
UserModel _$UserModelFromJson(Map<String, dynamic> json) =>
UserModel(
id: json['id'] as String?,
name: json['name'] as String?,
email: json['email'] as String?,
telegram: json['telegram'] as String?,
admin: json['admin'] as bool? ?? false,
purchases:
(json['purchases'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
[],
userSettings: json['userSettings'] as String?,
)
..userData = json['userData'] == null
? null
: UserDataModel.fromJson(json['userData'] as Map<String, dynamic>)
..subscriptionModel = json['subscriptionModel'] == null
? null
: UserSubscriptionModel.fromJson(
json['subscriptionModel'] as Map<String, dynamic>,
);
Map<String, dynamic> _$UserModelToJson(UserModel instance) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'email': instance.email,
'telegram': instance.telegram,
'admin': instance.admin,
'userData': instance.userData,
'purchases': instance.purchases,
'subscriptionModel': instance.subscriptionModel,
'userSettings': instance.userSettings,
};