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('GITEA_BASE_URL', 'http://localhost:3000') .replace(/\/+$/, ''); this.giteaToken = this.configService.get('GITEA_ADMIN_TOKEN', ''); this.studentOwner = this.configService.get('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'; } }