Sync LMS backend source
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user