From fa8d88ee216a0d423e04f0cf489b05f0e4ba26c0 Mon Sep 17 00:00:00 2001 From: Patryk Znamirowski Date: Fri, 31 Jul 2026 10:17:48 +0200 Subject: [PATCH 1/7] feat: add student tiles to dashboard --- apps/api/src/ai/ai-practice.queue.service.ts | 27 ++ apps/api/src/ai/ai-practice.schema.ts | 42 +++ apps/api/src/ai/ai-practice.types.ts | 24 ++ apps/api/src/ai/ai-practice.worker.ts | 52 ++++ apps/api/src/ai/ai.controller.ts | 56 +++- apps/api/src/ai/ai.module.ts | 11 +- .../handlers/ai-practice-requested.handler.ts | 13 + apps/api/src/ai/repositories/ai.repository.ts | 106 ++++++- .../src/ai/services/ai-practice.service.ts | 256 ++++++++++++++++ apps/api/src/ai/services/ai.service.ts | 48 ++- apps/api/src/ai/services/prompt.service.ts | 2 +- apps/api/src/ai/services/thread.service.ts | 8 + apps/api/src/ai/utils/ai.schema.ts | 6 +- .../certificates/certificate.repository.ts | 56 ++++ .../certificates/certificates.controller.ts | 24 +- .../src/certificates/certificates.schema.ts | 14 + .../src/certificates/certificates.service.ts | 12 + .../src/certificates/certificates.types.ts | 2 + apps/api/src/courses/course.controller.ts | 30 ++ apps/api/src/courses/course.service.ts | 184 +++++++++++ .../schemas/studentDashboard.schema.ts | 50 +++ .../ai/ai-mentor-practice-requested.event.ts | 10 + apps/api/src/events/index.ts | 1 + apps/api/src/queue/queue.types.ts | 1 + .../__tests__/settings.controller.e2e-spec.ts | 23 +- .../settings/schemas/settings.schema.spec.ts | 4 +- apps/api/src/settings/settings.service.ts | 15 +- .../0175_add_student_dashboard_tiles.sql | 51 ++++ ...6_migrate_student_dashboard_widget_ids.sql | 36 +++ .../src/storage/migrations/meta/_journal.json | 14 + apps/api/src/storage/schema/index.ts | 61 +++- .../mutations/useCreateAiMentorPractice.ts | 24 ++ .../app/api/mutations/useMarkCourseOpened.ts | 12 + .../api/mutations/useRetryAiMentorPractice.ts | 18 ++ .../app/api/queries/useAiMentorPractice.ts | 18 ++ .../api/queries/useAiMentorPracticeToday.ts | 21 ++ .../queries/useCertificateDashboardSummary.ts | 19 ++ .../api/queries/useDashboardCertificates.ts | 31 ++ .../api/queries/useStudentDashboardSummary.ts | 21 ++ apps/web/app/config/routeAccessConfig.ts | 3 + apps/web/app/locales/cs/translation.json | 78 ++++- apps/web/app/locales/de/translation.json | 78 ++++- apps/web/app/locales/en/translation.json | 78 ++++- apps/web/app/locales/es/translation.json | 78 ++++- apps/web/app/locales/lt/translation.json | 78 ++++- apps/web/app/locales/pl/translation.json | 78 ++++- .../AiMentorPractice.page.tsx | 190 ++++++++++++ .../Courses/CourseView/CourseCertificate.tsx | 19 +- .../Lesson/AiMentorLesson/AiMentorLesson.tsx | 21 +- .../AiMentorLesson/aiMentorChatTransport.ts | 20 ++ .../Courses/context/CourseAccessProvider.tsx | 9 + .../Dashboard/Home/components/WidgetCard.tsx | 7 +- .../modules/Dashboard/Home/widgetRegistry.ts | 60 ++-- .../widgets/student-ai-mentor-practice.tsx | 95 ++++++ .../Home/widgets/student-certificates.tsx | 212 +++++++++++++ .../widgets/student-continue-learning.tsx | 112 +++++++ .../widgets/student-course-completion.tsx | 150 +++++++++ .../Home/widgets/student-placeholder1.tsx | 25 -- .../Home/widgets/student-placeholder2.tsx | 25 -- .../Home/widgets/student-placeholder3.tsx | 25 -- .../Home/widgets/student-required-course.tsx | 113 +++++++ .../Home/widgets/student-widgets.test.tsx | 289 ++++++++++++++++++ apps/web/routes.ts | 1 + docs/specs/ai-mentor-lessons-business-spec.md | 10 + .../specs/personal-dashboard-business-spec.md | 29 +- packages/prompts/src/schemas/prompt.schema.ts | 7 + .../ai-mentor-practice-generation-prompt.yaml | 20 ++ packages/shared/src/constants/dashboard.ts | 47 ++- 68 files changed, 3179 insertions(+), 181 deletions(-) create mode 100644 apps/api/src/ai/ai-practice.queue.service.ts create mode 100644 apps/api/src/ai/ai-practice.schema.ts create mode 100644 apps/api/src/ai/ai-practice.types.ts create mode 100644 apps/api/src/ai/ai-practice.worker.ts create mode 100644 apps/api/src/ai/handlers/ai-practice-requested.handler.ts create mode 100644 apps/api/src/ai/services/ai-practice.service.ts create mode 100644 apps/api/src/courses/schemas/studentDashboard.schema.ts create mode 100644 apps/api/src/events/ai/ai-mentor-practice-requested.event.ts create mode 100644 apps/api/src/storage/migrations/0175_add_student_dashboard_tiles.sql create mode 100644 apps/api/src/storage/migrations/0176_migrate_student_dashboard_widget_ids.sql create mode 100644 apps/web/app/api/mutations/useCreateAiMentorPractice.ts create mode 100644 apps/web/app/api/mutations/useMarkCourseOpened.ts create mode 100644 apps/web/app/api/mutations/useRetryAiMentorPractice.ts create mode 100644 apps/web/app/api/queries/useAiMentorPractice.ts create mode 100644 apps/web/app/api/queries/useAiMentorPracticeToday.ts create mode 100644 apps/web/app/api/queries/useCertificateDashboardSummary.ts create mode 100644 apps/web/app/api/queries/useDashboardCertificates.ts create mode 100644 apps/web/app/api/queries/useStudentDashboardSummary.ts create mode 100644 apps/web/app/modules/AiMentorPractice/AiMentorPractice.page.tsx create mode 100644 apps/web/app/modules/Courses/Lesson/AiMentorLesson/aiMentorChatTransport.ts create mode 100644 apps/web/app/modules/Dashboard/Home/widgets/student-ai-mentor-practice.tsx create mode 100644 apps/web/app/modules/Dashboard/Home/widgets/student-certificates.tsx create mode 100644 apps/web/app/modules/Dashboard/Home/widgets/student-continue-learning.tsx create mode 100644 apps/web/app/modules/Dashboard/Home/widgets/student-course-completion.tsx delete mode 100644 apps/web/app/modules/Dashboard/Home/widgets/student-placeholder1.tsx delete mode 100644 apps/web/app/modules/Dashboard/Home/widgets/student-placeholder2.tsx delete mode 100644 apps/web/app/modules/Dashboard/Home/widgets/student-placeholder3.tsx create mode 100644 apps/web/app/modules/Dashboard/Home/widgets/student-required-course.tsx create mode 100644 apps/web/app/modules/Dashboard/Home/widgets/student-widgets.test.tsx create mode 100644 packages/prompts/src/templates/ai-mentor-practice-generation-prompt.yaml diff --git a/apps/api/src/ai/ai-practice.queue.service.ts b/apps/api/src/ai/ai-practice.queue.service.ts new file mode 100644 index 0000000000..eeb5d5ac53 --- /dev/null +++ b/apps/api/src/ai/ai-practice.queue.service.ts @@ -0,0 +1,27 @@ +import { Injectable } from "@nestjs/common"; + +import { QUEUE_NAMES, QueueService } from "src/queue"; + +import type { Job } from "bullmq"; +import type { AiMentorPracticeJobData } from "src/ai/ai-practice.types"; + +export const AI_MENTOR_PRACTICE_JOB_NAME = "generate-ai-mentor-practice"; + +@Injectable() +export class AiPracticeQueueService { + constructor(private readonly queueService: QueueService) {} + + enqueue(data: AiMentorPracticeJobData): Promise> { + return this.queueService.enqueue( + QUEUE_NAMES.AI_MENTOR_PRACTICE, + AI_MENTOR_PRACTICE_JOB_NAME, + data, + { + attempts: 3, + backoff: { type: "exponential", delay: 1000 }, + removeOnComplete: true, + removeOnFail: false, + }, + ); + } +} diff --git a/apps/api/src/ai/ai-practice.schema.ts b/apps/api/src/ai/ai-practice.schema.ts new file mode 100644 index 0000000000..60f34b02e5 --- /dev/null +++ b/apps/api/src/ai/ai-practice.schema.ts @@ -0,0 +1,42 @@ +import { SUPPORTED_LANGUAGES } from "@repo/shared"; +import { Type, type Static } from "@sinclair/typebox"; + +import { AI_MENTOR_PRACTICE_STATUSES } from "src/ai/ai-practice.types"; +import { UUIDSchema } from "src/common"; + +const practiceAnswerSchema = Type.String({ minLength: 1, maxLength: 1000 }); + +export const createAiMentorPracticeSchema = Type.Object({ + timezone: Type.String({ minLength: 1, maxLength: 100 }), + language: Type.Enum(SUPPORTED_LANGUAGES), + challenge: practiceAnswerSchema, + counterpart: practiceAnswerSchema, + desiredOutcome: practiceAnswerSchema, +}); + +export const aiMentorPracticeQuerySchema = Type.Object({ + timezone: Type.String({ minLength: 1, maxLength: 100 }), +}); + +export const aiMentorPracticeSessionSchema = Type.Object({ + id: UUIDSchema, + practiceDate: Type.String(), + timezone: Type.String(), + language: Type.Enum(SUPPORTED_LANGUAGES), + challenge: Type.String(), + counterpart: Type.String(), + desiredOutcome: Type.String(), + title: Type.Union([Type.String(), Type.Null()]), + instructions: Type.Union([Type.String(), Type.Null()]), + threadId: Type.Union([UUIDSchema, Type.Null()]), + status: Type.Enum(AI_MENTOR_PRACTICE_STATUSES), + errorCode: Type.Union([Type.String(), Type.Null()]), +}); + +export const nullableAiMentorPracticeSessionSchema = Type.Union([ + aiMentorPracticeSessionSchema, + Type.Null(), +]); + +export type CreateAiMentorPracticeBody = Static; +export type AiMentorPracticeSessionResponse = Static; diff --git a/apps/api/src/ai/ai-practice.types.ts b/apps/api/src/ai/ai-practice.types.ts new file mode 100644 index 0000000000..fea3c2fb2f --- /dev/null +++ b/apps/api/src/ai/ai-practice.types.ts @@ -0,0 +1,24 @@ +import type { SupportedLanguages } from "@repo/shared"; +import type { UUIDType } from "src/common"; + +export const AI_MENTOR_PRACTICE_STATUSES = { + QUEUED: "queued", + PROCESSING: "processing", + READY: "ready", + FAILED: "failed", +} as const; + +export type AiMentorPracticeStatus = + (typeof AI_MENTOR_PRACTICE_STATUSES)[keyof typeof AI_MENTOR_PRACTICE_STATUSES]; + +export type AiMentorPracticeJobData = { + tenantId: UUIDType; + sessionId: UUIDType; +}; + +export type AiMentorPracticeGenerationInput = { + challenge: string; + counterpart: string; + desiredOutcome: string; + language: SupportedLanguages; +}; diff --git a/apps/api/src/ai/ai-practice.worker.ts b/apps/api/src/ai/ai-practice.worker.ts new file mode 100644 index 0000000000..2b45043143 --- /dev/null +++ b/apps/api/src/ai/ai-practice.worker.ts @@ -0,0 +1,52 @@ +import { + Injectable, + InternalServerErrorException, + Logger, + type OnModuleDestroy, +} from "@nestjs/common"; +import { Worker } from "bullmq"; + +import { AI_MENTOR_PRACTICE_JOB_NAME } from "src/ai/ai-practice.queue.service"; +import { AiPracticeService } from "src/ai/services/ai-practice.service"; +import { QUEUE_NAMES, QueueService } from "src/queue"; +import { TenantDbRunnerService } from "src/storage/db/tenant-db-runner.service"; + +import type { Job } from "bullmq"; +import type { AiMentorPracticeJobData } from "src/ai/ai-practice.types"; + +@Injectable() +export class AiPracticeWorker implements OnModuleDestroy { + private readonly logger = new Logger(AiPracticeWorker.name); + private readonly worker: Worker; + + constructor( + private readonly queueService: QueueService, + private readonly practiceService: AiPracticeService, + private readonly tenantRunner: TenantDbRunnerService, + ) { + this.worker = new Worker( + QUEUE_NAMES.AI_MENTOR_PRACTICE, + (job) => this.process(job), + { + connection: this.queueService.getConnection(), + concurrency: Number(process.env.AI_MENTOR_PRACTICE_WORKER_CONCURRENCY || 2), + }, + ); + this.worker.on("failed", (job, error) => { + this.logger.error(`AI Mentor practice job ${job?.id} failed: ${error.message}`); + }); + } + + private async process(job: Job) { + if (job.name !== AI_MENTOR_PRACTICE_JOB_NAME) + throw new InternalServerErrorException(`Unexpected AI practice job name: ${job.name}`); + + await this.tenantRunner.runWithTenant(job.data.tenantId, () => + this.practiceService.processGenerationJob(job.data), + ); + } + + async onModuleDestroy() { + await this.worker.close(); + } +} diff --git a/apps/api/src/ai/ai.controller.ts b/apps/api/src/ai/ai.controller.ts index f80408f343..39c4d77ae1 100644 --- a/apps/api/src/ai/ai.controller.ts +++ b/apps/api/src/ai/ai.controller.ts @@ -4,6 +4,13 @@ import { Type } from "@sinclair/typebox"; import { Response } from "express"; import { Validate } from "nestjs-typebox"; +import { + aiMentorPracticeSessionSchema, + createAiMentorPracticeSchema, + nullableAiMentorPracticeSessionSchema, + type CreateAiMentorPracticeBody, +} from "src/ai/ai-practice.schema"; +import { AiPracticeService } from "src/ai/services/ai-practice.service"; import { AiService } from "src/ai/services/ai.service"; import { ThreadService } from "src/ai/services/thread.service"; import { loadAiSdk } from "src/ai/utils/ai-esm"; @@ -18,7 +25,7 @@ import { streamChatSchema, } from "src/ai/utils/ai.schema"; import { OPENAI_MODELS } from "src/ai/utils/ai.type"; -import { type BaseResponse, baseResponse, UUIDSchema, UUIDType } from "src/common"; +import { BaseResponse, baseResponse, UUIDSchema, UUIDType } from "src/common"; import { RequirePermission } from "src/common/decorators/require-permission.decorator"; import { CurrentUser } from "src/common/decorators/user.decorator"; import { CurrentUserType } from "src/common/types/current-user.type"; @@ -28,8 +35,55 @@ export class AiController { constructor( private readonly threadService: ThreadService, private readonly aiService: AiService, + private readonly aiPracticeService: AiPracticeService, ) {} + @Get("practice/today") + @RequirePermission(PERMISSIONS.AI_USE) + @Validate({ + request: [{ type: "query", name: "timezone", schema: Type.String() }], + response: baseResponse(nullableAiMentorPracticeSessionSchema), + }) + async getTodayPractice( + @Query("timezone") timezone: string, + @CurrentUser() currentUser: CurrentUserType, + ) { + return new BaseResponse(await this.aiPracticeService.getToday(timezone, currentUser)); + } + + @Post("practice") + @RequirePermission(PERMISSIONS.AI_USE) + @Validate({ + request: [{ type: "body", schema: createAiMentorPracticeSchema }], + response: baseResponse(aiMentorPracticeSessionSchema), + }) + async createPractice( + @Body() body: CreateAiMentorPracticeBody, + @CurrentUser() currentUser: CurrentUserType, + ) { + return new BaseResponse(await this.aiPracticeService.create(body, currentUser)); + } + + @Get("practice/:id") + @RequirePermission(PERMISSIONS.AI_USE) + @Validate({ + request: [{ type: "param", name: "id", schema: UUIDSchema }], + response: baseResponse(aiMentorPracticeSessionSchema), + }) + async getPractice(@Param("id") id: UUIDType, @CurrentUser() currentUser: CurrentUserType) { + return new BaseResponse(await this.aiPracticeService.getById(id, currentUser)); + } + + @Post("practice/:id/retry") + @RequirePermission(PERMISSIONS.AI_USE) + @Validate({ + request: [{ type: "param", name: "id", schema: UUIDSchema }], + response: baseResponse(aiMentorPracticeSessionSchema), + }) + async retryPractice(@Param("id") id: UUIDType, @CurrentUser() currentUser: CurrentUserType) { + return new BaseResponse(await this.aiPracticeService.retry(id, currentUser)); + } + @Get("thread") @RequirePermission(PERMISSIONS.AI_USE) @Validate({ diff --git a/apps/api/src/ai/ai.module.ts b/apps/api/src/ai/ai.module.ts index 78ef0965d6..84211e7c8b 100644 --- a/apps/api/src/ai/ai.module.ts +++ b/apps/api/src/ai/ai.module.ts @@ -1,11 +1,16 @@ import { Module } from "@nestjs/common"; +import { CqrsModule } from "@nestjs/cqrs"; +import { AiPracticeQueueService } from "src/ai/ai-practice.queue.service"; +import { AiPracticeWorker } from "src/ai/ai-practice.worker"; import { AiController } from "src/ai/ai.controller"; import { AiJudgeConfigurationGenerationWorkflowService } from "src/ai/judge-configuration-generation/services/ai-judge-configuration-generation-workflow.service"; import { AiJudgeConfigurationGeneratorService } from "src/ai/judge-configuration-generation/services/ai-judge-configuration-generator.service"; import { AiJudgeConfigurationValidatorService } from "src/ai/judge-configuration-generation/services/ai-judge-configuration-validator.service"; +import { AiPracticeRequestedHandler } from "src/ai/handlers/ai-practice-requested.handler"; import { AiRepository } from "src/ai/repositories/ai.repository"; import { RagRepository } from "src/ai/repositories/rag.repository"; +import { AiPracticeService } from "src/ai/services/ai-practice.service"; import { AiRuntimeService } from "src/ai/services/ai-runtime.service"; import { AiService } from "src/ai/services/ai.service"; import { ChatService } from "src/ai/services/chat.service"; @@ -21,7 +26,7 @@ import { PermissionsModule } from "src/permissions/permissions.module"; import { StudentLessonProgressModule } from "src/studentLessonProgress/studentLessonProgress.module"; @Module({ - imports: [StudentLessonProgressModule, LocalizationModule, PermissionsModule], + imports: [CqrsModule, StudentLessonProgressModule, LocalizationModule, PermissionsModule], controllers: [AiController], providers: [ AiJudgeConfigurationGeneratorService, @@ -39,6 +44,10 @@ import { StudentLessonProgressModule } from "src/studentLessonProgress/studentLe SummaryService, RagService, RagRepository, + AiPracticeService, + AiPracticeQueueService, + AiPracticeWorker, + AiPracticeRequestedHandler, ], exports: [ AiJudgeConfigurationGenerationWorkflowService, diff --git a/apps/api/src/ai/handlers/ai-practice-requested.handler.ts b/apps/api/src/ai/handlers/ai-practice-requested.handler.ts new file mode 100644 index 0000000000..0b2e733099 --- /dev/null +++ b/apps/api/src/ai/handlers/ai-practice-requested.handler.ts @@ -0,0 +1,13 @@ +import { EventsHandler, type IEventHandler } from "@nestjs/cqrs"; + +import { AiPracticeQueueService } from "src/ai/ai-practice.queue.service"; +import { AiMentorPracticeRequestedEvent } from "src/events"; + +@EventsHandler(AiMentorPracticeRequestedEvent) +export class AiPracticeRequestedHandler implements IEventHandler { + constructor(private readonly queueService: AiPracticeQueueService) {} + + async handle(event: AiMentorPracticeRequestedEvent) { + await this.queueService.enqueue(event.data); + } +} diff --git a/apps/api/src/ai/repositories/ai.repository.ts b/apps/api/src/ai/repositories/ai.repository.ts index 5dda1603bc..246f0944c2 100644 --- a/apps/api/src/ai/repositories/ai.repository.ts +++ b/apps/api/src/ai/repositories/ai.repository.ts @@ -21,6 +21,7 @@ import { aiMentorJudgementCriteria, aiMentorJudgements, aiMentorLessons, + aiMentorPracticeSessions, aiMentorThreadMessages, aiMentorThreads, chapters, @@ -106,11 +107,11 @@ export class AiRepository { const [lessonId] = await this.db .select({ lessonId: lessons.id }) .from(aiMentorThreads) - .innerJoin(aiMentorLessons, eq(aiMentorThreads.aiMentorLessonId, aiMentorLessons.id)) - .innerJoin(lessons, eq(lessons.id, aiMentorLessons.lessonId)) + .leftJoin(aiMentorLessons, eq(aiMentorThreads.aiMentorLessonId, aiMentorLessons.id)) + .leftJoin(lessons, eq(lessons.id, aiMentorLessons.lessonId)) .where(eq(aiMentorThreads.id, threadId)); - return lessonId; + return lessonId ?? { lessonId: null }; } async createThread(data: ThreadBody) { @@ -236,26 +237,103 @@ export class AiRepository { ): Promise { const [lesson] = await this.db .select({ - title: this.localizationService.getLocalizedSqlField(lessons.title, language), - instructions: this.localizationService.getLocalizedSqlField( - aiMentorLessons.aiMentorInstructions, - language, - ), - type: sql`${aiMentorLessons.type}`, - name: this.localizationService.getLocalizedSqlField(aiMentorLessons.name, language), + title: sql`COALESCE( + ${this.localizationService.getLocalizedSqlField(lessons.title, language)}, + ${aiMentorPracticeSessions.generatedTitle} + )`, + instructions: sql`COALESCE( + ${this.localizationService.getLocalizedSqlField( + aiMentorLessons.aiMentorInstructions, + language, + )}, + ${aiMentorPracticeSessions.generatedInstructions} + )`, + type: sql`COALESCE(${aiMentorLessons.type}, 'roleplay')`, + name: sql`COALESCE( + ${this.localizationService.getLocalizedSqlField(aiMentorLessons.name, language)}, + 'AI Mentor' + )`, learnerFirstName: users.firstName, }) .from(aiMentorThreads) - .innerJoin(aiMentorLessons, eq(aiMentorThreads.aiMentorLessonId, aiMentorLessons.id)) + .leftJoin(aiMentorLessons, eq(aiMentorThreads.aiMentorLessonId, aiMentorLessons.id)) .innerJoin(users, eq(users.id, aiMentorThreads.userId)) - .innerJoin(lessons, eq(lessons.id, aiMentorLessons.lessonId)) - .innerJoin(chapters, eq(chapters.id, lessons.chapterId)) - .innerJoin(courses, eq(courses.id, chapters.courseId)) + .leftJoin(lessons, eq(lessons.id, aiMentorLessons.lessonId)) + .leftJoin(chapters, eq(chapters.id, lessons.chapterId)) + .leftJoin(courses, eq(courses.id, chapters.courseId)) + .leftJoin( + aiMentorPracticeSessions, + eq(aiMentorThreads.practiceSessionId, aiMentorPracticeSessions.id), + ) .where(eq(aiMentorThreads.id, threadId)); return lesson; } + async findPracticeSessionByDate(userId: UUIDType, practiceDate: string) { + const [session] = await this.db + .select({ + ...getTableColumns(aiMentorPracticeSessions), + threadId: aiMentorThreads.id, + }) + .from(aiMentorPracticeSessions) + .leftJoin(aiMentorThreads, eq(aiMentorThreads.practiceSessionId, aiMentorPracticeSessions.id)) + .where( + and( + eq(aiMentorPracticeSessions.userId, userId), + eq(aiMentorPracticeSessions.practiceDate, practiceDate), + ), + ); + + return session; + } + + async findPracticeSessionById(sessionId: UUIDType) { + const [session] = await this.db + .select({ + ...getTableColumns(aiMentorPracticeSessions), + threadId: aiMentorThreads.id, + }) + .from(aiMentorPracticeSessions) + .leftJoin(aiMentorThreads, eq(aiMentorThreads.practiceSessionId, aiMentorPracticeSessions.id)) + .where(eq(aiMentorPracticeSessions.id, sessionId)); + + return session; + } + + async createPracticeSession( + data: typeof aiMentorPracticeSessions.$inferInsert, + dbInstance: DatabasePg = this.db, + ) { + const [session] = await dbInstance + .insert(aiMentorPracticeSessions) + .values(data) + .onConflictDoNothing({ + target: [ + aiMentorPracticeSessions.tenantId, + aiMentorPracticeSessions.userId, + aiMentorPracticeSessions.practiceDate, + ], + }) + .returning(); + + return session; + } + + async updatePracticeSession( + sessionId: UUIDType, + data: Partial, + dbInstance: DatabasePg = this.db, + ) { + const [session] = await dbInstance + .update(aiMentorPracticeSessions) + .set(data) + .where(eq(aiMentorPracticeSessions.id, sessionId)) + .returning(); + + return session; + } + async findJudgeRubricByThreadId( threadId: UUIDType, language: SupportedLanguages, diff --git a/apps/api/src/ai/services/ai-practice.service.ts b/apps/api/src/ai/services/ai-practice.service.ts new file mode 100644 index 0000000000..431adce0f6 --- /dev/null +++ b/apps/api/src/ai/services/ai-practice.service.ts @@ -0,0 +1,256 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Inject, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { Type } from "@sinclair/typebox"; + +import { + AI_MENTOR_PRACTICE_STATUSES, + type AiMentorPracticeJobData, +} from "src/ai/ai-practice.types"; +import { AiRepository } from "src/ai/repositories/ai.repository"; +import { AiRuntimeService } from "src/ai/services/ai-runtime.service"; +import { AiService } from "src/ai/services/ai.service"; +import { PromptService } from "src/ai/services/prompt.service"; +import { loadAiSdk } from "src/ai/utils/ai-esm"; +import { OPENAI_MODELS } from "src/ai/utils/ai.type"; +import { DatabasePg, type UUIDType } from "src/common"; +import { EnvService } from "src/env/services/env.service"; +import { AiMentorPracticeRequestedEvent } from "src/events"; +import { OutboxPublisher } from "src/outbox/outbox.publisher"; +import { DB } from "src/storage/db/db.providers"; + +import type { + AiMentorPracticeSessionResponse, + CreateAiMentorPracticeBody, +} from "src/ai/ai-practice.schema"; +import type { CurrentUserType } from "src/common/types/current-user.type"; + +const practiceGenerationSchema = Type.Object({ + title: Type.String({ minLength: 1, maxLength: 160 }), + instructions: Type.String({ minLength: 1, maxLength: 6000 }), +}); + +@Injectable() +export class AiPracticeService { + constructor( + @Inject(DB) private readonly db: DatabasePg, + private readonly aiRepository: AiRepository, + private readonly aiRuntimeService: AiRuntimeService, + private readonly aiService: AiService, + private readonly promptService: PromptService, + private readonly envService: EnvService, + private readonly outboxPublisher: OutboxPublisher, + ) {} + + async getToday( + timezone: string, + currentUser: CurrentUserType, + ): Promise { + const practiceDate = this.getPracticeDate(timezone); + const session = await this.aiRepository.findPracticeSessionByDate( + currentUser.userId, + practiceDate, + ); + + return session ? this.mapSession(session) : null; + } + + async create( + body: CreateAiMentorPracticeBody, + currentUser: CurrentUserType, + ): Promise { + if (!(await this.envService.getAIConfigured()).enabled) + throw new ForbiddenException("dashboardHome.widgets.ai_mentor_practice.aiNotConfigured"); + + const answers = { + challenge: body.challenge.trim(), + counterpart: body.counterpart.trim(), + desiredOutcome: body.desiredOutcome.trim(), + }; + if (Object.values(answers).some((answer) => !answer)) + throw new BadRequestException("common.validation.required"); + + const practiceDate = this.getPracticeDate(body.timezone); + + const session = await this.db.transaction(async (trx) => { + const created = await this.aiRepository.createPracticeSession( + { + userId: currentUser.userId, + practiceDate, + timezone: body.timezone, + language: body.language, + ...answers, + }, + trx, + ); + + if (!created) return null; + + await this.outboxPublisher.publish( + new AiMentorPracticeRequestedEvent({ + tenantId: currentUser.tenantId, + sessionId: created.id, + }), + trx, + ); + + return created; + }); + + if (!session) { + const existing = await this.aiRepository.findPracticeSessionByDate( + currentUser.userId, + practiceDate, + ); + if (!existing) throw new ConflictException("AI Mentor practice already exists"); + return this.mapSession(existing); + } + + return this.mapSession({ ...session, threadId: null }); + } + + async getById( + sessionId: UUIDType, + currentUser: CurrentUserType, + ): Promise { + const session = await this.aiRepository.findPracticeSessionById(sessionId); + if (!session) throw new NotFoundException("AI Mentor practice not found"); + if (session.userId !== currentUser.userId) + throw new ForbiddenException("You don't have access to this AI Mentor practice"); + + return this.mapSession(session); + } + + async retry( + sessionId: UUIDType, + currentUser: CurrentUserType, + ): Promise { + const session = await this.aiRepository.findPracticeSessionById(sessionId); + if (!session) throw new NotFoundException("AI Mentor practice not found"); + if (session.userId !== currentUser.userId) + throw new ForbiddenException("You don't have access to this AI Mentor practice"); + if (session.status !== AI_MENTOR_PRACTICE_STATUSES.FAILED) + throw new ConflictException("Only a failed AI Mentor practice can be retried"); + + const updated = await this.db.transaction(async (trx) => { + const row = await this.aiRepository.updatePracticeSession( + sessionId, + { + status: AI_MENTOR_PRACTICE_STATUSES.QUEUED, + errorCode: null, + }, + trx, + ); + + await this.outboxPublisher.publish( + new AiMentorPracticeRequestedEvent({ + tenantId: currentUser.tenantId, + sessionId, + }), + trx, + ); + + return row; + }); + + return this.mapSession({ ...updated, threadId: session.threadId }); + } + + async processGenerationJob(data: AiMentorPracticeJobData): Promise { + const session = await this.aiRepository.findPracticeSessionById(data.sessionId); + if (!session) throw new NotFoundException("AI Mentor practice not found"); + if (session.status === AI_MENTOR_PRACTICE_STATUSES.READY) return; + + await this.aiRepository.updatePracticeSession(session.id, { + status: AI_MENTOR_PRACTICE_STATUSES.PROCESSING, + errorCode: null, + }); + + try { + const prompt = await this.promptService.loadPrompt("aiMentorPracticeGeneration", { + language: session.language, + challenge: session.challenge, + counterpart: session.counterpart, + desiredOutcome: session.desiredOutcome, + }); + const { generateObject, jsonSchema } = await loadAiSdk(); + const openai = await this.aiRuntimeService.getAISdkOpenAI(); + const { object } = await generateObject({ + model: openai(OPENAI_MODELS.BASIC), + schema: jsonSchema(() => practiceGenerationSchema), + prompt, + }); + const output = object as { title: string; instructions: string }; + + await this.aiRepository.updatePracticeSession(session.id, { + generatedTitle: output.title, + generatedInstructions: output.instructions, + }); + await this.aiService.getPracticeThreadWithSetup({ + practiceSessionId: session.id, + userId: session.userId, + userLanguage: session.language, + }); + await this.aiRepository.updatePracticeSession(session.id, { + status: AI_MENTOR_PRACTICE_STATUSES.READY, + errorCode: null, + }); + } catch (error) { + await this.aiRepository.updatePracticeSession(session.id, { + status: AI_MENTOR_PRACTICE_STATUSES.FAILED, + errorCode: "generation_failed", + }); + throw error; + } + } + + private getPracticeDate(timezone: string): string { + try { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(new Date()); + const values = Object.fromEntries(parts.map((part) => [part.type, part.value])); + return `${values.year}-${values.month}-${values.day}`; + } catch { + throw new BadRequestException("common.validation.invalidTimezone"); + } + } + + private mapSession(session: { + id: string; + practiceDate: string; + timezone: string; + language: string; + challenge: string; + counterpart: string; + desiredOutcome: string; + generatedTitle: string | null; + generatedInstructions: string | null; + status: AiMentorPracticeSessionResponse["status"]; + errorCode: string | null; + threadId?: string | null; + }): AiMentorPracticeSessionResponse { + return { + id: session.id, + practiceDate: session.practiceDate, + timezone: session.timezone, + language: session.language as AiMentorPracticeSessionResponse["language"], + challenge: session.challenge, + counterpart: session.counterpart, + desiredOutcome: session.desiredOutcome, + title: session.generatedTitle, + instructions: session.generatedInstructions, + threadId: session.threadId ?? null, + status: session.status, + errorCode: session.errorCode, + }; + } +} diff --git a/apps/api/src/ai/services/ai.service.ts b/apps/api/src/ai/services/ai.service.ts index e11c55c7d4..490e886e8e 100644 --- a/apps/api/src/ai/services/ai.service.ts +++ b/apps/api/src/ai/services/ai.service.ts @@ -121,6 +121,34 @@ export class AiService { )(); } + async getPracticeThreadWithSetup(data: { + practiceSessionId: UUIDType; + userId: UUIDType; + userLanguage: SupportedLanguages; + }) { + const existingThread = await this.aiRepository.findThread([ + eq(aiMentorThreads.practiceSessionId, data.practiceSessionId), + eq(aiMentorThreads.userId, data.userId), + ]); + + if (existingThread) return existingThread; + + const thread = await this.aiRepository.createThread({ + practiceSessionId: data.practiceSessionId, + userId: data.userId, + userLanguage: data.userLanguage, + status: THREAD_STATUS.ACTIVE, + }); + + const systemPrompt = await this.promptService.setSystemPrompt({ + threadId: thread.id, + userId: thread.userId, + }); + await this.sendWelcomeMessage(thread.id, systemPrompt); + + return thread; + } + async streamMessage( data: AiStreamMessageInput, model: OpenAIModels, @@ -298,15 +326,17 @@ export class AiService { thread.userId, ); - await this.markAsCompletedIfJudge( - lessonId, - thread.userId, - learnerPermissions, - currentUser, - judged.data, - thread.userLanguage, - true, - ); + if (lessonId) { + await this.markAsCompletedIfJudge( + lessonId, + thread.userId, + learnerPermissions, + currentUser, + judged.data, + thread.userLanguage, + true, + ); + } const { status: _status, ...judgeData } = judged.data; diff --git a/apps/api/src/ai/services/prompt.service.ts b/apps/api/src/ai/services/prompt.service.ts index 951d1e68c8..74c0cfcea1 100644 --- a/apps/api/src/ai/services/prompt.service.ts +++ b/apps/api/src/ai/services/prompt.service.ts @@ -124,7 +124,7 @@ export class PromptService implements OnModuleInit { const { chunks: context } = await observe( async () => { - return this.ragService.getContext(contextInfo, lessonId); + return lessonId ? this.ragService.getContext(contextInfo, lessonId) : { chunks: [] }; }, { name: "RAG", asType: "retriever" }, )(); diff --git a/apps/api/src/ai/services/thread.service.ts b/apps/api/src/ai/services/thread.service.ts index 0fd21585c3..d6d29d1fe4 100644 --- a/apps/api/src/ai/services/thread.service.ts +++ b/apps/api/src/ai/services/thread.service.ts @@ -50,7 +50,15 @@ export class ThreadService { if (!thread) throw new NotFoundException("Thread not found"); + if (thread.practiceSessionId) { + if (thread.userId !== userId) + throw new ForbiddenException("You don't have access to this thread"); + + return { data: thread }; + } + const { lessonId } = await this.aiRepository.findLessonIdByThreadId(threadId); + if (!lessonId) throw new NotFoundException("Lesson not found"); const author = await this.aiRepository.getCourseAuthorByLesson(lessonId); diff --git a/apps/api/src/ai/utils/ai.schema.ts b/apps/api/src/ai/utils/ai.schema.ts index 5393e92c64..1f0d13e937 100644 --- a/apps/api/src/ai/utils/ai.schema.ts +++ b/apps/api/src/ai/utils/ai.schema.ts @@ -18,7 +18,8 @@ export const createThreadSchema = Type.Object({ }); export const threadSchema = Type.Object({ - aiMentorLessonId: UUIDSchema, + aiMentorLessonId: Type.Optional(Type.Union([UUIDSchema, Type.Null()])), + practiceSessionId: Type.Optional(Type.Union([UUIDSchema, Type.Null()])), userLanguage: Type.Enum(SUPPORTED_LANGUAGES), userId: UUIDSchema, status: Type.Enum(THREAD_STATUS), @@ -27,7 +28,8 @@ export const updateThreadSchema = Type.Partial(threadSchema); export const responseThreadSchema = Type.Object({ id: UUIDSchema, - aiMentorLessonId: UUIDSchema, + aiMentorLessonId: Type.Union([UUIDSchema, Type.Null()]), + practiceSessionId: Type.Union([UUIDSchema, Type.Null()]), userId: UUIDSchema, userLanguage: Type.Enum(SUPPORTED_LANGUAGES), createdAt: Type.String(), diff --git a/apps/api/src/certificates/certificate.repository.ts b/apps/api/src/certificates/certificate.repository.ts index adf245ca15..90f2c7f21e 100644 --- a/apps/api/src/certificates/certificate.repository.ts +++ b/apps/api/src/certificates/certificate.repository.ts @@ -34,6 +34,7 @@ import { certificates, users, courses, + courseSlugs, studentCourses, tenants, groups, @@ -121,6 +122,61 @@ export class CertificateRepository { return totalItems; } + async getDashboardSummary( + userId: UUIDType, + language: SupportedLanguages, + expiringBefore: string, + ) { + const [{ activeCount }] = await this.db + .select({ activeCount: sql`COUNT(*)::int` }) + .from(certificates) + .where( + and(eq(certificates.userId, userId), eq(certificates.status, CERTIFICATE_STATUSES.ACTIVE)), + ); + + const [expiringSoon] = await this.db + .select({ + certificateId: certificates.id, + courseId: certificates.courseId, + courseShortId: courses.shortId, + courseSlugBase: courseSlugs.slug, + courseTitle: this.localizationService.getLocalizedSqlField(courses.title, language), + expiresAt: sql`${certificates.expiresAt}`, + }) + .from(certificates) + .innerJoin(courses, eq(courses.id, certificates.courseId)) + .leftJoin( + courseSlugs, + and(eq(courseSlugs.courseShortId, courses.shortId), eq(courseSlugs.lang, language)), + ) + .where( + and( + eq(certificates.userId, userId), + eq(certificates.status, CERTIFICATE_STATUSES.ACTIVE), + gt(certificates.expiresAt, sql`CURRENT_TIMESTAMP`), + lte(certificates.expiresAt, expiringBefore), + ), + ) + .orderBy(certificates.expiresAt) + .limit(1); + + return { + activeCount, + expiringSoon: expiringSoon + ? { + certificateId: expiringSoon.certificateId, + courseId: expiringSoon.courseId, + courseSlug: + expiringSoon.courseShortId && expiringSoon.courseSlugBase + ? `${expiringSoon.courseShortId}-${expiringSoon.courseSlugBase}` + : expiringSoon.courseId, + courseTitle: expiringSoon.courseTitle, + expiresAt: expiringSoon.expiresAt, + } + : null, + }; + } + async findUserById(userId: string, trx?: DatabasePg) { const dbInstance = trx || this.db; diff --git a/apps/api/src/certificates/certificates.controller.ts b/apps/api/src/certificates/certificates.controller.ts index 8e77c2e2af..22a0b4e5a6 100644 --- a/apps/api/src/certificates/certificates.controller.ts +++ b/apps/api/src/certificates/certificates.controller.ts @@ -4,7 +4,14 @@ import { Type } from "@sinclair/typebox"; import { Request, Response } from "express"; import { Validate } from "nestjs-typebox"; -import { PaginatedResponse, paginatedResponse, UUIDSchema, UUIDType } from "src/common"; +import { + BaseResponse, + baseResponse, + PaginatedResponse, + paginatedResponse, + UUIDSchema, + UUIDType, +} from "src/common"; import { Public } from "src/common/decorators/public.decorator"; import { RequirePermission } from "src/common/decorators/require-permission.decorator"; import { CurrentUser } from "src/common/decorators/user.decorator"; @@ -19,6 +26,7 @@ import { certificateResetUsersSchema, certificateValidityImpactResponseSchema, certificateValidityImpactSchema, + certificateDashboardSummarySchema, certificateShareLinkResponseSchema, createCertificateShareLinkSchema, downloadCertificateSchema, @@ -39,6 +47,7 @@ import type { CertificateResetOptionsResponse, CertificateResetUsersResponse, CertificateValidityImpactResponse, + CertificateDashboardSummary, CertificateShareLinkResponse, ResetCourseCertificatesResponse, SingleCertificateResponse, @@ -78,6 +87,19 @@ export class CertificatesController { return new PaginatedResponse(data); } + @Get("dashboard-summary") + @RequirePermission(PERMISSIONS.CERTIFICATE_READ) + @Validate({ + request: [{ type: "query", name: "language", schema: supportedLanguagesSchema }], + response: baseResponse(certificateDashboardSummarySchema), + }) + async getDashboardSummary( + @Query("language") language: SupportedLanguages, + @CurrentUser("userId") userId: UUIDType, + ): Promise> { + return new BaseResponse(await this.certificatesService.getDashboardSummary(userId, language)); + } + @Get("certificate") @RequirePermission(PERMISSIONS.CERTIFICATE_READ) @Validate({ diff --git a/apps/api/src/certificates/certificates.schema.ts b/apps/api/src/certificates/certificates.schema.ts index 6ba073146c..3f450abb69 100644 --- a/apps/api/src/certificates/certificates.schema.ts +++ b/apps/api/src/certificates/certificates.schema.ts @@ -77,4 +77,18 @@ export const certificateValidityImpactSchema = Type.Object({ export const allCertificatesSchema = Type.Array(certificateSchema); export const singleCertificateSchema = Type.Union([certificateSchema, Type.Null()]); +export const certificateDashboardSummarySchema = Type.Object({ + activeCount: Type.Number(), + expiringSoon: Type.Union([ + Type.Object({ + certificateId: UUIDSchema, + courseId: UUIDSchema, + courseSlug: Type.String(), + courseTitle: Type.String(), + expiresAt: Type.String(), + }), + Type.Null(), + ]), +}); + export const paginatedCertificatesSchema = paginatedResponse(allCertificatesSchema); diff --git a/apps/api/src/certificates/certificates.service.ts b/apps/api/src/certificates/certificates.service.ts index 52045bf059..81d8885a91 100644 --- a/apps/api/src/certificates/certificates.service.ts +++ b/apps/api/src/certificates/certificates.service.ts @@ -65,6 +65,7 @@ import type { CertificateActivityRecord, CertificateArchiveTarget, CertificateExpirationWarningRecord, + CertificateDashboardSummary, CertificateNotificationRecord, CertificateResetUsersQuery, CertificateResetUsersResult, @@ -142,6 +143,17 @@ export class CertificatesService implements OnModuleDestroy { } } + async getDashboardSummary( + userId: UUIDType, + language: SupportedLanguages, + ): Promise { + return this.certificateRepository.getDashboardSummary( + userId, + language, + addDays(new Date(), 30).toISOString(), + ); + } + async createCertificate(userId: UUIDType, courseId: UUIDType, trx?: DatabasePg) { try { const executeInTransaction = async (transactionInstance: DatabasePg) => { diff --git a/apps/api/src/certificates/certificates.types.ts b/apps/api/src/certificates/certificates.types.ts index 12876d4ee6..e89a2a8d4e 100644 --- a/apps/api/src/certificates/certificates.types.ts +++ b/apps/api/src/certificates/certificates.types.ts @@ -6,6 +6,7 @@ import type { certificateSchema, certificateValidityImpactResponseSchema, certificateValidityImpactSchema, + certificateDashboardSummarySchema, createCertificateShareLinkSchema, downloadCertificateSchema, resetCourseCertificatesResponseSchema, @@ -32,6 +33,7 @@ export type CertificateValidityImpactResponse = Static< >; export type AllCertificatesResponse = Static; +export type CertificateDashboardSummary = Static; export type CertificatesQuery = { userId: UUIDType; diff --git a/apps/api/src/courses/course.controller.ts b/apps/api/src/courses/course.controller.ts index 163f7d6cd4..f52ec6c79e 100644 --- a/apps/api/src/courses/course.controller.ts +++ b/apps/api/src/courses/course.controller.ts @@ -97,6 +97,7 @@ import { commonShowBetaCourseSchema, commonShowCourseSchema, } from "src/courses/schemas/showCourseCommon.schema"; +import { studentCourseDashboardSummarySchema } from "src/courses/schemas/studentDashboard.schema"; import { UpdateCourseBody, updateCourseSchema } from "src/courses/schemas/updateCourse.schema"; import { updateCourseMediaSchema, @@ -264,6 +265,35 @@ export class CourseController { return new PaginatedResponse(data); } + @Get("dashboard-summary") + @RequirePermission(PERMISSIONS.COURSE_READ_ASSIGNED) + @Validate({ + request: [{ type: "query", name: "language", schema: supportedLanguagesSchema }], + response: baseResponse(studentCourseDashboardSummarySchema), + }) + async getStudentDashboardSummary( + @Query("language") language: SupportedLanguages, + @CurrentUser("userId") currentUserId: UUIDType, + ) { + return new BaseResponse( + await this.courseService.getStudentDashboardSummary(currentUserId, language), + ); + } + + @Post(":courseId/open") + @RequirePermission(PERMISSIONS.COURSE_READ_ASSIGNED) + @Validate({ + request: [{ type: "param", name: "courseId", schema: UUIDSchema }], + response: baseResponse(Type.Object({})), + }) + async markCourseOpened( + @Param("courseId") courseId: UUIDType, + @CurrentUser("userId") currentUserId: UUIDType, + ) { + await this.courseService.markCourseOpened(courseId, currentUserId); + return new BaseResponse({}); + } + @RequirePermission(PERMISSIONS.COURSE_ENROLLMENT) @Get(":courseId/students") @Validate(studentsWithEnrolmentValidation) diff --git a/apps/api/src/courses/course.service.ts b/apps/api/src/courses/course.service.ts index d5086b9382..4b6c3c1d40 100644 --- a/apps/api/src/courses/course.service.ts +++ b/apps/api/src/courses/course.service.ts @@ -195,6 +195,7 @@ import type { CreateCourseBody } from "./schemas/createCourse.schema"; import type { CreateCoursesEnrollment } from "./schemas/createCoursesEnrollment"; import type { StudentCourseSelect } from "./schemas/enrolledStudent.schema"; import type { CommonShowBetaCourse, CommonShowCourse } from "./schemas/showCourseCommon.schema"; +import type { StudentCourseDashboardSummary } from "./schemas/studentDashboard.schema"; import type { UpdateCourseBody } from "./schemas/updateCourse.schema"; import type { UpdateCourseMediaBody } from "./schemas/updateCourseMedia.schema"; import type { UpdateCourseSettings } from "./schemas/updateCourseSettings.schema"; @@ -508,6 +509,189 @@ export class CourseService { }); } + async markCourseOpened(courseId: UUIDType, userId: UUIDType): Promise { + const [updatedEnrollment] = await this.db + .update(studentCourses) + .set({ lastOpenedAt: sql`CURRENT_TIMESTAMP` }) + .where( + and( + eq(studentCourses.courseId, courseId), + eq(studentCourses.studentId, userId), + eq(studentCourses.status, COURSE_ENROLLMENT.ENROLLED), + ), + ) + .returning({ id: studentCourses.id }); + + if (!updatedEnrollment) throw new ForbiddenException("common.toast.courseAccessDenied"); + } + + async getStudentDashboardSummary( + userId: UUIDType, + language: SupportedLanguages, + ): Promise { + const continueCourses = await this.db + .select({ + courseId: courses.id, + title: this.localizationService.getLocalizedSqlField(courses.title, language), + thumbnailS3Key: courses.thumbnailS3Key, + completedChapterCount: studentCourses.finishedChapterCount, + courseChapterCount: courses.chapterCount, + }) + .from(studentCourses) + .innerJoin(courses, eq(courses.id, studentCourses.courseId)) + .where( + and( + eq(studentCourses.studentId, userId), + eq(studentCourses.status, COURSE_ENROLLMENT.ENROLLED), + eq(studentCourses.progress, PROGRESS_STATUSES.IN_PROGRESS), + isNull(studentCourses.completedAt), + inArray(courses.status, ["published", "private"]), + ), + ) + .orderBy(desc(studentCourses.lastOpenedAt), desc(studentCourses.updatedAt)); + + const requiredCourses = await this.db + .select({ + courseId: courses.id, + title: this.localizationService.getLocalizedSqlField(courses.title, language), + dueDate: sql`${groupCourses.dueDate}`, + }) + .from(studentCourses) + .innerJoin(courses, eq(courses.id, studentCourses.courseId)) + .innerJoin( + groupCourses, + and( + eq(groupCourses.courseId, studentCourses.courseId), + eq(groupCourses.groupId, studentCourses.enrolledByGroupId), + ), + ) + .where( + and( + eq(studentCourses.studentId, userId), + eq(studentCourses.status, COURSE_ENROLLMENT.ENROLLED), + isNull(studentCourses.completedAt), + eq(groupCourses.isMandatory, true), + inArray(courses.status, ["published", "private"]), + ), + ) + .orderBy(groupCourses.dueDate, courses.title); + + const [completion] = await this.db + .select({ + total: sql`COUNT(*)::int`, + completed: sql`COUNT(*) FILTER (WHERE ${studentCourses.completedAt} IS NOT NULL)::int`, + inProgress: sql`COUNT(*) FILTER ( + WHERE ${studentCourses.completedAt} IS NULL + AND ${studentCourses.progress} = ${PROGRESS_STATUSES.IN_PROGRESS} + )::int`, + notStarted: sql`COUNT(*) FILTER ( + WHERE ${studentCourses.completedAt} IS NULL + AND ${studentCourses.progress} <> ${PROGRESS_STATUSES.IN_PROGRESS} + )::int`, + }) + .from(studentCourses) + .where( + and( + eq(studentCourses.studentId, userId), + eq(studentCourses.status, COURSE_ENROLLMENT.ENROLLED), + ), + ); + + const continueCourseIds = continueCourses.map((course) => course.courseId); + const requiredCourseIds = requiredCourses.map((course) => course.courseId); + const courseIds = [...new Set([...continueCourseIds, ...requiredCourseIds])]; + const slugs = await this.courseSlugService.getCoursesSlugs(language, courseIds); + + const nextLessons = + continueCourseIds.length > 0 + ? await this.db + .select({ + courseId: chapters.courseId, + id: lessons.id, + title: this.localizationService.getLocalizedSqlField(lessons.title, language), + }) + .from(lessons) + .innerJoin(chapters, eq(chapters.id, lessons.chapterId)) + .innerJoin(courses, eq(courses.id, chapters.courseId)) + .leftJoin( + studentLessonProgress, + and( + eq(studentLessonProgress.lessonId, lessons.id), + eq(studentLessonProgress.chapterId, chapters.id), + eq(studentLessonProgress.studentId, userId), + ), + ) + .where( + and( + inArray(chapters.courseId, continueCourseIds), + sql`NOT ( + ${studentLessonProgress.completedAt} IS NOT NULL + AND ( + ${studentLessonProgress.isQuizPassed} IS TRUE + OR ${studentLessonProgress.isQuizPassed} IS NULL + ) + )`, + ), + ) + .orderBy(chapters.courseId, chapters.displayOrder, lessons.displayOrder) + : []; + const nextLessonByCourse = new Map(); + + for (const { courseId, id, title } of nextLessons) { + if (!nextLessonByCourse.has(courseId)) { + nextLessonByCourse.set(courseId, { id, title }); + } + } + + const continueLearningCourses = await Promise.all( + continueCourses.map(async (course) => ({ + courseId: course.courseId, + slug: slugs.get(course.courseId) ?? course.courseId, + title: course.title, + thumbnailUrl: course.thumbnailS3Key + ? await this.getSignedCourseThumbnailUrl(course.thumbnailS3Key) + : null, + completedChapterCount: course.completedChapterCount, + courseChapterCount: course.courseChapterCount, + lesson: nextLessonByCourse.get(course.courseId) ?? null, + })), + ); + const dueSoonBoundary = addDays(new Date(), 7).getTime(); + + const total = completion?.total ?? 0; + const completed = completion?.completed ?? 0; + + return { + continueLearningCourses, + requiredCourses: requiredCourses.map((course) => { + let urgency: StudentCourseDashboardSummary["requiredCourses"][number]["urgency"] = + "noDeadline"; + + if (course.dueDate) { + const dueDate = new Date(course.dueDate).getTime(); + if (dueDate < Date.now()) urgency = "overdue"; + else if (dueDate <= dueSoonBoundary) urgency = "dueSoon"; + else urgency = "scheduled"; + } + + return { + courseId: course.courseId, + slug: slugs.get(course.courseId) ?? course.courseId, + title: course.title, + dueDate: course.dueDate, + urgency, + }; + }), + completion: { + total, + completed, + inProgress: completion?.inProgress ?? 0, + notStarted: completion?.notStarted ?? 0, + percentage: total ? Math.round((completed / total) * 100) : 0, + }, + }; + } + async getStudentsWithEnrollmentDate(query: EnrolledStudentsQuery) { const { courseId, filters = {}, language, page = 1, perPage = DEFAULT_PAGE_SIZE } = query; const { keyword, sort = EnrolledStudentSortFields.enrolledAt } = filters; diff --git a/apps/api/src/courses/schemas/studentDashboard.schema.ts b/apps/api/src/courses/schemas/studentDashboard.schema.ts new file mode 100644 index 0000000000..4c8722f7c7 --- /dev/null +++ b/apps/api/src/courses/schemas/studentDashboard.schema.ts @@ -0,0 +1,50 @@ +import { Type, type Static } from "@sinclair/typebox"; + +import { UUIDSchema } from "src/common"; + +const dashboardCourseBaseSchema = Type.Object({ + courseId: UUIDSchema, + slug: Type.String(), + title: Type.String(), +}); + +export const continueLearningCourseSchema = Type.Object({ + ...dashboardCourseBaseSchema.properties, + thumbnailUrl: Type.Union([Type.String(), Type.Null()]), + completedChapterCount: Type.Number(), + courseChapterCount: Type.Number(), + lesson: Type.Union([ + Type.Object({ + id: UUIDSchema, + title: Type.Union([Type.String(), Type.Null()]), + }), + Type.Null(), + ]), +}); + +export const requiredDashboardCourseSchema = Type.Object({ + ...dashboardCourseBaseSchema.properties, + dueDate: Type.Union([Type.String(), Type.Null()]), + urgency: Type.Union([ + Type.Literal("overdue"), + Type.Literal("dueSoon"), + Type.Literal("scheduled"), + Type.Literal("noDeadline"), + ]), +}); + +export const studentCourseCompletionSchema = Type.Object({ + total: Type.Number(), + completed: Type.Number(), + inProgress: Type.Number(), + notStarted: Type.Number(), + percentage: Type.Number(), +}); + +export const studentCourseDashboardSummarySchema = Type.Object({ + continueLearningCourses: Type.Array(continueLearningCourseSchema), + requiredCourses: Type.Array(requiredDashboardCourseSchema), + completion: studentCourseCompletionSchema, +}); + +export type StudentCourseDashboardSummary = Static; diff --git a/apps/api/src/events/ai/ai-mentor-practice-requested.event.ts b/apps/api/src/events/ai/ai-mentor-practice-requested.event.ts new file mode 100644 index 0000000000..5b2b7f32fb --- /dev/null +++ b/apps/api/src/events/ai/ai-mentor-practice-requested.event.ts @@ -0,0 +1,10 @@ +import type { UUIDType } from "src/common"; + +export class AiMentorPracticeRequestedEvent { + constructor( + public readonly data: { + tenantId: UUIDType; + sessionId: UUIDType; + }, + ) {} +} diff --git a/apps/api/src/events/index.ts b/apps/api/src/events/index.ts index a98173188f..c244dbd67b 100644 --- a/apps/api/src/events/index.ts +++ b/apps/api/src/events/index.ts @@ -96,3 +96,4 @@ export * from "./scorm/update-scorm.event"; export * from "./scorm/delete-scorm.event"; export * from "./scorm/play-scorm.event"; export * from "./scorm/complete-scorm.event"; +export * from "./ai/ai-mentor-practice-requested.event"; diff --git a/apps/api/src/queue/queue.types.ts b/apps/api/src/queue/queue.types.ts index 81846ad046..866d63296a 100644 --- a/apps/api/src/queue/queue.types.ts +++ b/apps/api/src/queue/queue.types.ts @@ -14,6 +14,7 @@ export const QUEUE_NAMES = { LUMA_COURSE_GENERATION_SYNC: "luma-course-generation-sync", AI_JUDGE_CONFIGURATION_GENERATION: "ai-judge-configuration-generation", MICROSOFT_CALENDAR_SYNC: "microsoft-calendar-sync", + AI_MENTOR_PRACTICE: "ai-mentor-practice", } as const; export type QueueName = (typeof QUEUE_NAMES)[keyof typeof QUEUE_NAMES]; diff --git a/apps/api/src/settings/__tests__/settings.controller.e2e-spec.ts b/apps/api/src/settings/__tests__/settings.controller.e2e-spec.ts index 62b242c37f..04f878030d 100644 --- a/apps/api/src/settings/__tests__/settings.controller.e2e-spec.ts +++ b/apps/api/src/settings/__tests__/settings.controller.e2e-spec.ts @@ -101,7 +101,7 @@ describe("SettingsController (e2e)", () => { const dashboard = { widgets: [ { - id: DASHBOARD_WIDGET_IDS.STUDENT_PLACEHOLDER1, + id: DASHBOARD_WIDGET_IDS.STUDENT_CONTINUE_LEARNING, order: 0, width: DASHBOARD_WIDGET_WIDTHS.MEDIUM, }, @@ -125,7 +125,7 @@ describe("SettingsController (e2e)", () => { dashboard: { widgets: [ { - id: DASHBOARD_WIDGET_IDS.STUDENT_PLACEHOLDER1, + id: DASHBOARD_WIDGET_IDS.STUDENT_CONTINUE_LEARNING, order: 0, width: DASHBOARD_WIDGET_WIDTHS.SMALL, }, @@ -252,17 +252,17 @@ describe("SettingsController (e2e)", () => { expect(response.body.data.dashboard).toEqual({ widgets: [ { - id: DASHBOARD_WIDGET_IDS.STUDENT_PLACEHOLDER1, + id: DASHBOARD_WIDGET_IDS.STUDENT_CONTINUE_LEARNING, order: 1, width: DASHBOARD_WIDGET_WIDTHS.MEDIUM, }, { - id: DASHBOARD_WIDGET_IDS.STUDENT_PLACEHOLDER2, + id: DASHBOARD_WIDGET_IDS.STUDENT_REQUIRED_COURSE, order: 2, width: DASHBOARD_WIDGET_WIDTHS.SMALL, }, { - id: DASHBOARD_WIDGET_IDS.STUDENT_PLACEHOLDER3, + id: DASHBOARD_WIDGET_IDS.STUDENT_COURSE_COMPLETION, order: 3, width: DASHBOARD_WIDGET_WIDTHS.SMALL, }, @@ -291,9 +291,10 @@ describe("SettingsController (e2e)", () => { .expect(200); expect(response.body.data).toEqual([ - DASHBOARD_WIDGET_IDS.STUDENT_PLACEHOLDER1, - DASHBOARD_WIDGET_IDS.STUDENT_PLACEHOLDER2, - DASHBOARD_WIDGET_IDS.STUDENT_PLACEHOLDER3, + DASHBOARD_WIDGET_IDS.STUDENT_CONTINUE_LEARNING, + DASHBOARD_WIDGET_IDS.STUDENT_REQUIRED_COURSE, + DASHBOARD_WIDGET_IDS.STUDENT_COURSE_COMPLETION, + DASHBOARD_WIDGET_IDS.STUDENT_CERTIFICATES, ]); }); @@ -305,17 +306,17 @@ describe("SettingsController (e2e)", () => { expect(response.body.data).toEqual([ { - id: DASHBOARD_WIDGET_IDS.STUDENT_PLACEHOLDER1, + id: DASHBOARD_WIDGET_IDS.STUDENT_CONTINUE_LEARNING, order: 1, width: DASHBOARD_WIDGET_WIDTHS.MEDIUM, }, { - id: DASHBOARD_WIDGET_IDS.STUDENT_PLACEHOLDER2, + id: DASHBOARD_WIDGET_IDS.STUDENT_REQUIRED_COURSE, order: 2, width: DASHBOARD_WIDGET_WIDTHS.SMALL, }, { - id: DASHBOARD_WIDGET_IDS.STUDENT_PLACEHOLDER3, + id: DASHBOARD_WIDGET_IDS.STUDENT_COURSE_COMPLETION, order: 3, width: DASHBOARD_WIDGET_WIDTHS.SMALL, }, diff --git a/apps/api/src/settings/schemas/settings.schema.spec.ts b/apps/api/src/settings/schemas/settings.schema.spec.ts index 0a73684504..c24c1ebe4c 100644 --- a/apps/api/src/settings/schemas/settings.schema.spec.ts +++ b/apps/api/src/settings/schemas/settings.schema.spec.ts @@ -23,7 +23,7 @@ describe("studentSettingsJSONContentSchema dashboard validation", () => { Value.Check( studentSettingsJSONContentSchema, createSettings({ - id: DASHBOARD_WIDGET_IDS.STUDENT_PLACEHOLDER1, + id: DASHBOARD_WIDGET_IDS.STUDENT_CONTINUE_LEARNING, width: DASHBOARD_WIDGET_WIDTHS.MEDIUM, }), ), @@ -43,7 +43,7 @@ describe("studentSettingsJSONContentSchema dashboard validation", () => { expect( Value.Check( studentSettingsJSONContentSchema, - createSettings({ id: DASHBOARD_WIDGET_IDS.STUDENT_PLACEHOLDER1, width: 3 }), + createSettings({ id: DASHBOARD_WIDGET_IDS.STUDENT_CONTINUE_LEARNING, width: 3 }), ), ).toBe(false); }); diff --git a/apps/api/src/settings/settings.service.ts b/apps/api/src/settings/settings.service.ts index 18ee583073..eca68f913a 100644 --- a/apps/api/src/settings/settings.service.ts +++ b/apps/api/src/settings/settings.service.ts @@ -27,6 +27,7 @@ import { CORS_ORIGIN } from "src/auth/consts"; import { DatabasePg } from "src/common"; import { buildJsonbFieldWithMultipleEntries, setJsonbField } from "src/common/helpers/sqlHelpers"; import { getSupportModeContext } from "src/common/helpers/support-mode-context"; +import { EnvService } from "src/env/services/env.service"; import { UpdateSettingsEvent } from "src/events"; import { RESOURCE_CATEGORIES, RESOURCE_RELATIONSHIP_TYPES } from "src/file/file.constants"; import { FileService } from "src/file/file.service"; @@ -145,6 +146,7 @@ export class SettingsService { private readonly fileService: FileService, private readonly outboxPublisher: OutboxPublisher, private readonly localizationService: LocalizationService, + private readonly envService: EnvService, ) {} public async getCurrentUserSettings( @@ -735,9 +737,11 @@ export class SettingsService { userId: UUIDType, widgetIds: DashboardWidgetsIdsJSONContentSchema, ): Promise { - const { roleSlugs } = await this.permissionsService.getUserAccess(userId); + const { roleSlugs, permissions } = await this.permissionsService.getUserAccess(userId); const userRoles = new Set(roleSlugs); + const userPermissions = new Set(permissions); const globalSettings = await this.getPublicGlobalSettings(); + const aiConfigured = await this.envService.getAIConfigured(); const isValidWidgetId = (id: DashboardWidgetId) => Object.prototype.hasOwnProperty.call(DASHBOARD_WIDGETS, id); @@ -746,9 +750,16 @@ export class SettingsService { if (!isValidWidgetId(widgetId)) return false; const widgetDefinition: DashboardWidgetDefinition = DASHBOARD_WIDGETS[widgetId]; - const { allowedRoles, requiredFeature } = widgetDefinition; + const { allowedRoles, requiredFeature, requiredPermissions, requiresAiConfigured } = + widgetDefinition; if (requiredFeature && !globalSettings[FEATURE_SETTINGS_KEYS[requiredFeature]]) return false; + if (requiresAiConfigured && !aiConfigured.enabled) return false; + if ( + requiredPermissions && + !requiredPermissions.every((permission) => userPermissions.has(permission)) + ) + return false; if (!allowedRoles) return true; diff --git a/apps/api/src/storage/migrations/0175_add_student_dashboard_tiles.sql b/apps/api/src/storage/migrations/0175_add_student_dashboard_tiles.sql new file mode 100644 index 0000000000..e3e8ba7325 --- /dev/null +++ b/apps/api/src/storage/migrations/0175_add_student_dashboard_tiles.sql @@ -0,0 +1,51 @@ +CREATE TABLE IF NOT EXISTS "ai_mentor_practice_sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "created_at" timestamp(3) with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" timestamp(3) with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + "user_id" uuid NOT NULL, + "practice_date" date NOT NULL, + "timezone" text NOT NULL, + "language" varchar(20) NOT NULL, + "challenge" text NOT NULL, + "counterpart" text NOT NULL, + "desired_outcome" text NOT NULL, + "generated_title" text, + "generated_instructions" text, + "status" varchar(20) DEFAULT 'queued' NOT NULL, + "error_code" text, + "tenant_id" uuid DEFAULT current_setting('app.tenant_id', true)::uuid NOT NULL +); +--> statement-breakpoint +ALTER TABLE "ai_mentor_threads" ALTER COLUMN "ai_mentor_lesson_id" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "ai_mentor_threads" ADD COLUMN "practice_session_id" uuid;--> statement-breakpoint +ALTER TABLE "student_courses" ADD COLUMN "last_opened_at" timestamp(3) with time zone;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "ai_mentor_practice_sessions" ADD CONSTRAINT "ai_mentor_practice_sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "ai_mentor_practice_sessions" ADD CONSTRAINT "ai_mentor_practice_sessions_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "ai_mentor_practice_sessions_tenant_id_idx" ON "ai_mentor_practice_sessions" USING btree ("tenant_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "ai_mentor_practice_sessions_daily_unique_idx" ON "ai_mentor_practice_sessions" USING btree ("tenant_id","user_id","practice_date");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "ai_mentor_practice_sessions_status_idx" ON "ai_mentor_practice_sessions" USING btree ("tenant_id","status");--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "ai_mentor_threads" ADD CONSTRAINT "ai_mentor_threads_practice_session_id_ai_mentor_practice_sessions_id_fk" FOREIGN KEY ("practice_session_id") REFERENCES "public"."ai_mentor_practice_sessions"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "ai_mentor_threads_practice_session_unique_idx" ON "ai_mentor_threads" USING btree ("practice_session_id"); +--> statement-breakpoint +ALTER TABLE "ai_mentor_threads" +ADD CONSTRAINT "ai_mentor_threads_exactly_one_source_check" +CHECK ( + ("ai_mentor_lesson_id" IS NOT NULL AND "practice_session_id" IS NULL) + OR + ("ai_mentor_lesson_id" IS NULL AND "practice_session_id" IS NOT NULL) +); diff --git a/apps/api/src/storage/migrations/0176_migrate_student_dashboard_widget_ids.sql b/apps/api/src/storage/migrations/0176_migrate_student_dashboard_widget_ids.sql new file mode 100644 index 0000000000..c9fb3146a4 --- /dev/null +++ b/apps/api/src/storage/migrations/0176_migrate_student_dashboard_widget_ids.sql @@ -0,0 +1,36 @@ +UPDATE "settings" AS target +SET "settings" = jsonb_set( + target."settings", + '{dashboard,widgets}', + ( + SELECT jsonb_agg( + jsonb_set( + widget.value, + '{id}', + to_jsonb( + CASE widget.value->>'id' + WHEN 's_placeholder_1' THEN 's_continue_learning' + WHEN 's_placeholder_2' THEN 's_required_course' + WHEN 's_placeholder_3' THEN 's_course_completion' + ELSE widget.value->>'id' + END + ), + false + ) + ORDER BY widget.ordinality + ) + FROM jsonb_array_elements(target."settings"->'dashboard'->'widgets') + WITH ORDINALITY AS widget(value, ordinality) + ), + false +) +WHERE jsonb_typeof(target."settings"->'dashboard'->'widgets') = 'array' + AND EXISTS ( + SELECT 1 + FROM jsonb_array_elements(target."settings"->'dashboard'->'widgets') AS widget(value) + WHERE widget.value->>'id' IN ( + 's_placeholder_1', + 's_placeholder_2', + 's_placeholder_3' + ) + ); diff --git a/apps/api/src/storage/migrations/meta/_journal.json b/apps/api/src/storage/migrations/meta/_journal.json index 08f1a99483..6c0790f9e9 100644 --- a/apps/api/src/storage/migrations/meta/_journal.json +++ b/apps/api/src/storage/migrations/meta/_journal.json @@ -1275,6 +1275,20 @@ "when": 1785923514335, "tag": "0181_backfill_user_settings_dashboard", "breakpoints": true + }, + { + "idx": 175, + "version": "7", + "when": 1785316489562, + "tag": "0175_add_student_dashboard_tiles", + "breakpoints": true + }, + { + "idx": 176, + "version": "7", + "when": 1785316517843, + "tag": "0176_migrate_student_dashboard_widget_ids", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/storage/schema/index.ts b/apps/api/src/storage/schema/index.ts index 9f4110b3d3..bb9617c770 100644 --- a/apps/api/src/storage/schema/index.ts +++ b/apps/api/src/storage/schema/index.ts @@ -34,6 +34,8 @@ import { sql } from "drizzle-orm"; import { bigint, boolean, + check, + date, index, integer, jsonb, @@ -115,6 +117,7 @@ import type { AnnouncementAudience, } from "@repo/shared"; import type { ActivityLogActionType, ActivityLogMetadata } from "src/activity-logs/types"; +import type { AiMentorPracticeStatus } from "src/ai/ai-practice.types"; import type { AiJudgeCriterionStatus } from "src/ai/judge-configuration/judge-configuration.types"; import type { MicrosoftCalendarOutboundErrorCode } from "src/calendar/calendar.constants"; import type { ActivityHistory, AllSettings } from "src/common/types"; @@ -951,14 +954,59 @@ export const aiMentorThreads = pgTable( userId: uuid("user_id") .references(() => users.id, { onDelete: "cascade" }) .notNull(), - aiMentorLessonId: uuid("ai_mentor_lesson_id") - .references(() => aiMentorLessons.id, { onDelete: "cascade" }) - .notNull(), + aiMentorLessonId: uuid("ai_mentor_lesson_id").references(() => aiMentorLessons.id, { + onDelete: "cascade", + }), + practiceSessionId: uuid("practice_session_id").references( + (): AnyPgColumn => aiMentorPracticeSessions.id, + { onDelete: "cascade" }, + ), status: varchar("status", { length: 20 }).notNull().default("active"), userLanguage: varchar("user_language", { length: 20 }).notNull().default("en"), tenantId, }, - withTenantIdIndex("ai_mentor_threads"), + withTenantIdIndex("ai_mentor_threads", (table) => ({ + practiceSessionUniqueIdx: uniqueIndex("ai_mentor_threads_practice_session_unique_idx").on( + table.practiceSessionId, + ), + sourceCheck: check( + "ai_mentor_threads_exactly_one_source_check", + sql`(${table.aiMentorLessonId} IS NOT NULL) <> (${table.practiceSessionId} IS NOT NULL)`, + ), + })), +); + +export const aiMentorPracticeSessions = pgTable( + "ai_mentor_practice_sessions", + { + ...id, + ...timestamps, + userId: uuid("user_id") + .references(() => users.id, { onDelete: "cascade" }) + .notNull(), + practiceDate: date("practice_date", { mode: "string" }).notNull(), + timezone: text("timezone").notNull(), + language: varchar("language", { length: 20 }).$type().notNull(), + challenge: text("challenge").notNull(), + counterpart: text("counterpart").notNull(), + desiredOutcome: text("desired_outcome").notNull(), + generatedTitle: text("generated_title"), + generatedInstructions: text("generated_instructions"), + status: varchar("status", { length: 20 }) + .$type() + .notNull() + .default("queued"), + errorCode: text("error_code"), + tenantId, + }, + withTenantIdIndex("ai_mentor_practice_sessions", (table) => ({ + dailyUniqueIdx: uniqueIndex("ai_mentor_practice_sessions_daily_unique_idx").on( + table.tenantId, + table.userId, + table.practiceDate, + ), + statusIdx: index("ai_mentor_practice_sessions_status_idx").on(table.tenantId, table.status), + })), ); export const aiMentorThreadMessages = pgTable( @@ -1307,6 +1355,11 @@ export const studentCourses = pgTable( status: varchar("status").notNull().default("enrolled"), // enrolled/not_enrolled paymentId: varchar("payment_id", { length: 50 }), enrolledByGroupId: uuid("enrolled_by_group_id").references(() => groups.id), + lastOpenedAt: timestamp("last_opened_at", { + mode: "string", + withTimezone: true, + precision: 3, + }), tenantId, }, withTenantIdIndex("student_courses", (table) => ({ diff --git a/apps/web/app/api/mutations/useCreateAiMentorPractice.ts b/apps/web/app/api/mutations/useCreateAiMentorPractice.ts new file mode 100644 index 0000000000..c34e854417 --- /dev/null +++ b/apps/web/app/api/mutations/useCreateAiMentorPractice.ts @@ -0,0 +1,24 @@ +import { useMutation } from "@tanstack/react-query"; + +import { queryClient } from "~/api/queryClient"; + +import { ApiClient } from "../api-client"; +import { getBrowserTimezone } from "../queries/useAiMentorPracticeToday"; + +import type { CreatePracticeBody } from "../generated-api"; + +export function useCreateAiMentorPractice() { + return useMutation({ + mutationFn: async (body: Omit) => { + const response = await ApiClient.api.aiControllerCreatePractice({ + ...body, + timezone: getBrowserTimezone(), + }); + return response.data.data; + }, + onSuccess: (practice) => { + queryClient.setQueryData(["aiMentorPractice", "today", practice.timezone], practice); + queryClient.setQueryData(["aiMentorPractice", practice.id], practice); + }, + }); +} diff --git a/apps/web/app/api/mutations/useMarkCourseOpened.ts b/apps/web/app/api/mutations/useMarkCourseOpened.ts new file mode 100644 index 0000000000..58a94f4d02 --- /dev/null +++ b/apps/web/app/api/mutations/useMarkCourseOpened.ts @@ -0,0 +1,12 @@ +import { useMutation } from "@tanstack/react-query"; + +import { ApiClient } from "../api-client"; + +export function useMarkCourseOpened() { + return useMutation({ + mutationFn: async (courseId: string) => { + const response = await ApiClient.api.courseControllerMarkCourseOpened(courseId); + return response.data; + }, + }); +} diff --git a/apps/web/app/api/mutations/useRetryAiMentorPractice.ts b/apps/web/app/api/mutations/useRetryAiMentorPractice.ts new file mode 100644 index 0000000000..5dec9e41d0 --- /dev/null +++ b/apps/web/app/api/mutations/useRetryAiMentorPractice.ts @@ -0,0 +1,18 @@ +import { useMutation } from "@tanstack/react-query"; + +import { queryClient } from "~/api/queryClient"; + +import { ApiClient } from "../api-client"; + +export function useRetryAiMentorPractice() { + return useMutation({ + mutationFn: async (id: string) => { + const response = await ApiClient.api.aiControllerRetryPractice(id); + return response.data.data; + }, + onSuccess: (practice) => { + queryClient.setQueryData(["aiMentorPractice", practice.id], practice); + queryClient.setQueryData(["aiMentorPractice", "today", practice.timezone], practice); + }, + }); +} diff --git a/apps/web/app/api/queries/useAiMentorPractice.ts b/apps/web/app/api/queries/useAiMentorPractice.ts new file mode 100644 index 0000000000..bb2c73e23b --- /dev/null +++ b/apps/web/app/api/queries/useAiMentorPractice.ts @@ -0,0 +1,18 @@ +import { useQuery } from "@tanstack/react-query"; + +import { ApiClient } from "../api-client"; + +export function useAiMentorPractice(id: string) { + return useQuery({ + queryKey: ["aiMentorPractice", id], + queryFn: async () => { + const response = await ApiClient.api.aiControllerGetPractice(id); + return response.data.data; + }, + enabled: Boolean(id), + refetchInterval: (query) => { + const status = query.state.data?.status; + return status === "queued" || status === "processing" ? 2000 : false; + }, + }); +} diff --git a/apps/web/app/api/queries/useAiMentorPracticeToday.ts b/apps/web/app/api/queries/useAiMentorPracticeToday.ts new file mode 100644 index 0000000000..67430d94c9 --- /dev/null +++ b/apps/web/app/api/queries/useAiMentorPracticeToday.ts @@ -0,0 +1,21 @@ +import { useQuery } from "@tanstack/react-query"; + +import { ApiClient } from "../api-client"; + +export const getBrowserTimezone = () => Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + +export function useAiMentorPracticeToday() { + const timezone = getBrowserTimezone(); + + return useQuery({ + queryKey: ["aiMentorPractice", "today", timezone], + queryFn: async () => { + const response = await ApiClient.api.aiControllerGetTodayPractice({ timezone }); + return response.data.data; + }, + refetchInterval: (query) => { + const status = query.state.data?.status; + return status === "queued" || status === "processing" ? 2000 : false; + }, + }); +} diff --git a/apps/web/app/api/queries/useCertificateDashboardSummary.ts b/apps/web/app/api/queries/useCertificateDashboardSummary.ts new file mode 100644 index 0000000000..87ed0e9271 --- /dev/null +++ b/apps/web/app/api/queries/useCertificateDashboardSummary.ts @@ -0,0 +1,19 @@ +import { useQuery } from "@tanstack/react-query"; + +import { useLanguageStore } from "~/modules/Dashboard/Settings/Language/LanguageStore"; + +import { ApiClient } from "../api-client"; + +export function useCertificateDashboardSummary() { + const language = useLanguageStore((state) => state.language); + + return useQuery({ + queryKey: ["dashboard", "certificateSummary", language], + queryFn: async () => { + const response = await ApiClient.api.certificatesControllerGetDashboardSummary({ + language, + }); + return response.data.data; + }, + }); +} diff --git a/apps/web/app/api/queries/useDashboardCertificates.ts b/apps/web/app/api/queries/useDashboardCertificates.ts new file mode 100644 index 0000000000..e96cf56dcb --- /dev/null +++ b/apps/web/app/api/queries/useDashboardCertificates.ts @@ -0,0 +1,31 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query"; + +import { useLanguageStore } from "~/modules/Dashboard/Settings/Language/LanguageStore"; + +import { ApiClient } from "../api-client"; + +import { useCurrentUser } from "./useCurrentUser"; + +const DASHBOARD_CERTIFICATES_PAGE_SIZE = 10; + +export function useDashboardCertificates(page: number, enabled: boolean) { + const language = useLanguageStore((state) => state.language); + const { data: currentUser } = useCurrentUser(); + + return useQuery({ + queryKey: ["dashboard", "certificates", currentUser?.id, language, page], + queryFn: async () => { + const response = await ApiClient.api.certificatesControllerGetAllCertificates({ + userId: currentUser!.id, + language, + page, + perPage: DASHBOARD_CERTIFICATES_PAGE_SIZE, + sort: "-createdAt", + }); + + return response.data; + }, + enabled: enabled && Boolean(currentUser?.id), + placeholderData: keepPreviousData, + }); +} diff --git a/apps/web/app/api/queries/useStudentDashboardSummary.ts b/apps/web/app/api/queries/useStudentDashboardSummary.ts new file mode 100644 index 0000000000..08c467f676 --- /dev/null +++ b/apps/web/app/api/queries/useStudentDashboardSummary.ts @@ -0,0 +1,21 @@ +import { useQuery } from "@tanstack/react-query"; + +import { useLanguageStore } from "~/modules/Dashboard/Settings/Language/LanguageStore"; + +import { ApiClient } from "../api-client"; + +export const studentDashboardSummaryQueryOptions = (language: string) => ({ + queryKey: ["dashboard", "studentCourseSummary", language], + queryFn: async () => { + const response = await ApiClient.api.courseControllerGetStudentDashboardSummary({ + language: language as "en" | "pl" | "de" | "lt" | "cs" | "es", + }); + return response.data.data; + }, +}); + +export function useStudentDashboardSummary() { + const language = useLanguageStore((state) => state.language); + + return useQuery(studentDashboardSummaryQueryOptions(language)); +} diff --git a/apps/web/app/config/routeAccessConfig.ts b/apps/web/app/config/routeAccessConfig.ts index 2b6a679301..5e6a7c83dc 100644 --- a/apps/web/app/config/routeAccessConfig.ts +++ b/apps/web/app/config/routeAccessConfig.ts @@ -107,6 +107,9 @@ export const routeAccessConfig = createRouteConfig({ "news/add": NEWS_EDIT_ACCESS, "news/:newsId/edit": NEWS_EDIT_ACCESS, dashboard: DASHBOARD_READ_ACCESS, + "ai-mentor/practice/:id": { + allOf: [PERMISSIONS.AI_USE], + }, // Client and public "course/:id": PUBLIC, courses: PUBLIC, diff --git a/apps/web/app/locales/cs/translation.json b/apps/web/app/locales/cs/translation.json index a4843bf2a2..64363655cd 100644 --- a/apps/web/app/locales/cs/translation.json +++ b/apps/web/app/locales/cs/translation.json @@ -38,6 +38,64 @@ "s_placeholder_1": { "title": "Widget studenta 1" }, "s_placeholder_2": { "title": "Widget studenta 2" }, "s_placeholder_3": { "title": "Widget studenta 3" }, + "loadError": "Tento widget se nepodařilo načíst.", + "studentTiles": { + "continueLearning": { + "description": "Všechny zahájené kurzy", + "empty": "Nemáte žádné rozpracované kurzy.", + "nextLesson": "Další: {{title}}", + "openCourse": "Otevřít kurz", + "viewAll": "{{count}} rozpracovaných kurzů" + }, + "requiredCourse": { + "description": "Všechny nedokončené povinné kurzy", + "empty": "Nemáte žádné povinné kurzy k dokončení.", + "overdue": "Po termínu", + "dueSoon": "Brzy vyprší", + "scheduled": "Nadcházející", + "noDeadline": "Bez termínu", + "dueDate": "Termín {{date}}", + "noDueDate": "Bez data dokončení", + "total": "{{count}} povinných kurzů", + "overdueCount": "{{count}} po termínu" + }, + "courseCompletion": { + "description": "Přehled průběhu přiřazených kurzů", + "empty": "Zatím nemáte přiřazené kurzy.", + "completedOfTotal": "Dokončeno {{completed}} z {{total}}", + "completed": "Dokončeno", + "inProgress": "Probíhá", + "notStarted": "Nezahájeno" + }, + "certificates": { + "description": "Aktivní certifikáty a blížící se expirace", + "empty": "Nemáte žádné aktivní certifikáty.", + "active": "Aktivní certifikáty", + "expiringSoon": "Vyprší do 30 dnů", + "cta": "Zobrazit certifikát", + "viewAll": "Zobrazit všechny certifikáty", + "dialogTitle": "Vaše certifikáty", + "dialogDescription": "Prohlédněte si všechny získané aktivní certifikáty.", + "issued": "Vydáno {{date}}", + "expires": "Vyprší {{date}}", + "noExpiry": "Bez data expirace", + "previous": "Předchozí", + "next": "Další", + "page": "Strana {{page}} z {{totalPages}}" + }, + "aiMentorPractice": { + "description": "Vytvořte každý den jeden cílený scénář", + "empty": "Popište situaci a procvičte ji s AI mentorem.", + "startCta": "Zahájit trénink", + "continueCta": "Pokračovat v tréninku", + "status": { + "queued": "Scénář čeká.", + "processing": "Scénář se připravuje.", + "ready": "Dnešní trénink je připraven.", + "failed": "Generování selhalo. Otevřete jej a zkuste to znovu." + } + } + }, "commonDescription": "Klíčové informace na první pohled", "training_completion": { "title": "Dokončení školení" }, "deadline_risks": { "title": "Ohrožené termíny" }, @@ -45,11 +103,29 @@ "event_calendar": { "title": "Kalendář událostí" }, "continue_learning": { "title": "Pokračovat ve vzdělávání" }, "required_course": { "title": "Povinné kurzy" }, - "course_completion": { "title": "Dokončené kurzy" }, + "course_completion": { "title": "Průběh kurzů" }, "certificates": { "title": "Certifikáty" }, "ai_mentor_practice": { "title": "Cvičení s AI Mentorem" } } }, + "aiMentorPractice": { + "form": { + "title": "Vytvořit dnešní trénink s AI mentorem", + "description": "Odpovězte na tři krátké otázky.", + "challenge": "Jakou situaci chcete procvičit?", + "counterpart": "S kým budete mluvit?", + "desiredOutcome": "Jakého výsledku chcete dosáhnout?", + "submit": "Vygenerovat trénink" + }, + "generating": "AI mentor připravuje scénář.", + "failed": "Scénář se nepodařilo vytvořit.", + "error": "Trénink se nepodařilo načíst.", + "retry": "Zkusit znovu", + "conversationTitle": "Trénink s AI mentorem", + "mentorName": "AI mentor", + "message": "Zpráva pro AI mentora", + "send": "Odeslat" + }, "common": { "button": { "save": "Uložit", diff --git a/apps/web/app/locales/de/translation.json b/apps/web/app/locales/de/translation.json index 155d3cbb9a..35b7c8aac5 100644 --- a/apps/web/app/locales/de/translation.json +++ b/apps/web/app/locales/de/translation.json @@ -38,6 +38,64 @@ "s_placeholder_1": { "title": "Lernenden-Widget 1" }, "s_placeholder_2": { "title": "Lernenden-Widget 2" }, "s_placeholder_3": { "title": "Lernenden-Widget 3" }, + "loadError": "Dieses Widget konnte nicht geladen werden.", + "studentTiles": { + "continueLearning": { + "description": "Alle von dir begonnenen Kurse", + "empty": "Du hast keine laufenden Kurse.", + "nextLesson": "Als Nächstes: {{title}}", + "openCourse": "Kurs öffnen", + "viewAll": "{{count}} laufende Kurse" + }, + "requiredCourse": { + "description": "Alle noch abzuschließenden Pflichtkurse", + "empty": "Du hast keine offenen Pflichtkurse.", + "overdue": "Überfällig", + "dueSoon": "Bald fällig", + "scheduled": "Bevorstehend", + "noDeadline": "Keine Frist", + "dueDate": "Fällig am {{date}}", + "noDueDate": "Kein Fälligkeitsdatum", + "total": "{{count}} Pflichtkurse", + "overdueCount": "{{count}} überfällig" + }, + "courseCompletion": { + "description": "Fortschritt deiner zugewiesenen Kurse", + "empty": "Dir wurden noch keine Kurse zugewiesen.", + "completedOfTotal": "{{completed}} von {{total}} abgeschlossen", + "completed": "Abgeschlossen", + "inProgress": "In Bearbeitung", + "notStarted": "Nicht begonnen" + }, + "certificates": { + "description": "Aktive Zertifikate und bevorstehende Abläufe", + "empty": "Du hast keine aktiven Zertifikate.", + "active": "Aktive Zertifikate", + "expiringSoon": "Läuft innerhalb von 30 Tagen ab", + "cta": "Zertifikat anzeigen", + "viewAll": "Alle Zertifikate anzeigen", + "dialogTitle": "Deine Zertifikate", + "dialogDescription": "Alle erworbenen aktiven Zertifikate anzeigen.", + "issued": "Ausgestellt am {{date}}", + "expires": "Läuft am {{date}} ab", + "noExpiry": "Kein Ablaufdatum", + "previous": "Zurück", + "next": "Weiter", + "page": "Seite {{page}} von {{totalPages}}" + }, + "aiMentorPractice": { + "description": "Erstelle täglich ein gezieltes Übungsszenario", + "empty": "Beschreibe eine Situation und übe sie mit dem KI-Mentor.", + "startCta": "Übung starten", + "continueCta": "Übung fortsetzen", + "status": { + "queued": "Dein Szenario wartet.", + "processing": "Dein Szenario wird vorbereitet.", + "ready": "Die heutige Übung ist bereit.", + "failed": "Die Erstellung ist fehlgeschlagen. Öffne sie zum Wiederholen." + } + } + }, "commonDescription": "Wichtige Informationen auf einen Blick", "training_completion": { "title": "Schulungsabschluss" }, "deadline_risks": { "title": "Gefährdete Fristen" }, @@ -45,11 +103,29 @@ "event_calendar": { "title": "Veranstaltungskalender" }, "continue_learning": { "title": "Weiterlernen" }, "required_course": { "title": "Pflichtkurse" }, - "course_completion": { "title": "Abgeschlossene Kurse" }, + "course_completion": { "title": "Kursfortschritt" }, "certificates": { "title": "Zertifikate" }, "ai_mentor_practice": { "title": "KI-Mentor-Übung" } } }, + "aiMentorPractice": { + "form": { + "title": "Heutige KI-Mentor-Übung erstellen", + "description": "Beantworte drei kurze Fragen für ein gezieltes Szenario.", + "challenge": "Welche Situation möchtest du üben?", + "counterpart": "Mit wem wirst du sprechen?", + "desiredOutcome": "Welches Ergebnis möchtest du erreichen?", + "submit": "Übung erstellen" + }, + "generating": "Der KI-Mentor bereitet dein Szenario vor.", + "failed": "Das Übungsszenario konnte nicht erstellt werden.", + "error": "Die Übung konnte nicht geladen werden.", + "retry": "Erneut versuchen", + "conversationTitle": "KI-Mentor-Übung", + "mentorName": "KI-Mentor", + "message": "Nachricht an den KI-Mentor", + "send": "Senden" + }, "common": { "button": { "save": "Speichern", diff --git a/apps/web/app/locales/en/translation.json b/apps/web/app/locales/en/translation.json index de28e00589..d7aba08047 100644 --- a/apps/web/app/locales/en/translation.json +++ b/apps/web/app/locales/en/translation.json @@ -38,6 +38,64 @@ "s_placeholder_1": { "title": "Learner widget 1" }, "s_placeholder_2": { "title": "Learner widget 2" }, "s_placeholder_3": { "title": "Learner widget 3" }, + "loadError": "We could not load this widget.", + "studentTiles": { + "continueLearning": { + "description": "All courses you have started", + "empty": "You have no courses in progress.", + "nextLesson": "Next: {{title}}", + "openCourse": "Open course", + "viewAll": "{{count}} courses in progress" + }, + "requiredCourse": { + "description": "All mandatory courses you still need to complete", + "empty": "You have no required courses to complete.", + "overdue": "Overdue", + "dueSoon": "Due soon", + "scheduled": "Upcoming", + "noDeadline": "No deadline", + "dueDate": "Due {{date}}", + "noDueDate": "No due date", + "total": "{{count}} required courses", + "overdueCount": "{{count}} overdue" + }, + "courseCompletion": { + "description": "A summary of your assigned course progress", + "empty": "You have no assigned courses yet.", + "completedOfTotal": "{{completed}} of {{total}} completed", + "completed": "Completed", + "inProgress": "In progress", + "notStarted": "Not started" + }, + "certificates": { + "description": "Your active certificates and upcoming expirations", + "empty": "You do not have any active certificates.", + "active": "Active certificates", + "expiringSoon": "Expiring within 30 days", + "cta": "View certificate", + "viewAll": "View all certificates", + "dialogTitle": "Your certificates", + "dialogDescription": "Review all active certificates you have earned.", + "issued": "Issued {{date}}", + "expires": "Expires {{date}}", + "noExpiry": "No expiration date", + "previous": "Previous", + "next": "Next", + "page": "Page {{page}} of {{totalPages}}" + }, + "aiMentorPractice": { + "description": "Create one focused practice scenario each day", + "empty": "Describe a situation and practice it with AI Mentor.", + "startCta": "Start practice", + "continueCta": "Continue practice", + "status": { + "queued": "Your practice scenario is queued.", + "processing": "Your practice scenario is being prepared.", + "ready": "Today's practice is ready.", + "failed": "Scenario generation failed. Open it to retry." + } + } + }, "commonDescription": "Key information at a glance", "training_completion": { "title": "Training completion" }, "deadline_risks": { "title": "Deadline risks" }, @@ -45,11 +103,29 @@ "event_calendar": { "title": "Event calendar" }, "continue_learning": { "title": "Continue learning" }, "required_course": { "title": "Required courses" }, - "course_completion": { "title": "Completed courses" }, + "course_completion": { "title": "Course progress" }, "certificates": { "title": "Certificates" }, "ai_mentor_practice": { "title": "AI Mentor practice" } } }, + "aiMentorPractice": { + "form": { + "title": "Create today's AI Mentor practice", + "description": "Answer three short questions so AI Mentor can prepare a focused scenario.", + "challenge": "What situation do you want to practice?", + "counterpart": "Who will you be speaking with?", + "desiredOutcome": "What outcome do you want to achieve?", + "submit": "Generate practice" + }, + "generating": "AI Mentor is preparing your scenario.", + "failed": "We could not generate this practice scenario.", + "error": "We could not load this practice.", + "retry": "Try generation again", + "conversationTitle": "AI Mentor practice", + "mentorName": "AI Mentor", + "message": "Message to AI Mentor", + "send": "Send" + }, "common": { "button": { "save": "Save", diff --git a/apps/web/app/locales/es/translation.json b/apps/web/app/locales/es/translation.json index 43ef480eac..5f7ab04317 100644 --- a/apps/web/app/locales/es/translation.json +++ b/apps/web/app/locales/es/translation.json @@ -38,6 +38,64 @@ "s_placeholder_1": { "title": "Widget de estudiante 1" }, "s_placeholder_2": { "title": "Widget de estudiante 2" }, "s_placeholder_3": { "title": "Widget de estudiante 3" }, + "loadError": "No se pudo cargar este widget.", + "studentTiles": { + "continueLearning": { + "description": "Todos los cursos que has comenzado", + "empty": "No tienes cursos en progreso.", + "nextLesson": "Siguiente: {{title}}", + "openCourse": "Abrir curso", + "viewAll": "{{count}} cursos en progreso" + }, + "requiredCourse": { + "description": "Todos los cursos obligatorios pendientes", + "empty": "No tienes cursos obligatorios pendientes.", + "overdue": "Atrasado", + "dueSoon": "Próximo vencimiento", + "scheduled": "Próximo", + "noDeadline": "Sin fecha límite", + "dueDate": "Vence el {{date}}", + "noDueDate": "Sin fecha de vencimiento", + "total": "{{count}} cursos obligatorios", + "overdueCount": "{{count}} atrasados" + }, + "courseCompletion": { + "description": "Resumen del progreso de cursos asignados", + "empty": "Aún no tienes cursos asignados.", + "completedOfTotal": "{{completed}} de {{total}} completados", + "completed": "Completados", + "inProgress": "En progreso", + "notStarted": "Sin iniciar" + }, + "certificates": { + "description": "Certificados activos y próximos vencimientos", + "empty": "No tienes certificados activos.", + "active": "Certificados activos", + "expiringSoon": "Vence en 30 días", + "cta": "Ver certificado", + "viewAll": "Ver todos los certificados", + "dialogTitle": "Tus certificados", + "dialogDescription": "Revisa todos los certificados activos que has obtenido.", + "issued": "Emitido el {{date}}", + "expires": "Vence el {{date}}", + "noExpiry": "Sin fecha de vencimiento", + "previous": "Anterior", + "next": "Siguiente", + "page": "Página {{page}} de {{totalPages}}" + }, + "aiMentorPractice": { + "description": "Crea un escenario de práctica al día", + "empty": "Describe una situación y practícala con Mentor IA.", + "startCta": "Iniciar práctica", + "continueCta": "Continuar práctica", + "status": { + "queued": "Tu escenario está en cola.", + "processing": "Tu escenario se está preparando.", + "ready": "La práctica de hoy está lista.", + "failed": "La generación falló. Ábrela para reintentar." + } + } + }, "commonDescription": "Información clave de un vistazo", "training_completion": { "title": "Finalización de formación" }, "deadline_risks": { "title": "Plazos en riesgo" }, @@ -45,11 +103,29 @@ "event_calendar": { "title": "Calendario de eventos" }, "continue_learning": { "title": "Continuar aprendiendo" }, "required_course": { "title": "Cursos obligatorios" }, - "course_completion": { "title": "Cursos completados" }, + "course_completion": { "title": "Progreso de cursos" }, "certificates": { "title": "Certificados" }, "ai_mentor_practice": { "title": "Práctica con Mentor IA" } } }, + "aiMentorPractice": { + "form": { + "title": "Crear la práctica de hoy con Mentor IA", + "description": "Responde tres preguntas breves para preparar el escenario.", + "challenge": "¿Qué situación quieres practicar?", + "counterpart": "¿Con quién hablarás?", + "desiredOutcome": "¿Qué resultado quieres lograr?", + "submit": "Generar práctica" + }, + "generating": "Mentor IA está preparando tu escenario.", + "failed": "No se pudo generar el escenario.", + "error": "No se pudo cargar la práctica.", + "retry": "Intentar de nuevo", + "conversationTitle": "Práctica con Mentor IA", + "mentorName": "Mentor IA", + "message": "Mensaje para Mentor IA", + "send": "Enviar" + }, "common": { "button": { "save": "Guardar", diff --git a/apps/web/app/locales/lt/translation.json b/apps/web/app/locales/lt/translation.json index 01f9214726..9b2e39a9d6 100644 --- a/apps/web/app/locales/lt/translation.json +++ b/apps/web/app/locales/lt/translation.json @@ -38,6 +38,64 @@ "s_placeholder_1": { "title": "Besimokančiojo valdiklis 1" }, "s_placeholder_2": { "title": "Besimokančiojo valdiklis 2" }, "s_placeholder_3": { "title": "Besimokančiojo valdiklis 3" }, + "loadError": "Nepavyko įkelti šio valdiklio.", + "studentTiles": { + "continueLearning": { + "description": "Visi pradėti kursai", + "empty": "Neturite vykdomų kursų.", + "nextLesson": "Toliau: {{title}}", + "openCourse": "Atidaryti kursą", + "viewAll": "{{count}} vykdomi kursai" + }, + "requiredCourse": { + "description": "Visi nebaigti privalomi kursai", + "empty": "Neturite privalomų kursų, kuriuos reikia baigti.", + "overdue": "Pavėluota", + "dueSoon": "Artėja terminas", + "scheduled": "Artėjantis", + "noDeadline": "Be termino", + "dueDate": "Terminas {{date}}", + "noDueDate": "Terminas nenustatytas", + "total": "{{count}} privalomi kursai", + "overdueCount": "{{count}} pavėluoti" + }, + "courseCompletion": { + "description": "Priskirtų kursų pažangos suvestinė", + "empty": "Dar neturite priskirtų kursų.", + "completedOfTotal": "Baigta {{completed}} iš {{total}}", + "completed": "Baigta", + "inProgress": "Vykdoma", + "notStarted": "Nepradėta" + }, + "certificates": { + "description": "Aktyvūs sertifikatai ir artėjanti galiojimo pabaiga", + "empty": "Neturite aktyvių sertifikatų.", + "active": "Aktyvūs sertifikatai", + "expiringSoon": "Baigs galioti per 30 dienų", + "cta": "Peržiūrėti sertifikatą", + "viewAll": "Peržiūrėti visus sertifikatus", + "dialogTitle": "Jūsų sertifikatai", + "dialogDescription": "Peržiūrėkite visus gautus aktyvius sertifikatus.", + "issued": "Išduota {{date}}", + "expires": "Galioja iki {{date}}", + "noExpiry": "Galiojimo laikas neribotas", + "previous": "Ankstesnis", + "next": "Kitas", + "page": "{{page}} puslapis iš {{totalPages}}" + }, + "aiMentorPractice": { + "description": "Kasdien sukurkite vieną praktikos scenarijų", + "empty": "Aprašykite situaciją ir praktikuokitės su DI mentoriumi.", + "startCta": "Pradėti praktiką", + "continueCta": "Tęsti praktiką", + "status": { + "queued": "Scenarijus laukia.", + "processing": "Scenarijus ruošiamas.", + "ready": "Šiandienos praktika paruošta.", + "failed": "Scenarijaus sukurti nepavyko. Atidarykite ir bandykite dar kartą." + } + } + }, "commonDescription": "Svarbiausia informacija vienu žvilgsniu", "training_completion": { "title": "Mokymų užbaigimas" }, "deadline_risks": { "title": "Rizikingi terminai" }, @@ -45,11 +103,29 @@ "event_calendar": { "title": "Įvykių kalendorius" }, "continue_learning": { "title": "Tęsti mokymąsi" }, "required_course": { "title": "Privalomi kursai" }, - "course_completion": { "title": "Baigti kursai" }, + "course_completion": { "title": "Kursų pažanga" }, "certificates": { "title": "Sertifikatai" }, "ai_mentor_practice": { "title": "DI mentoriaus praktika" } } }, + "aiMentorPractice": { + "form": { + "title": "Sukurti šiandienos DI mentoriaus praktiką", + "description": "Atsakykite į tris trumpus klausimus.", + "challenge": "Kokią situaciją norite praktikuoti?", + "counterpart": "Su kuo kalbėsite?", + "desiredOutcome": "Kokio rezultato siekiate?", + "submit": "Generuoti praktiką" + }, + "generating": "DI mentorius ruošia scenarijų.", + "failed": "Nepavyko sukurti praktikos scenarijaus.", + "error": "Nepavyko įkelti praktikos.", + "retry": "Bandyti dar kartą", + "conversationTitle": "DI mentoriaus praktika", + "mentorName": "DI mentorius", + "message": "Žinutė DI mentoriui", + "send": "Siųsti" + }, "common": { "button": { "save": "Išsaugoti", diff --git a/apps/web/app/locales/pl/translation.json b/apps/web/app/locales/pl/translation.json index 9b06e0dd4b..88da2d9ed0 100644 --- a/apps/web/app/locales/pl/translation.json +++ b/apps/web/app/locales/pl/translation.json @@ -38,6 +38,64 @@ "s_placeholder_1": { "title": "Widżet uczestnika 1" }, "s_placeholder_2": { "title": "Widżet uczestnika 2" }, "s_placeholder_3": { "title": "Widżet uczestnika 3" }, + "loadError": "Nie udało się wczytać tego kafelka.", + "studentTiles": { + "continueLearning": { + "description": "Wszystkie rozpoczęte przez Ciebie kursy", + "empty": "Nie masz rozpoczętych kursów.", + "nextLesson": "Następna lekcja: {{title}}", + "openCourse": "Otwórz kurs", + "viewAll": "Kursy w trakcie: {{count}}" + }, + "requiredCourse": { + "description": "Wszystkie nieukończone kursy obowiązkowe", + "empty": "Nie masz kursów obowiązkowych do ukończenia.", + "overdue": "Po terminie", + "dueSoon": "Termin wkrótce", + "scheduled": "Nadchodzący", + "noDeadline": "Bez terminu", + "dueDate": "Termin: {{date}}", + "noDueDate": "Brak terminu", + "total": "Kursy obowiązkowe: {{count}}", + "overdueCount": "Po terminie: {{count}}" + }, + "courseCompletion": { + "description": "Podsumowanie postępu przypisanych kursów", + "empty": "Nie masz jeszcze przypisanych kursów.", + "completedOfTotal": "Ukończono {{completed}} z {{total}}", + "completed": "Ukończone", + "inProgress": "W trakcie", + "notStarted": "Nierozpoczęte" + }, + "certificates": { + "description": "Aktywne certyfikaty i nadchodzące wygaśnięcia", + "empty": "Nie masz aktywnych certyfikatów.", + "active": "Aktywne certyfikaty", + "expiringSoon": "Wygasa w ciągu 30 dni", + "cta": "Zobacz certyfikat", + "viewAll": "Zobacz wszystkie certyfikaty", + "dialogTitle": "Twoje certyfikaty", + "dialogDescription": "Przejrzyj wszystkie zdobyte aktywne certyfikaty.", + "issued": "Wydano: {{date}}", + "expires": "Wygasa: {{date}}", + "noExpiry": "Bezterminowy", + "previous": "Poprzednia", + "next": "Następna", + "page": "Strona {{page}} z {{totalPages}}" + }, + "aiMentorPractice": { + "description": "Codziennie utwórz jeden scenariusz treningowy", + "empty": "Opisz sytuację i przećwicz ją z Mentorem AI.", + "startCta": "Rozpocznij trening", + "continueCta": "Kontynuuj trening", + "status": { + "queued": "Scenariusz czeka na przygotowanie.", + "processing": "Scenariusz jest przygotowywany.", + "ready": "Dzisiejszy trening jest gotowy.", + "failed": "Nie udało się wygenerować scenariusza. Otwórz go, aby ponowić." + } + } + }, "commonDescription": "Najważniejsze informacje w jednym miejscu", "training_completion": { "title": "Realizacja szkoleń" }, "deadline_risks": { "title": "Zagrożone terminy" }, @@ -45,11 +103,29 @@ "event_calendar": { "title": "Kalendarz wydarzeń" }, "continue_learning": { "title": "Kontynuuj naukę" }, "required_course": { "title": "Kursy obowiązkowe" }, - "course_completion": { "title": "Ukończone kursy" }, + "course_completion": { "title": "Postęp kursów" }, "certificates": { "title": "Certyfikaty" }, "ai_mentor_practice": { "title": "Ćwiczenia z Mentorem AI" } } }, + "aiMentorPractice": { + "form": { + "title": "Utwórz dzisiejszy trening z Mentorem AI", + "description": "Odpowiedz na trzy krótkie pytania, aby przygotować dopasowany scenariusz.", + "challenge": "Jaką sytuację chcesz przećwiczyć?", + "counterpart": "Z kim będziesz rozmawiać?", + "desiredOutcome": "Jaki rezultat chcesz osiągnąć?", + "submit": "Wygeneruj trening" + }, + "generating": "Mentor AI przygotowuje Twój scenariusz.", + "failed": "Nie udało się wygenerować scenariusza treningowego.", + "error": "Nie udało się wczytać treningu.", + "retry": "Spróbuj wygenerować ponownie", + "conversationTitle": "Trening z Mentorem AI", + "mentorName": "Mentor AI", + "message": "Wiadomość do Mentora AI", + "send": "Wyślij" + }, "common": { "button": { "save": "Zapisz", diff --git a/apps/web/app/modules/AiMentorPractice/AiMentorPractice.page.tsx b/apps/web/app/modules/AiMentorPractice/AiMentorPractice.page.tsx new file mode 100644 index 0000000000..78d9a8b583 --- /dev/null +++ b/apps/web/app/modules/AiMentorPractice/AiMentorPractice.page.tsx @@ -0,0 +1,190 @@ +import { useChat, type UIMessage } from "@ai-sdk/react"; +import { useNavigate, useParams } from "@remix-run/react"; +import { createTextUiMessage, toUiMessageRole } from "@repo/shared"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { useCreateAiMentorPractice } from "~/api/mutations/useCreateAiMentorPractice"; +import { useRetryAiMentorPractice } from "~/api/mutations/useRetryAiMentorPractice"; +import { useAiMentorPractice } from "~/api/queries/useAiMentorPractice"; +import { useCurrentThreadMessages } from "~/api/queries/useCurrentThreadMessages"; +import { PageWrapper } from "~/components/PageWrapper"; +import { Button } from "~/components/ui/button"; +import { Textarea } from "~/components/ui/textarea"; +import Loader from "~/modules/common/Loader/Loader"; +import { createAiMentorChatTransport } from "~/modules/Courses/Lesson/AiMentorLesson/aiMentorChatTransport"; +import ChatLoader from "~/modules/Courses/Lesson/AiMentorLesson/components/ChatLoader"; +import ChatMessage from "~/modules/Courses/Lesson/AiMentorLesson/components/ChatMessage"; +import { useLanguageStore } from "~/modules/Dashboard/Settings/Language/LanguageStore"; + +const fields = ["challenge", "counterpart", "desiredOutcome"] as const; + +export default function AiMentorPracticePage() { + const { t } = useTranslation(); + const { id = "new" } = useParams(); + const navigate = useNavigate(); + const language = useLanguageStore((state) => state.language); + const isNew = id === "new"; + const { data: practice, isLoading, isError, refetch } = useAiMentorPractice(isNew ? "" : id); + const { mutateAsync: createPractice, isPending: isCreating } = useCreateAiMentorPractice(); + const { mutateAsync: retryPractice, isPending: isRetrying } = useRetryAiMentorPractice(); + const [answers, setAnswers] = useState({ + challenge: "", + counterpart: "", + desiredOutcome: "", + }); + + const handleCreate = async () => { + const created = await createPractice({ ...answers, language }); + navigate(`/ai-mentor/practice/${created.id}`, { replace: true }); + }; + + if (isNew) { + return ( + +

{t("aiMentorPractice.form.title")}

+

{t("aiMentorPractice.form.description")}

+
+ {fields.map((field) => ( +