Files
lms-be/src/studio/studio.service.ts
T
2026-07-07 16:09:12 +09:00

389 lines
11 KiB
TypeScript

import {
BadGatewayException,
BadRequestException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AuthService } from '../auth/auth.service';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class StudioService {
private readonly giteaBaseUrl: string;
private readonly giteaToken: string;
private readonly studentOwner: string;
constructor(
private readonly authService: AuthService,
private readonly configService: ConfigService,
private readonly prisma: PrismaService,
) {
this.giteaBaseUrl = this.configService
.get<string>('GITEA_BASE_URL', 'http://localhost:3000')
.replace(/\/+$/, '');
this.giteaToken = this.configService.get<string>('GITEA_ADMIN_TOKEN', '');
this.studentOwner = this.configService.get<string>('GITEA_STUDENT_OWNER', 'student');
}
async prepareRepository(
accessToken: string | undefined,
input: {
courseId?: string;
week?: number;
moduleName?: string;
folderName?: string;
templateRepositoryUrl?: string;
},
) {
if (!this.giteaToken) {
throw new BadGatewayException('GITEA_ADMIN_TOKEN is not configured');
}
const { user: authUser } = await this.authService.me(accessToken);
if (!authUser?.email) throw new UnauthorizedException('Invalid user');
if (!input.week || input.week < 1) throw new BadRequestException('week is required');
const moduleName = input.moduleName?.trim();
const folderName = input.folderName?.trim();
if (!moduleName && !folderName) throw new BadRequestException('moduleName or folderName is required');
const lmsUser = await this.prisma.user.findUnique({
where: { email: authUser.email },
});
if (!lmsUser) throw new NotFoundException('LMS user not found');
const courseId = input.courseId?.trim();
if (courseId) {
const enrollment = await this.prisma.studentEnrollment.findFirst({
where: {
userId: lmsUser.id,
courseId,
status: 'ACTIVE',
},
});
if (!enrollment) throw new NotFoundException('Student enrollment not found');
}
const courseWeek = await this.prisma.courseWeek.findFirst({
where: courseId
? {
courseId,
week: input.week,
course: { isActive: true },
}
: {
week: input.week,
course: {
slug: 'futuredrive-autonomy-basic',
isActive: true,
},
},
});
if (!courseWeek) throw new NotFoundException('Course week not found');
const moduleConditions = [
moduleName ? { name: moduleName } : undefined,
folderName ? { folderName } : undefined,
].filter((condition): condition is { name: string } | { folderName: string } => Boolean(condition));
const module = await this.prisma.courseWeekModule.findFirst({
where: {
weekId: courseWeek.id,
OR: moduleConditions,
},
});
if (!module) throw new NotFoundException('Course week module not found');
const templateRepositoryUrl =
module.templateRepositoryUrl?.trim() || input.templateRepositoryUrl?.trim();
if (!templateRepositoryUrl) {
throw new BadRequestException('templateRepositoryUrl is required');
}
const repoName = `${this.slug(authUser.email.split('@')[0] || authUser.email)}-week-${courseWeek.week}-${this.slug(module.name)}`;
const repositoryUrl = `${this.giteaBaseUrl}/${this.studentOwner}/${repoName}.git`;
await this.ensureGiteaRepository(repoName, `Studio workspace for ${authUser.email} ${courseWeek.week}W ${module.name}`);
const repository = await this.prisma.studioRepository.upsert({
where: {
userId_moduleId: {
userId: lmsUser.id,
moduleId: module.id,
},
},
update: {
repoOwner: this.studentOwner,
repoName,
repositoryUrl,
templateRepositoryUrl,
},
create: {
userId: lmsUser.id,
weekId: courseWeek.id,
moduleId: module.id,
repoOwner: this.studentOwner,
repoName,
repositoryUrl,
templateRepositoryUrl,
},
});
const moduleWithArtifacts = module as typeof module & {
sourceType?: string | null;
prebuiltArtifacts?: unknown;
};
return {
id: repository.id,
owner: this.studentOwner,
repoName,
repositoryUrl,
repositoryUrlWithToken: this.withToken(repositoryUrl),
templateRepositoryUrl,
templateRepositoryUrlWithToken: this.withToken(templateRepositoryUrl),
sourceType: moduleWithArtifacts.sourceType ?? 'GIT',
prebuiltArtifacts: moduleWithArtifacts.prebuiltArtifacts ?? [],
};
}
async recordSubmission(
accessToken: string | undefined,
input: {
courseId?: string;
week?: number;
completedWeeks?: number;
totalWeeks?: number;
commitCount?: number;
},
) {
const { user: authUser } = await this.authService.me(accessToken);
if (!authUser?.email) throw new UnauthorizedException('Invalid user');
const lmsUser = await this.prisma.user.findUnique({
where: { email: authUser.email },
});
if (!lmsUser) throw new NotFoundException('LMS user not found');
const courseId = input.courseId?.trim();
if (!courseId) throw new BadRequestException('courseId is required');
const enrollment = await this.prisma.studentEnrollment.findFirst({
where: {
userId: lmsUser.id,
courseId,
status: 'ACTIVE',
},
include: {
course: {
include: {
weeks: true,
},
},
},
});
if (!enrollment) throw new NotFoundException('Student enrollment not found');
const totalWeeks = Math.max(0, input.totalWeeks ?? enrollment.course.weeks.length);
const completedWeeks = Math.max(0, Math.min(totalWeeks, input.completedWeeks ?? Math.max(0, (input.week ?? 1) - 1)));
const commitCount = Math.max(0, input.commitCount ?? 0);
const progress = await this.prisma.courseStudentProgress.upsert({
where: {
courseId_userId: {
courseId,
userId: lmsUser.id,
},
},
update: {
completedWeeks,
totalWeeks,
commitCount: {
increment: commitCount,
},
},
create: {
courseId,
userId: lmsUser.id,
completedWeeks,
totalWeeks,
commitCount,
},
});
const week = Math.max(0, Math.floor(input.week ?? 0));
if (week > 0 && commitCount > 0) {
await this.prisma.courseStudentWeekProgress.upsert({
where: {
courseId_userId_week: {
courseId,
userId: lmsUser.id,
week,
},
},
update: {
submittedAt: new Date(),
commitCount: {
increment: commitCount,
},
},
create: {
courseId,
userId: lmsUser.id,
week,
submittedAt: new Date(),
commitCount,
},
});
}
return {
ok: true,
progress: {
completedWeeks: progress.completedWeeks,
totalWeeks: progress.totalWeeks,
commitCount: progress.commitCount,
},
};
}
async recordLearningTime(
accessToken: string | undefined,
input: {
courseId?: string;
week?: number;
learningMinutes?: number;
learningMinutesDelta?: number;
},
) {
const { user: authUser } = await this.authService.me(accessToken);
if (!authUser?.email) throw new UnauthorizedException('Invalid user');
const lmsUser = await this.prisma.user.findUnique({
where: { email: authUser.email },
});
if (!lmsUser) throw new NotFoundException('LMS user not found');
const courseId = input.courseId?.trim();
if (!courseId) throw new BadRequestException('courseId is required');
const enrollment = await this.prisma.studentEnrollment.findFirst({
where: {
userId: lmsUser.id,
courseId,
status: 'ACTIVE',
},
include: {
course: {
include: {
weeks: true,
},
},
},
});
if (!enrollment) throw new NotFoundException('Student enrollment not found');
const learningMinutes = Math.max(0, Math.floor(input.learningMinutes ?? 0));
const learningMinutesDelta = Math.max(0, Math.min(10, Math.floor(input.learningMinutesDelta ?? 0)));
const totalWeeks = enrollment.course.weeks.length;
const existingProgress = await this.prisma.courseStudentProgress.findUnique({
where: {
courseId_userId: {
courseId,
userId: lmsUser.id,
},
},
});
const previousLearningMinutes = existingProgress?.learningHours ?? 0;
const nextLearningMinutes = learningMinutesDelta > 0
? previousLearningMinutes + learningMinutesDelta
: Math.max(previousLearningMinutes, learningMinutes);
const progress = await this.prisma.courseStudentProgress.upsert({
where: {
courseId_userId: {
courseId,
userId: lmsUser.id,
},
},
update: {
totalWeeks,
learningHours: nextLearningMinutes,
},
create: {
courseId,
userId: lmsUser.id,
completedWeeks: 0,
totalWeeks,
learningHours: nextLearningMinutes,
commitCount: 0,
},
});
return {
ok: true,
progress: {
learningMinutes: progress.learningHours,
totalWeeks: progress.totalWeeks,
},
};
}
private async ensureGiteaRepository(repoName: string, description: string) {
const existingResponse = await fetch(`${this.giteaBaseUrl}/api/v1/repos/${encodeURIComponent(this.studentOwner)}/${encodeURIComponent(repoName)}`, {
headers: this.giteaHeaders(),
});
if (existingResponse.ok) return;
if (existingResponse.status !== 404) {
throw new BadGatewayException(await existingResponse.text());
}
const createResponse = await fetch(`${this.giteaBaseUrl}/api/v1/orgs/${encodeURIComponent(this.studentOwner)}/repos`, {
method: 'POST',
headers: {
...this.giteaHeaders(),
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: repoName,
description,
private: true,
auto_init: false,
}),
});
if (!createResponse.ok && createResponse.status !== 409) {
throw new BadGatewayException(await createResponse.text());
}
}
private giteaHeaders() {
return {
Authorization: `token ${this.giteaToken}`,
};
}
private withToken(repositoryUrl: string) {
try {
const url = new URL(repositoryUrl);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return repositoryUrl;
url.username = 'oauth2';
url.password = this.giteaToken;
return url.toString();
} catch {
return repositoryUrl;
}
}
private slug(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'workspace';
}
}