This commit is contained in:
chyoo
2026-06-22 18:17:02 +09:00
parent 324728304b
commit 62d7f6ae09
122 changed files with 22484 additions and 1 deletions
+989
View File
@@ -0,0 +1,989 @@
import {
BadGatewayException,
ForbiddenException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
AiChatRunStatus,
ChatMessageStatus,
ChatSenderType,
ChatThreadStatus,
ChatThreadType,
Prisma,
} from '@prisma/client';
import { AuthService, type AuthResponseUser } from '../auth/auth.service';
import { PrismaService } from '../prisma/prisma.service';
import { ChatGateway, type ChatNotificationPayload } from './chat.gateway';
import { CreateAiResponseDto } from './dto/create-ai-response.dto';
import { CreateChatMessageDto } from './dto/create-chat-message.dto';
import { CreateInstructorChatMessageDto } from './dto/create-instructor-chat-message.dto';
import { ListChatThreadsDto } from './dto/list-chat-threads.dto';
type OpenAiChatCompletionResponse = {
choices?: Array<{
message?: {
content?: string;
};
}>;
usage?: {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
};
error?: {
message?: string;
};
};
type FutureDriveLlmChatResponse = {
answer?: string;
sources?: Array<{
title?: string;
snippet?: string;
score?: number;
}>;
topic?: string;
follow_ups?: string[];
request_id?: string;
error?: {
message?: string;
};
message?: string;
};
const STUDENT_CHAT_VISIBLE_MESSAGE_LIMIT = 20;
const AI_CHAT_CONTEXT_MESSAGE_LIMIT = 6;
@Injectable()
export class ChatService {
private readonly authServerUrl: string;
constructor(
private readonly authService: AuthService,
private readonly chatGateway: ChatGateway,
private readonly configService: ConfigService,
private readonly prisma: PrismaService,
) {
this.authServerUrl = this.configService
.get<string>('AUTH_SERVER_URL', 'http://localhost:4100')
.replace(/\/$/, '');
}
async listThreads(accessToken: string | undefined, query: ListChatThreadsDto) {
const student = await this.getLmsUser(accessToken);
const course = await this.getActiveCourse();
const week = query.week
? await this.getCourseWeek(course.id, query.week)
: null;
const threads = await this.prisma.chatThread.findMany({
where: {
courseId: course.id,
studentId: student.id,
weekId: week?.id,
type: query.mode ? this.toThreadType(query.mode) : undefined,
status: { not: ChatThreadStatus.ARCHIVED },
},
orderBy: { lastMessageAt: 'desc' },
include: {
messages: {
where: { deletedAt: null },
orderBy: { createdAt: 'desc' },
take: STUDENT_CHAT_VISIBLE_MESSAGE_LIMIT,
include: {
aiRun: true,
senderUser: {
select: {
authUserId: true,
email: true,
name: true,
},
},
},
},
week: {
select: { week: true, title: true },
},
},
});
return {
threads: threads.map((thread) => this.serializeThread(thread)),
};
}
async clearStudentThreads(accessToken: string | undefined, query: ListChatThreadsDto) {
const student = await this.getLmsUser(accessToken);
const course = await this.getActiveCourse();
const week = query.week
? await this.getCourseWeek(course.id, query.week)
: null;
const result = await this.prisma.chatThread.updateMany({
where: {
courseId: course.id,
studentId: student.id,
weekId: week?.id,
type: query.mode ? this.toThreadType(query.mode) : undefined,
status: { not: ChatThreadStatus.ARCHIVED },
},
data: {
status: ChatThreadStatus.ARCHIVED,
},
});
return { deletedCount: result.count };
}
async createStudentMessage(
accessToken: string | undefined,
dto: CreateChatMessageDto,
) {
const student = await this.getLmsUser(accessToken);
const course = await this.getActiveCourse();
const week = dto.week ? await this.getCourseWeek(course.id, dto.week) : null;
const thread = await this.getOrCreateThread({
courseId: course.id,
weekId: week?.id ?? null,
studentId: student.id,
type: this.toThreadType(dto.mode),
firstMessage: dto.content,
status:
dto.mode === 'teacher'
? ChatThreadStatus.WAITING_INSTRUCTOR
: ChatThreadStatus.OPEN,
});
const message = await this.prisma.$transaction(async (tx) => {
const createdMessage = await tx.chatMessage.create({
data: {
threadId: thread.id,
senderType: ChatSenderType.STUDENT,
senderUserId: student.id,
content: dto.content,
},
});
await tx.chatThread.update({
where: { id: thread.id },
data: {
status:
dto.mode === 'teacher'
? ChatThreadStatus.WAITING_INSTRUCTOR
: thread.status,
lastMessageAt: createdMessage.createdAt,
},
});
return createdMessage;
});
const payload = this.toNotificationPayload(thread.id, message);
this.chatGateway.notifyThread(thread.id, payload);
this.chatGateway.notifyUser(student.id, payload);
if (dto.mode === 'teacher') {
this.chatGateway.notifyCourseInstructors(course.id, payload);
}
return {
threadId: thread.id,
message: this.serializeMessage(message),
};
}
async createAiResponse(
accessToken: string | undefined,
dto: CreateAiResponseDto,
) {
const student = await this.getLmsUser(accessToken);
const course = await this.getActiveCourse();
const week = dto.week ? await this.getCourseWeek(course.id, dto.week) : null;
const thread = await this.getOrCreateThread({
courseId: course.id,
weekId: week?.id ?? null,
studentId: student.id,
type: ChatThreadType.AI,
firstMessage: dto.content,
status: ChatThreadStatus.OPEN,
});
const studentMessage = await this.prisma.$transaction(async (tx) => {
const createdMessage = await tx.chatMessage.create({
data: {
threadId: thread.id,
senderType: ChatSenderType.STUDENT,
senderUserId: student.id,
content: dto.content,
},
});
await tx.chatThread.update({
where: { id: thread.id },
data: { lastMessageAt: createdMessage.createdAt },
});
return createdMessage;
});
const studentPayload = this.toNotificationPayload(thread.id, studentMessage);
this.chatGateway.notifyThread(thread.id, studentPayload);
this.chatGateway.notifyUser(student.id, studentPayload);
const recentMessages = await this.prisma.chatMessage.findMany({
where: {
threadId: thread.id,
deletedAt: null,
senderType: { in: [ChatSenderType.STUDENT, ChatSenderType.AI] },
status: ChatMessageStatus.ACTIVE,
},
orderBy: { createdAt: 'desc' },
take: AI_CHAT_CONTEXT_MESSAGE_LIMIT,
});
const startedAt = Date.now();
const aiProvider = this.configService.get<string>('AI_PROVIDER', 'openai').trim().toLowerCase();
const model = aiProvider === 'futuredrive'
? this.configService.get<string>('FUTUREDRIVE_LLM_MODEL', 'futuredrive-llm')
: this.configService.get<string>('OPENAI_CHAT_MODEL', 'gpt-4.1-nano');
try {
const aiResponse = await this.requestAiChatCompletion(
model,
recentMessages.reverse().map((message) => ({
role:
message.senderType === ChatSenderType.STUDENT
? ('user' as const)
: ('assistant' as const),
content: message.content,
})),
);
const currentStudentMessage = await this.prisma.chatMessage.findUnique({
where: { id: studentMessage.id },
select: { status: true },
});
if (currentStudentMessage?.status === ChatMessageStatus.CANCELED) {
return {
threadId: thread.id,
message: this.serializeMessage({
...studentMessage,
status: ChatMessageStatus.CANCELED,
canceledAt: new Date(),
aiRun: null,
senderUser: null,
}),
content: '',
canceled: true,
};
}
const assistantMessage = await this.prisma.$transaction(async (tx) => {
const message = await tx.chatMessage.create({
data: {
threadId: thread.id,
senderType: ChatSenderType.AI,
content: aiResponse.content,
aiRun: {
create: {
model,
promptTokens: aiResponse.usage.promptTokens,
completionTokens: aiResponse.usage.completionTokens,
totalTokens: aiResponse.usage.totalTokens,
latencyMs: Date.now() - startedAt,
status: AiChatRunStatus.SUCCEEDED,
},
},
},
include: { aiRun: true },
});
await tx.chatThread.update({
where: { id: thread.id },
data: { lastMessageAt: message.createdAt },
});
return message;
});
const payload = this.toNotificationPayload(thread.id, assistantMessage);
this.chatGateway.notifyThread(thread.id, payload);
this.chatGateway.notifyUser(student.id, payload);
return {
threadId: thread.id,
message: this.serializeMessage(assistantMessage),
content: assistantMessage.content,
};
} catch (error) {
const message = error instanceof Error ? error.message : 'AI request failed';
const failedMessage = await this.prisma.chatMessage.create({
data: {
threadId: thread.id,
senderType: ChatSenderType.SYSTEM,
content: message,
aiRun: {
create: {
model,
latencyMs: Date.now() - startedAt,
status: AiChatRunStatus.FAILED,
error: message,
},
},
},
});
await this.prisma.chatThread.update({
where: { id: thread.id },
data: { lastMessageAt: failedMessage.createdAt },
});
const payload = this.toNotificationPayload(thread.id, failedMessage);
this.chatGateway.notifyThread(thread.id, payload);
this.chatGateway.notifyUser(student.id, payload);
throw new BadGatewayException(message);
}
}
async cancelAiMessage(
accessToken: string | undefined,
messageId: string,
content?: string,
) {
const student = await this.getLmsUser(accessToken);
const message = await this.prisma.chatMessage.findFirst({
where: {
id: messageId,
senderType: ChatSenderType.AI,
thread: {
studentId: student.id,
type: ChatThreadType.AI,
status: { not: ChatThreadStatus.ARCHIVED },
},
},
include: {
aiRun: true,
senderUser: {
select: {
authUserId: true,
email: true,
name: true,
},
},
},
});
if (!message) {
throw new NotFoundException('AI 메시지를 찾을 수 없습니다.');
}
const nextContent = typeof content === 'string' && content.trim()
? content.trim()
: message.content;
const canceledMessage = await this.prisma.chatMessage.update({
where: { id: message.id },
data: {
content: nextContent,
status: ChatMessageStatus.CANCELED,
canceledAt: new Date(),
},
include: {
aiRun: true,
senderUser: {
select: {
authUserId: true,
email: true,
name: true,
},
},
},
});
return {
message: this.serializeMessage(canceledMessage),
};
}
async cancelActiveAiResponse(
accessToken: string | undefined,
weekNumber?: number,
) {
const student = await this.getLmsUser(accessToken);
const course = await this.getActiveCourse();
const week = weekNumber ? await this.getCourseWeek(course.id, weekNumber) : null;
const message = await this.prisma.chatMessage.findFirst({
where: {
senderType: ChatSenderType.STUDENT,
status: ChatMessageStatus.ACTIVE,
thread: {
courseId: course.id,
studentId: student.id,
weekId: week?.id ?? null,
type: ChatThreadType.AI,
status: { not: ChatThreadStatus.ARCHIVED },
},
},
orderBy: { createdAt: 'desc' },
include: {
aiRun: true,
senderUser: {
select: {
authUserId: true,
email: true,
name: true,
},
},
},
});
if (!message) {
return { message: null };
}
const canceledMessage = await this.prisma.chatMessage.update({
where: { id: message.id },
data: {
status: ChatMessageStatus.CANCELED,
canceledAt: new Date(),
},
include: {
aiRun: true,
senderUser: {
select: {
authUserId: true,
email: true,
name: true,
},
},
},
});
return {
message: this.serializeMessage(canceledMessage),
};
}
async listInstructorThreads(
accessToken: string | undefined,
query: ListChatThreadsDto,
) {
const instructor = await this.getInstructorLmsUser(accessToken);
const course = await this.getActiveCourse();
const week = query.week
? await this.getCourseWeek(course.id, query.week)
: null;
const threads = await this.prisma.chatThread.findMany({
where: {
courseId: course.id,
weekId: week?.id,
type: ChatThreadType.INSTRUCTOR,
status: { not: ChatThreadStatus.ARCHIVED },
...this.instructorThreadVisibilityWhere(instructor),
},
orderBy: { lastMessageAt: 'desc' },
include: {
student: {
select: {
id: true,
authUserId: true,
email: true,
name: true,
},
},
assignedInstructor: {
select: {
id: true,
email: true,
name: true,
},
},
messages: {
where: { deletedAt: null },
orderBy: { createdAt: 'asc' },
include: {
aiRun: true,
senderUser: {
select: {
authUserId: true,
email: true,
name: true,
},
},
},
},
week: {
select: { week: true, title: true },
},
},
});
return {
threads: threads.map((thread) => this.serializeInstructorThread(thread)),
};
}
async getInstructorThread(accessToken: string | undefined, threadId: string) {
const instructor = await this.getInstructorLmsUser(accessToken);
const thread = await this.getInstructorThreadById(threadId, instructor);
return {
thread: this.serializeInstructorThread(thread),
};
}
async createInstructorMessage(
accessToken: string | undefined,
threadId: string,
dto: CreateInstructorChatMessageDto,
) {
const instructor = await this.getInstructorLmsUser(accessToken);
const thread = await this.getInstructorThreadById(threadId, instructor);
const message = await this.prisma.$transaction(async (tx) => {
const createdMessage = await tx.chatMessage.create({
data: {
threadId,
senderType: ChatSenderType.INSTRUCTOR,
senderUserId: instructor.id,
content: dto.content,
},
});
await tx.chatThread.update({
where: { id: threadId },
data: {
assignedInstructorId: instructor.id,
status: ChatThreadStatus.OPEN,
lastMessageAt: createdMessage.createdAt,
},
});
return createdMessage;
});
const updatedThread = await this.getInstructorThreadById(threadId, instructor);
const payload = this.toNotificationPayload(threadId, message);
this.chatGateway.notifyThread(threadId, payload);
this.chatGateway.notifyUser(thread.student.id, payload);
this.chatGateway.notifyCourseInstructors(thread.courseId, payload);
return {
thread: this.serializeInstructorThread(updatedThread),
message: this.serializeMessage(message),
};
}
private async getLmsUser(accessToken: string | undefined) {
if (!accessToken) throw new UnauthorizedException('Bearer token required');
const { user } = await this.authService.me(accessToken);
return this.prisma.user.upsert({
where: { email: user.email },
update: this.toUserSyncData(user),
create: {
email: user.email,
...this.toUserSyncData(user),
},
});
}
private async getInstructorLmsUser(accessToken: string | undefined) {
if (!accessToken) throw new UnauthorizedException('Bearer token required');
const { user } = await this.authService.me(accessToken);
const roles = new Set(
[user.role, ...(user.roles ?? [])].map((role) => role.toUpperCase()),
);
if (!['INSTRUCTOR', 'LMS_ADMIN'].some((role) => roles.has(role))) {
throw new ForbiddenException('Instructor role required');
}
return this.prisma.user.upsert({
where: { email: user.email },
update: this.toUserSyncData(user),
create: {
email: user.email,
...this.toUserSyncData(user),
},
});
}
private toUserSyncData(user: AuthResponseUser) {
return {
authUserId: user.id,
name: user.name,
authRole: user.role,
primaryOrganizationId: user.primaryAffiliation?.organizationId ?? null,
primaryOrganizationName: user.primaryAffiliation?.organizationName ?? null,
primaryOrganizationType: user.primaryAffiliation?.organizationType ?? null,
department: user.primaryAffiliation?.department ?? null,
position: user.primaryAffiliation?.position ?? null,
};
}
private instructorThreadVisibilityWhere(
instructor: Prisma.UserGetPayload<Record<string, never>>,
): Prisma.ChatThreadWhereInput {
const studentOrganizationFilters = [
instructor.primaryOrganizationId
? {
primaryOrganizationId: instructor.primaryOrganizationId,
}
: undefined,
instructor.primaryOrganizationName
? {
progress: {
some: {
course: {
organizationName: instructor.primaryOrganizationName,
},
},
},
}
: undefined,
].filter(Boolean) as Prisma.UserWhereInput[];
return {
OR: [
{ assignedInstructorId: null },
{ assignedInstructorId: instructor.id },
],
student: studentOrganizationFilters.length
? {
OR: studentOrganizationFilters,
}
: {
id: '__NO_ORGANIZATION_SCOPE__',
},
};
}
private async getActiveCourse() {
const course = await this.prisma.course.findFirst({
where: { slug: 'futuredrive-autonomy-basic', isActive: true },
});
if (!course) throw new NotFoundException('Curriculum course not found');
return course;
}
private async getCourseWeek(courseId: string, weekNumber: number) {
const week = await this.prisma.courseWeek.findUnique({
where: { courseId_week: { courseId, week: weekNumber } },
});
if (!week) throw new NotFoundException('Course week not found');
return week;
}
private async getOrCreateThread(input: {
courseId: string;
weekId: string | null;
studentId: string;
type: ChatThreadType;
firstMessage: string;
status: ChatThreadStatus;
}) {
const thread = await this.prisma.chatThread.findFirst({
where: {
courseId: input.courseId,
weekId: input.weekId,
studentId: input.studentId,
type: input.type,
status: { not: ChatThreadStatus.ARCHIVED },
},
orderBy: { lastMessageAt: 'desc' },
});
if (thread) return thread;
return this.prisma.chatThread.create({
data: {
courseId: input.courseId,
weekId: input.weekId,
studentId: input.studentId,
type: input.type,
status: input.status,
title: input.firstMessage.slice(0, 80),
},
});
}
private async getInstructorThreadById(
threadId: string,
instructor: Prisma.UserGetPayload<Record<string, never>>,
) {
const thread = await this.prisma.chatThread.findFirst({
where: {
id: threadId,
type: ChatThreadType.INSTRUCTOR,
status: { not: ChatThreadStatus.ARCHIVED },
...this.instructorThreadVisibilityWhere(instructor),
},
include: {
student: {
select: {
id: true,
authUserId: true,
email: true,
name: true,
},
},
assignedInstructor: {
select: {
id: true,
email: true,
name: true,
},
},
messages: {
where: { deletedAt: null },
orderBy: { createdAt: 'asc' },
include: {
aiRun: true,
senderUser: {
select: {
authUserId: true,
email: true,
name: true,
},
},
},
},
week: {
select: { week: true, title: true },
},
},
});
if (!thread) throw new NotFoundException('Instructor chat thread not found');
return thread;
}
private async requestAiChatCompletion(
model: string,
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
) {
const provider = this.configService.get<string>('AI_PROVIDER', 'openai').trim().toLowerCase();
if (provider === 'futuredrive') {
return this.requestFutureDriveLlmChatCompletion(messages);
}
return this.requestOpenAiChatCompletion(model, messages);
}
private async requestOpenAiChatCompletion(
model: string,
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
) {
const apiKey = this.configService.get<string>('OPENAI_API_KEY');
if (!apiKey) {
throw new Error(
'OPENAI_API_KEY가 설정되어 있지 않습니다. lms-be .env에 OPENAI_API_KEY=... 형식으로 추가하세요.',
);
}
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model,
temperature: 0.3,
messages: [
{
role: 'system',
content:
'당신은 자율주행 교육용 ROS2, SLAM 실습을 돕는 한국어 AI 챗봇입니다. 답변은 짧고 실습자가 바로 따라 할 수 있게 명확하게 작성하세요.',
},
...messages,
],
}),
});
const data = (await response.json()) as OpenAiChatCompletionResponse;
if (!response.ok) {
throw new Error(data.error?.message ?? `OpenAI request failed: ${response.status}`);
}
const content = data.choices?.[0]?.message?.content?.trim();
if (!content) throw new Error('OpenAI 응답이 비어 있습니다.');
return {
content,
usage: {
promptTokens: data.usage?.prompt_tokens,
completionTokens: data.usage?.completion_tokens,
totalTokens: data.usage?.total_tokens,
},
};
}
private async requestFutureDriveLlmChatCompletion(
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
) {
const apiKey = this.configService.get<string>('FUTUREDRIVE_LLM_API_KEY');
if (!apiKey) {
throw new Error(
'FUTUREDRIVE_LLM_API_KEY가 설정되어 있지 않습니다. lms-be .env에 FUTUREDRIVE_LLM_API_KEY=... 형식으로 추가하세요.',
);
}
const baseUrl = this.configService
.get<string>('FUTUREDRIVE_LLM_BASE_URL', 'https://famine-tissue-cursor.ngrok-free.dev')
.replace(/\/+$/, '');
const lang = this.configService.get<string>('FUTUREDRIVE_LLM_LANG', 'ko');
const response = await fetch(`${baseUrl}/api/chat/complete`, {
method: 'POST',
headers: {
'X-API-Key': apiKey,
'Content-Type': 'application/json',
'ngrok-skip-browser-warning': '1',
},
body: JSON.stringify({
messages,
lang,
}),
});
const data = (await response.json().catch(() => ({}))) as FutureDriveLlmChatResponse;
if (!response.ok) {
throw new Error(data.error?.message ?? data.message ?? `FutureDrive LLM request failed: ${response.status}`);
}
const content = data.answer?.trim();
if (!content) throw new Error('FutureDrive LLM 응답이 비어 있습니다.');
return {
content,
usage: {
promptTokens: undefined,
completionTokens: undefined,
totalTokens: undefined,
},
};
}
private toThreadType(mode: 'ai' | 'teacher') {
return mode === 'ai' ? ChatThreadType.AI : ChatThreadType.INSTRUCTOR;
}
private serializeThread(
thread: Prisma.ChatThreadGetPayload<{
include: {
messages: { include: { aiRun: true; senderUser: { select: { authUserId: true; email: true; name: true } } } };
week: { select: { week: true; title: true } };
};
}>,
) {
return {
id: thread.id,
type: thread.type,
status: thread.status,
title: thread.title,
week: thread.week,
lastMessageAt: thread.lastMessageAt,
messages: [...thread.messages]
.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime())
.map((message) => this.serializeMessage(message)),
};
}
private serializeInstructorThread(
thread: Prisma.ChatThreadGetPayload<{
include: {
student: { select: { id: true; authUserId: true; email: true; name: true } };
assignedInstructor: { select: { id: true; email: true; name: true } };
messages: { include: { aiRun: true; senderUser: { select: { authUserId: true; email: true; name: true } } } };
week: { select: { week: true; title: true } };
};
}>,
) {
return {
id: thread.id,
type: thread.type,
status: thread.status,
title: thread.title,
student: thread.student,
studentName: thread.student.name ?? thread.student.email,
studentAvatarUrl: this.getStudentAvatarUrl(thread.student),
assignedInstructor: thread.assignedInstructor,
week: thread.week,
lastMessageAt: thread.lastMessageAt,
messages: thread.messages.map((message) => this.serializeMessage(message)),
};
}
private serializeMessage(
message: Prisma.ChatMessageGetPayload<{
include: {
aiRun: true;
senderUser: { select: { authUserId: true; email: true; name: true } };
};
}> | {
id: string;
senderType: ChatSenderType;
senderUserId: string | null;
senderUser?: { email: string; name: string | null } | null;
content: string;
contentFormat: string;
status?: ChatMessageStatus;
metadata: Prisma.JsonValue | null;
createdAt: Date;
canceledAt?: Date | null;
deletedAt: Date | null;
},
) {
return {
id: message.id,
senderType: message.senderType,
senderUserId: message.senderUserId,
senderName: message.senderUser?.name ?? message.senderUser?.email ?? null,
senderAvatarUrl: this.getUserAvatarUrl(message.senderUser ?? null),
content: message.content,
contentFormat: message.contentFormat,
status: 'status' in message ? message.status : ChatMessageStatus.ACTIVE,
metadata: message.metadata,
createdAt: message.createdAt,
canceledAt: 'canceledAt' in message ? message.canceledAt : null,
};
}
private toNotificationPayload(
threadId: string,
message: {
id: string;
senderType: ChatSenderType;
content: string;
createdAt: Date;
},
): ChatNotificationPayload {
return {
type: 'chat.message.created',
threadId,
messageId: message.id,
senderType: message.senderType,
preview: message.content.slice(0, 120),
createdAt: message.createdAt.toISOString(),
};
}
private getStudentAvatarUrl(student: {
authUserId: string | null;
email: string;
}) {
return this.getUserAvatarUrl(student);
}
private getUserAvatarUrl(user: {
authUserId?: string | null;
email?: string | null;
} | null) {
if (!user) return null;
if (user.authUserId) {
return `${this.authServerUrl}/auth/users/${user.authUserId}/avatar`;
}
if (user.email) {
return `${this.authServerUrl}/auth/users/avatar?email=${encodeURIComponent(user.email)}`;
}
return null;
}
}