389 lines
11 KiB
TypeScript
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';
|
|
}
|
|
}
|