api-server/src/modules/ai-runtime/user-ai.service.ts
wangdl 0889e3af46
All checks were successful
Deploy API Server / build (push) Successful in 36s
Deploy API Server / deploy (push) Successful in 1m0s
feat: knowledge generation workflow + drop import-candidate module + migrations
2026-07-08 19:34:10 +08:00

700 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { CredentialEncryptionService } from './credential-encryption.service';
import { SnapshotBuilderService } from './snapshot-builder.service';
import { PriorityRulesService } from './priority-rules.service';
import { UserAiQuotaService } from './user-ai-quota.service';
import { PlatformBudgetService } from './platform-budget.service';
import type { SaveLearningProfileDto, UpdateAiSettingsDto, CreateCredentialDto, UpdateCredentialDto, CredentialResponseDto, CreateAnalysisJobDto, CreateAnalysisJobResponseDto } from './user-ai.dto';
const JOB_TYPE_CONFIG: Record<string, { promptVersion: string; outputSchemaVersion: string }> = {
learning_state_analysis: { promptVersion: 'learning_state_v1', outputSchemaVersion: 'analysis_output_v1' },
weak_point_analysis: { promptVersion: 'weak_point_v1', outputSchemaVersion: 'weak_point_output_v1' },
next_action_planning: { promptVersion: 'next_action_v1', outputSchemaVersion: 'next_action_output_v1' },
quiz_generation: { promptVersion: 'quiz_gen_v1', outputSchemaVersion: 'quiz_output_v1' },
flashcard_generation: { promptVersion: 'flashcard_gen_v1', outputSchemaVersion: 'flashcard_output_v1' },
};
@Injectable()
export class UserAiService {
constructor(
private readonly prisma: PrismaService,
private readonly crypto: CredentialEncryptionService,
private readonly snapshotBuilder: SnapshotBuilderService,
private readonly priorityRules: PriorityRulesService,
private readonly quota: UserAiQuotaService,
private readonly budget: PlatformBudgetService,
private readonly config: ConfigService,
) {}
// ══ LearningProfile ══
async getProfile(userId: string) {
return this.prisma.userLearningProfile.findUnique({ where: { userId } });
}
async saveProfile(userId: string, dto: SaveLearningProfileDto) {
return this.prisma.userLearningProfile.upsert({
where: { userId },
create: { userId, ...dto },
update: dto,
});
}
// ══ AiSettings ══
async getSettings(userId: string) {
let settings = await this.prisma.userAiSettings.findUnique({ where: { userId } });
if (!settings) {
settings = await this.prisma.userAiSettings.create({ data: { userId } });
}
return settings;
}
async updateSettings(userId: string, dto: UpdateAiSettingsDto) {
if (dto.apiKeyMode === 'user_deepseek_key' && dto.defaultCredentialId) {
const cred = await this.prisma.userModelCredential.findFirst({
where: { id: dto.defaultCredentialId, userId },
});
if (!cred) {
throw new BadRequestException('Credential not found or does not belong to user');
}
}
return this.prisma.userAiSettings.upsert({
where: { userId },
create: { userId, ...dto },
update: dto,
});
}
// ══ ModelCredentials ══
async listCredentials(userId: string): Promise<CredentialResponseDto[]> {
const creds = await this.prisma.userModelCredential.findMany({
where: { userId, deletedAt: null },
orderBy: { createdAt: 'desc' },
});
return creds.map(c => this.toResponse(c));
}
async createCredential(userId: string, dto: CreateCredentialDto): Promise<CredentialResponseDto> {
const encryptedApiKey = this.crypto.encrypt(dto.apiKey);
const keyHash = this.crypto.hash(dto.apiKey);
const maskedKey = this.crypto.mask(dto.apiKey);
const cred = await this.prisma.userModelCredential.create({
data: {
userId,
provider: dto.provider,
keyAlias: dto.keyAlias,
encryptedApiKey,
keyHash,
maskedKey,
},
});
return this.toResponse(cred);
}
async updateCredential(userId: string, credId: string, dto: UpdateCredentialDto): Promise<CredentialResponseDto> {
const cred = await this.prisma.userModelCredential.findFirst({
where: { id: credId, userId, deletedAt: null },
});
if (!cred) throw new NotFoundException('Credential not found');
const encryptedApiKey = this.crypto.encrypt(dto.apiKey);
const keyHash = this.crypto.hash(dto.apiKey);
const maskedKey = this.crypto.mask(dto.apiKey);
const status = 'active';
const updated = await this.prisma.userModelCredential.update({
where: { id: credId },
data: { encryptedApiKey, keyHash, maskedKey, status, keyAlias: dto.keyAlias, lastErrorCode: null, lastErrorMessage: null },
});
return this.toResponse(updated);
}
async deleteCredential(userId: string, credId: string): Promise<void> {
const cred = await this.prisma.userModelCredential.findFirst({
where: { id: credId, userId, deletedAt: null },
});
if (!cred) throw new NotFoundException('Credential not found');
await this.prisma.userModelCredential.update({
where: { id: credId },
data: { deletedAt: new Date(), status: 'deleted' },
});
}
async testCredential(userId: string, credId: string) {
const cred = await this.prisma.userModelCredential.findFirst({
where: { id: credId, userId, deletedAt: null },
});
if (!cred) throw new NotFoundException('Credential not found');
let decryptedKey: string;
try {
decryptedKey = this.crypto.decrypt(cred.encryptedApiKey);
} catch {
await this.prisma.userModelCredential.update({
where: { id: credId },
data: { status: 'invalid', lastErrorCode: 'DECRYPT_FAILED' },
});
return { success: false, errorCode: 'DECRYPT_FAILED', message: '解密 Key 失败,请重新填写' };
}
try {
const baseUrl = this.config.get<string>('ai.deepseek.baseUrl', 'https://api.deepseek.com');
const res = await fetch(`${baseUrl}/v1/models`, {
headers: { Authorization: `Bearer ${decryptedKey}` },
signal: AbortSignal.timeout(10000),
});
const success = res.ok;
await this.prisma.userModelCredential.update({
where: { id: credId },
data: {
status: success ? 'active' : 'invalid',
lastTestedAt: new Date(),
lastErrorCode: success ? null : `HTTP_${res.status}`,
lastErrorMessage: success ? null : `DeepSeek returned ${res.status}`,
},
});
return { success, status: success ? 'active' : 'invalid' };
} catch (err: any) {
await this.prisma.userModelCredential.update({
where: { id: credId },
data: {
status: 'invalid',
lastTestedAt: new Date(),
lastErrorCode: 'NETWORK_ERROR',
lastErrorMessage: err.message?.substring(0, 500),
},
});
return { success: false, errorCode: 'NETWORK_ERROR', message: err.message };
}
}
/** 内部使用:解密 key 并返回明文(不写日志) */
async resolveCredentialForJob(userId: string, credId: string): Promise<{ provider: string; apiKey: string }> {
const cred = await this.prisma.userModelCredential.findFirst({
where: { id: credId, userId, deletedAt: null, status: 'active' },
});
if (!cred) throw new NotFoundException('Credential not found or not active');
const apiKey = this.crypto.decrypt(cred.encryptedApiKey);
await this.prisma.userModelCredential.update({
where: { id: credId },
data: { lastUsedAt: new Date() },
});
return { provider: cred.provider, apiKey };
}
// ══ Analysis Job Creation ══
async createAnalysisJob(userId: string, dto: CreateAnalysisJobDto): Promise<CreateAnalysisJobResponseDto> {
// 1. Settings check (auto-create with defaults if new user)
let settings = await this.prisma.userAiSettings.findUnique({ where: { userId } });
if (!settings) {
settings = await this.prisma.userAiSettings.create({ data: { userId } });
}
if (!settings.allowAiAnalysis) {
throw new BadRequestException({ errorCode: 'AI_ANALYSIS_DISABLED', message: 'AI analysis is disabled. Enable it in settings.' });
}
// 2. Validate jobType + per-type constraints
if (!Object.keys(JOB_TYPE_CONFIG).includes(dto.jobType)) {
throw new BadRequestException({
errorCode: 'INVALID_JOB_TYPE',
message: `Invalid jobType "${dto.jobType}". Must be one of: ${Object.keys(JOB_TYPE_CONFIG).join(', ')}`,
});
}
const requiresKnowledgeBase = ['quiz_generation', 'flashcard_generation'];
if (requiresKnowledgeBase.includes(dto.jobType) && dto.targetType !== 'knowledge_base') {
throw new BadRequestException({
errorCode: 'INVALID_TARGET_TYPE',
message: `${dto.jobType} requires targetType="knowledge_base"`,
});
}
// 3. Idempotency check (cheapest fast-return, before quota/snapshot)
if (dto.idempotencyKey) {
const existing = await this.prisma.aiRuntimeJob.findUnique({
where: { idempotencyKey: dto.idempotencyKey },
select: { id: true, status: true, createdAt: true },
});
if (existing) {
return { jobId: existing.id, status: existing.status, createdAt: existing.createdAt.toISOString() };
}
}
// 4. Resolve apiKeyMode / credentialId
// NOTE: apiKeyMode may be reassigned in step 6 fallback — steps 711 consume the final values
let apiKeyMode = dto.apiKeyMode ?? settings.apiKeyMode;
let credentialId: string | undefined = dto.credentialId ?? settings.defaultCredentialId ?? undefined;
if (apiKeyMode === 'user_deepseek_key' && !credentialId) {
throw new BadRequestException({ errorCode: 'CREDENTIAL_REQUIRED', message: 'User key mode requires a credential.' });
}
if (credentialId) {
const cred = await this.prisma.userModelCredential.findFirst({
where: { id: credentialId, userId, deletedAt: null, status: 'active' },
});
if (!cred) {
throw new NotFoundException({ errorCode: 'CREDENTIAL_NOT_FOUND', message: 'Credential not found or not active.' });
}
}
// 5. Quota check (read-only fast-fail; actual reservation at step 11)
await this.quota.checkQuota(userId, apiKeyMode);
// 6. Platform budget check (platform_key only) — with fallback to user key
if (apiKeyMode === 'platform_key') {
try {
await this.budget.checkPlatformBudget('deepseek', 'deepseek-chat');
} catch (err: any) {
if (err?.response?.errorCode === 'PLATFORM_CIRCUIT_OPEN' && settings.fallbackToPlatformKey && settings.defaultCredentialId) {
// Fallback: apiKeyMode + credentialId reassigned — downstream steps use updated values
apiKeyMode = 'user_deepseek_key';
credentialId = settings.defaultCredentialId;
} else {
throw err;
}
}
}
// 7. Build snapshot
const snapshot = await this.snapshotBuilder.buildSnapshot(userId, dto.targetType, dto.targetId);
// 8. Resolve prompt/output schema versions
const config = JOB_TYPE_CONFIG[dto.jobType];
const promptVersion = dto.promptVersion ?? config.promptVersion;
const outputSchemaVersion = dto.outputSchemaVersion ?? config.outputSchemaVersion;
// 9. Compute priority
const profile = await this.prisma.userLearningProfile.findUnique({ where: { userId } });
const priority = this.priorityRules.computeJobPriority(profile, settings, dto.targetType);
// 10. Create job + per-type plan records
const job = await this.prisma.aiRuntimeJob.create({
data: {
userId,
jobType: dto.jobType,
targetType: dto.targetType,
targetId: dto.targetId,
snapshotId: snapshot.id,
status: 'pending',
priority,
idempotencyKey: dto.idempotencyKey ?? null,
apiKeyMode,
credentialId: credentialId ?? null,
modelProvider: 'deepseek',
modelName: 'deepseek-chat',
promptVersion,
outputSchemaVersion,
},
});
// 10. Create per-type plan record
let planId: string | undefined;
if (dto.jobType === 'quiz_generation') {
const quizSourceIds = dto.sourceIds ?? [];
const plan = await this.prisma.questionGenerationPlan.create({
data: {
userId,
jobId: job.id,
snapshotId: snapshot.id,
targetType: dto.targetType,
targetId: dto.targetId,
sourceIds: quizSourceIds as any,
questionTypes: (dto.questionTypes ?? []) as any,
difficultyLevel: dto.difficultyLevel ?? null,
count: dto.questionCount ?? 5,
status: 'pending',
},
});
planId = plan.id;
}
if (dto.jobType === 'flashcard_generation') {
const plan = await this.prisma.flashcardGenerationPlan.create({
data: {
userId,
jobId: job.id,
snapshotId: snapshot.id,
targetType: dto.targetType,
targetId: dto.targetId,
knowledgePointIds: (dto.knowledgePointIds ?? []) as any,
difficultyLevel: dto.difficultyLevel ?? null,
count: dto.cardCount ?? 5,
status: 'pending',
},
});
planId = plan.id;
}
// 11. Increment quota
await this.quota.incrementJobCount(userId, apiKeyMode).catch(() => {});
return { jobId: job.id, status: job.status, createdAt: job.createdAt.toISOString(), ...(planId ? { planId } : {}) };
}
async cancelJob(userId: string, jobId: string) {
const job = await this.prisma.aiRuntimeJob.findFirst({
where: { id: jobId, userId },
select: { id: true, status: true },
});
if (!job) throw new NotFoundException({ errorCode: 'JOB_NOT_FOUND', message: 'Job not found' });
if (job.status === 'pending') {
await this.prisma.aiRuntimeJob.update({
where: { id: jobId },
data: { status: 'cancelled', cancelledAt: new Date() },
});
return { jobId, status: 'cancelled' };
}
if (job.status === 'locked' || job.status === 'running') {
await this.prisma.aiRuntimeJob.update({
where: { id: jobId },
data: { cancelRequestedAt: new Date() },
});
return { jobId, status: 'cancel_requested' };
}
throw new BadRequestException({
errorCode: 'JOB_CANNOT_CANCEL',
message: `Job is in terminal state "${job.status}"`,
});
}
// ══ Quiz Publish ══
async publishQuiz(userId: string, quizId: string) {
const quiz = await this.prisma.quiz.findFirst({
where: { id: quizId, userId },
select: { id: true, status: true, knowledgeBaseId: true },
});
if (!quiz) throw new NotFoundException({ errorCode: 'QUIZ_NOT_FOUND', message: 'Quiz not found' });
if (quiz.status !== 'ready') {
throw new BadRequestException({
errorCode: 'QUIZ_NOT_READY',
message: `Quiz is "${quiz.status}", only "ready" quizzes can be published`,
});
}
// Archive old active quizzes for same knowledge base
if (quiz.knowledgeBaseId) {
await this.prisma.quiz.updateMany({
where: { userId, knowledgeBaseId: quiz.knowledgeBaseId, status: 'active', id: { not: quizId } },
data: { status: 'archived' },
});
}
await this.prisma.quiz.update({
where: { id: quizId },
data: { status: 'active' },
});
return { quizId, status: 'active' };
}
// ══ Flashcard Publish ══
async publishFlashcard(userId: string, cardId: string) {
const card = await this.prisma.flashcard.findFirst({
where: { id: cardId, userId, deletedAt: null },
select: { id: true, status: true },
});
if (!card) throw new NotFoundException({ errorCode: 'FLASHCARD_NOT_FOUND', message: 'Flashcard not found' });
if (card.status !== 'draft') {
throw new BadRequestException({
errorCode: 'FLASHCARD_NOT_DRAFT',
message: `Flashcard is "${card.status}", only "draft" cards can be published`,
});
}
await this.prisma.flashcard.update({
where: { id: cardId },
data: { status: 'active' },
});
return { cardId, status: 'active' };
}
// ══ Job Status Query ══
async getJob(userId: string, jobId: string) {
const job = await this.prisma.aiRuntimeJob.findFirst({
where: { id: jobId, userId },
select: {
id: true, jobType: true, targetType: true, targetId: true,
status: true, priority: true, snapshotId: true,
attemptNo: true, retryCount: true, maxRetryCount: true,
errorCode: true, errorMessage: true,
cancelRequestedAt: true, cancelledAt: true,
startedAt: true, finishedAt: true,
createdAt: true, updatedAt: true,
},
});
if (!job) throw new NotFoundException({ errorCode: 'JOB_NOT_FOUND', message: 'Job not found' });
return job;
}
async listJobs(userId: string, status?: string, take: number = 20) {
const where: any = { userId };
if (status) where.status = status;
return this.prisma.aiRuntimeJob.findMany({
where,
select: {
id: true, jobType: true, targetType: true, targetId: true,
status: true, priority: true,
errorCode: true, cancelRequestedAt: true,
startedAt: true, finishedAt: true, createdAt: true,
},
orderBy: { createdAt: 'desc' },
take: Math.min(take, 50),
});
}
// ══ Analysis Results Query ══
async getAnalysis(userId: string, analysisId: string) {
const a = await this.prisma.aiLearningAnalysis.findFirst({
where: { id: analysisId, userId },
select: {
id: true, userId: true, jobId: true, snapshotId: true,
targetType: true, targetId: true,
learningState: true, summary: true, riskLevel: true, confidence: true,
evidence: true, nextActionIds: true,
promptVersion: true, schemaVersion: true,
createdAt: true, updatedAt: true,
},
});
if (!a) throw new NotFoundException({ errorCode: 'ANALYSIS_NOT_FOUND', message: 'Analysis not found' });
return a;
}
async listAnalyses(userId: string, targetType?: string, targetId?: string, take: number = 20) {
const where: any = { userId };
if (targetType) where.targetType = targetType;
if (targetId) where.targetId = targetId;
return this.prisma.aiLearningAnalysis.findMany({
where,
select: {
id: true, targetType: true, targetId: true,
learningState: true, riskLevel: true, confidence: true,
summary: true, createdAt: true,
},
orderBy: { createdAt: 'desc' },
take: Math.min(take, 50),
});
}
// ══ Suggestions / Weak Points Query ══
async listRecommendations(userId: string, targetType?: string, targetId?: string, status?: string, take: number = 20) {
const where: any = { userId };
if (targetType) where.targetType = targetType;
if (targetId) where.targetId = targetId;
if (status) where.status = status;
return this.prisma.nextActionRecommendation.findMany({
where,
select: {
id: true, actionType: true, targetType: true, targetId: true,
title: true, reason: true, priority: true, estimatedMinutes: true,
deviceSuitability: true, status: true, createdAt: true,
},
orderBy: { priority: 'asc' },
take: Math.min(take, 50),
});
}
async listWeakPoints(userId: string, targetType?: string, targetId?: string, status?: string, take: number = 20) {
const where: any = { userId };
if (targetType) where.targetType = targetType;
if (targetId) where.targetId = targetId;
if (status) where.status = status;
return this.prisma.weakPointCandidate.findMany({
where,
select: {
id: true, knowledgePointId: true, title: true, reason: true,
confidence: true, evidence: true, status: true,
targetType: true, targetId: true, createdAt: true,
},
orderBy: { confidence: 'desc' },
take: Math.min(take, 50),
});
}
// ══ Quiz Query ══
async getQuiz(userId: string, quizId: string) {
const quiz = await this.prisma.quiz.findFirst({
where: { id: quizId, userId },
select: {
id: true, knowledgeBaseId: true, title: true, description: true,
questionCount: true, sourceType: true, sourceId: true,
status: true, createdAt: true, updatedAt: true,
},
});
if (!quiz) throw new NotFoundException({ errorCode: 'QUIZ_NOT_FOUND', message: 'Quiz not found' });
return quiz;
}
async getQuizQuestions(userId: string, quizId: string, attemptId?: string) {
const quiz = await this.prisma.quiz.findFirst({ where: { id: quizId, userId }, select: { id: true } });
if (!quiz) throw new NotFoundException({ errorCode: 'QUIZ_NOT_FOUND', message: 'Quiz not found' });
// M-QUIZ-01-01: 若提供了 attemptId按当前 Attempt 状态控制答案可见性
// 未提供 attemptId 时,不返回 answer/explanation安全默认
let isFinished = false;
if (attemptId) {
const attempt = await this.prisma.quizAttempt.findFirst({
where: { id: attemptId, userId, quizId },
select: { finishedAt: true },
});
if (attempt) {
isFinished = attempt.finishedAt !== null;
}
}
return this.prisma.quizQuestion.findMany({
where: { quizId },
select: {
id: true, type: true, stem: true, options: true,
// M-QUIZ-01-01: 仅当前 Attempt 已提交后才返回 answer 和 explanation
// 不存在基于"任意历史 Attempt"的答案泄漏
...(isFinished ? { answer: true, explanation: true } : {}),
sourceBlockIds: true, orderIndex: true,
},
orderBy: { orderIndex: 'asc' },
});
}
async listQuizzes(userId: string, knowledgeBaseId?: string, status?: string, take: number = 20) {
const where: any = { userId };
if (knowledgeBaseId) where.knowledgeBaseId = knowledgeBaseId;
if (status) where.status = status;
return this.prisma.quiz.findMany({
where,
select: {
id: true, knowledgeBaseId: true, title: true, questionCount: true,
sourceType: true, status: true, createdAt: true,
},
orderBy: { createdAt: 'desc' },
take: Math.min(take, 50),
});
}
// ══ Flashcard Query ══
async getFlashcard(userId: string, cardId: string) {
const card = await this.prisma.flashcard.findFirst({
where: { id: cardId, userId, deletedAt: null },
select: {
id: true, front: true, back: true, hint: true,
difficultyLevel: true, knowledgePointId: true, sourceBlockIds: true,
sourceType: true, sourceId: true, generatedByJobId: true,
status: true, createdAt: true, updatedAt: true,
},
});
if (!card) throw new NotFoundException({ errorCode: 'FLASHCARD_NOT_FOUND', message: 'Flashcard not found' });
return card;
}
async listFlashcards(userId: string, knowledgePointId?: string, status?: string, take: number = 20) {
const where: any = { userId, deletedAt: null };
if (knowledgePointId) where.knowledgePointId = knowledgePointId;
if (status) where.status = status;
return this.prisma.flashcard.findMany({
where,
select: {
id: true, front: true, difficultyLevel: true,
knowledgePointId: true, sourceType: true, status: true, createdAt: true,
},
orderBy: { createdAt: 'desc' },
take: Math.min(take, 50),
});
}
// ══ Re-analysis Trigger ══
async triggerReanalysis(userId: string, targetType: string, targetId: string) {
return this.createAnalysisJob(userId, {
jobType: 'learning_state_analysis',
targetType,
targetId,
});
}
// ══ Artifact Feedback ══
async submitArtifactFeedback(
userId: string, artifactType: string, artifactId: string,
dto: { feedbackType: string; reason?: string },
) {
const exists = await this.checkArtifactExists(userId, artifactType, artifactId);
if (!exists) throw new NotFoundException({ errorCode: 'ARTIFACT_NOT_FOUND', message: `${artifactType} not found` });
return this.prisma.aiArtifactFeedback.create({
data: { userId, artifactType, artifactId, feedbackType: dto.feedbackType, reason: dto.reason ?? null },
select: { id: true, feedbackType: true, createdAt: true },
});
}
private async checkArtifactExists(userId: string, artifactType: string, artifactId: string): Promise<boolean> {
if (artifactType === 'analysis') {
const a = await this.prisma.aiLearningAnalysis.findFirst({ where: { id: artifactId, userId }, select: { id: true } });
return !!a;
}
if (artifactType === 'quiz') {
const q = await this.prisma.quiz.findFirst({ where: { id: artifactId, userId }, select: { id: true } });
return !!q;
}
if (artifactType === 'flashcard') {
const f = await this.prisma.flashcard.findFirst({ where: { id: artifactId, userId, deletedAt: null }, select: { id: true } });
return !!f;
}
return false;
}
// ══ General Feedback ══
async submitFeedback(userId: string, dto: { category: string; content: string; email?: string; deviceInfo?: any }) {
return this.prisma.feedback.create({
data: {
userId, category: dto.category, content: dto.content,
email: dto.email ?? null, deviceInfo: dto.deviceInfo ?? undefined, status: 'open',
},
select: { id: true, status: true, createdAt: true },
});
}
// ══ Notifications ══
async listNotifications(userId: string, take: number = 20) {
return this.prisma.notification.findMany({
where: { userId },
select: { id: true, type: true, title: true, content: true, data: true, readAt: true, createdAt: true },
orderBy: { createdAt: 'desc' },
take: Math.min(take, 50),
});
}
private toResponse(c: any): CredentialResponseDto {
return {
id: c.id,
provider: c.provider,
keyAlias: c.keyAlias,
maskedKey: c.maskedKey,
status: c.status,
lastTestedAt: c.lastTestedAt?.toISOString(),
lastUsedAt: c.lastUsedAt?.toISOString(),
createdAt: c.createdAt.toISOString(),
updatedAt: c.updatedAt.toISOString(),
};
}
}