Merge branch 'main' of https://github.com/classroomio/classroomio
This commit is contained in:
@@ -0,0 +1,92 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('@api/services/course/compliance', () => ({
|
||||||
|
ensureComplianceEnrollmentRecordsForProfiles: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@cio/db/queries/course/people', () => ({
|
||||||
|
getCourseMember: vi.fn(),
|
||||||
|
getCourseMembers: vi.fn(),
|
||||||
|
addCourseMember: vi.fn(),
|
||||||
|
deleteCourseMember: vi.fn(),
|
||||||
|
updateCourseMember: vi.fn(),
|
||||||
|
getCourseTeachers: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@cio/db/queries/course/reset-progress', () => ({
|
||||||
|
resetStudentCourseProgress: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { AppError } from '@api/utils/errors';
|
||||||
|
import { ROLE } from '@cio/utils/constants';
|
||||||
|
import { getCourseMember } from '@cio/db/queries/course/people';
|
||||||
|
import { resetStudentCourseProgress } from '@cio/db/queries/course/reset-progress';
|
||||||
|
import { resetMemberCourseProgress } from '@api/services/course/people';
|
||||||
|
|
||||||
|
describe('resetMemberCourseProgress', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when the member is missing', async () => {
|
||||||
|
vi.mocked(getCourseMember).mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(resetMemberCourseProgress('course-1', 'member-1', 'actor-1')).rejects.toMatchObject({
|
||||||
|
statusCode: 404
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when the member has no profileId', async () => {
|
||||||
|
vi.mocked(getCourseMember).mockResolvedValue({
|
||||||
|
id: 'member-1',
|
||||||
|
profileId: null,
|
||||||
|
roleId: ROLE.STUDENT
|
||||||
|
} as Awaited<ReturnType<typeof getCourseMember>>);
|
||||||
|
|
||||||
|
await expect(resetMemberCourseProgress('course-1', 'member-1', 'actor-1')).rejects.toBeInstanceOf(AppError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 when the member is not a student', async () => {
|
||||||
|
vi.mocked(getCourseMember).mockResolvedValue({
|
||||||
|
id: 'member-1',
|
||||||
|
profileId: 'profile-1',
|
||||||
|
roleId: ROLE.TUTOR
|
||||||
|
} as Awaited<ReturnType<typeof getCourseMember>>);
|
||||||
|
|
||||||
|
await expect(resetMemberCourseProgress('course-1', 'member-1', 'actor-1')).rejects.toMatchObject({
|
||||||
|
statusCode: 400
|
||||||
|
});
|
||||||
|
expect(resetStudentCourseProgress).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets progress and returns the summary', async () => {
|
||||||
|
const summary = {
|
||||||
|
lessonCompletion: 2,
|
||||||
|
lessonVideoProgress: 1,
|
||||||
|
submissions: 3,
|
||||||
|
groupAttendance: 0,
|
||||||
|
lessonComments: 1,
|
||||||
|
courseNewsfeed: 0,
|
||||||
|
courseNewsfeedComments: 0,
|
||||||
|
courseCertificateIssues: 0,
|
||||||
|
courseCompletionRecords: 0,
|
||||||
|
aiChatConversations: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(getCourseMember).mockResolvedValue({
|
||||||
|
id: 'member-1',
|
||||||
|
profileId: 'profile-1',
|
||||||
|
roleId: ROLE.STUDENT
|
||||||
|
} as Awaited<ReturnType<typeof getCourseMember>>);
|
||||||
|
vi.mocked(resetStudentCourseProgress).mockResolvedValue(summary);
|
||||||
|
|
||||||
|
const result = await resetMemberCourseProgress('course-1', 'member-1', 'actor-1');
|
||||||
|
|
||||||
|
expect(resetStudentCourseProgress).toHaveBeenCalledWith({
|
||||||
|
courseId: 'course-1',
|
||||||
|
groupMemberId: 'member-1',
|
||||||
|
profileId: 'profile-1'
|
||||||
|
});
|
||||||
|
expect(result).toEqual(summary);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,12 +2,19 @@ import {
|
|||||||
ZAddCourseMembers,
|
ZAddCourseMembers,
|
||||||
ZCourseMembersMemberParam,
|
ZCourseMembersMemberParam,
|
||||||
ZCourseMembersParam,
|
ZCourseMembersParam,
|
||||||
|
ZResetCourseMemberProgressParam,
|
||||||
ZUpdateCourseMember
|
ZUpdateCourseMember
|
||||||
} from '@cio/utils/validation/course/people';
|
} from '@cio/utils/validation/course/people';
|
||||||
import { addMembers, deleteMember, listCourseMembers, updateMember } from '@api/services/course/people';
|
import {
|
||||||
|
addMembers,
|
||||||
|
deleteMember,
|
||||||
|
listCourseMembers,
|
||||||
|
resetMemberCourseProgress,
|
||||||
|
updateMember
|
||||||
|
} from '@api/services/course/people';
|
||||||
|
|
||||||
import { Hono } from '@api/utils/hono';
|
import { Hono } from '@api/utils/hono';
|
||||||
import { ZCourseUserAnalyticsParam } from '@cio/utils/validation/course';
|
import { ZCourseUserAnalyticsParam, ZCourseUserAnalyticsQuery } from '@cio/utils/validation/course';
|
||||||
import { courseTeamMemberMiddleware } from '@api/middlewares/course-team-member';
|
import { courseTeamMemberMiddleware } from '@api/middlewares/course-team-member';
|
||||||
import { getUserCourseAnalytics } from '@cio/core/services/course/course';
|
import { getUserCourseAnalytics } from '@cio/core/services/course/course';
|
||||||
import { handleError } from '@api/utils/errors';
|
import { handleError } from '@api/utils/errors';
|
||||||
@@ -115,15 +122,48 @@ export const membersRouter = new Hono()
|
|||||||
return handleError(c, error, 'Failed to delete course member');
|
return handleError(c, error, 'Failed to delete course member');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
/**
|
||||||
|
* POST /course/:courseId/members/:memberId/reset-progress
|
||||||
|
* Clears all learner progress for a course member while keeping them enrolled.
|
||||||
|
* Requires authentication and course team membership (admin/tutor role)
|
||||||
|
*/
|
||||||
|
.post(
|
||||||
|
'/:memberId/reset-progress',
|
||||||
|
courseTeamMemberMiddleware,
|
||||||
|
zValidator('param', ZResetCourseMemberProgressParam),
|
||||||
|
async (c) => {
|
||||||
|
try {
|
||||||
|
const user = c.get('user')!;
|
||||||
|
const { courseId, memberId } = c.req.valid('param');
|
||||||
|
const summary = await resetMemberCourseProgress(courseId, memberId, user.id);
|
||||||
|
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
data: summary
|
||||||
|
},
|
||||||
|
200
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to reset course member progress');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
/**
|
/**
|
||||||
* GET /course/:courseId/members/:userId/analytics
|
* GET /course/:courseId/members/:userId/analytics
|
||||||
* Gets user course analytics for a specific course
|
* Gets user course analytics for a specific course
|
||||||
* Requires authentication and course membership
|
* Requires authentication and course membership
|
||||||
*/
|
*/
|
||||||
.get('/:userId/analytics', courseTeamMemberMiddleware, zValidator('param', ZCourseUserAnalyticsParam), async (c) => {
|
.get(
|
||||||
|
'/:userId/analytics',
|
||||||
|
courseTeamMemberMiddleware,
|
||||||
|
zValidator('param', ZCourseUserAnalyticsParam),
|
||||||
|
zValidator('query', ZCourseUserAnalyticsQuery),
|
||||||
|
async (c) => {
|
||||||
try {
|
try {
|
||||||
const { courseId, userId } = c.req.valid('param');
|
const { courseId, userId } = c.req.valid('param');
|
||||||
const analytics = await getUserCourseAnalytics(courseId, userId);
|
const { includeProgressImpact } = c.req.valid('query');
|
||||||
|
const analytics = await getUserCourseAnalytics(courseId, userId, { includeProgressImpact });
|
||||||
|
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
@@ -135,4 +175,5 @@ export const membersRouter = new Hono()
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleError(c, error, 'Failed to fetch user course analytics');
|
return handleError(c, error, 'Failed to fetch user course analytics');
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ROLE } from '@cio/utils/constants';
|
||||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||||
import {
|
import {
|
||||||
addCourseMember,
|
addCourseMember,
|
||||||
@@ -7,8 +8,8 @@ import {
|
|||||||
getCourseTeachers,
|
getCourseTeachers,
|
||||||
updateCourseMember
|
updateCourseMember
|
||||||
} from '@cio/db/queries/course/people';
|
} from '@cio/db/queries/course/people';
|
||||||
|
import { resetStudentCourseProgress } from '@cio/db/queries/course/reset-progress';
|
||||||
|
|
||||||
import { ROLE } from '@cio/utils/constants';
|
|
||||||
import type { TAddCourseMembers } from '@cio/utils/validation/course/people';
|
import type { TAddCourseMembers } from '@cio/utils/validation/course/people';
|
||||||
import type { TGroupmember } from '@cio/db/types';
|
import type { TGroupmember } from '@cio/db/types';
|
||||||
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
|
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
|
||||||
@@ -316,3 +317,43 @@ export async function deleteMember(courseId: string, memberId: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets all learner progress for a course member while keeping them enrolled.
|
||||||
|
*/
|
||||||
|
export async function resetMemberCourseProgress(courseId: string, memberId: string, actorProfileId: string) {
|
||||||
|
try {
|
||||||
|
const member = await getCourseMember(courseId, memberId);
|
||||||
|
if (!member || !member.profileId) {
|
||||||
|
throw new AppError('Course member not found', ErrorCodes.NOT_FOUND, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (member.roleId !== ROLE.STUDENT) {
|
||||||
|
throw new AppError('Only student progress can be reset', ErrorCodes.VALIDATION_ERROR, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = await resetStudentCourseProgress({
|
||||||
|
courseId,
|
||||||
|
groupMemberId: memberId,
|
||||||
|
profileId: member.profileId
|
||||||
|
});
|
||||||
|
|
||||||
|
console.info('resetMemberCourseProgress', {
|
||||||
|
actorProfileId,
|
||||||
|
courseId,
|
||||||
|
memberId,
|
||||||
|
summary
|
||||||
|
});
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AppError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new AppError(
|
||||||
|
error instanceof Error ? error.message : 'Failed to reset course member progress',
|
||||||
|
ErrorCodes.INTERNAL_ERROR,
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
GetUserCourseAnalyticsRequest,
|
GetUserCourseAnalyticsRequest,
|
||||||
ListStudentInvitesRequest,
|
ListStudentInvitesRequest,
|
||||||
ListPeopleRequest,
|
ListPeopleRequest,
|
||||||
|
ResetMemberCourseProgressRequest,
|
||||||
RevokeStudentInviteRequest,
|
RevokeStudentInviteRequest,
|
||||||
UpdatePeopleRequest
|
UpdatePeopleRequest
|
||||||
} from '../utils/types';
|
} from '../utils/types';
|
||||||
@@ -290,6 +291,29 @@ export class PeopleApi extends BaseApiWithErrors {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets all learner progress for a course member while keeping them enrolled.
|
||||||
|
*/
|
||||||
|
async resetCourseProgress(courseId: string, memberId: string) {
|
||||||
|
await this.execute<ResetMemberCourseProgressRequest>({
|
||||||
|
requestFn: () =>
|
||||||
|
classroomio.course[':courseId']['members'][':memberId']['reset-progress'].$post({
|
||||||
|
param: { courseId, memberId }
|
||||||
|
}),
|
||||||
|
logContext: 'resetting course member progress',
|
||||||
|
onSuccess: () => {
|
||||||
|
snackbar.success('snackbar.course.people.progress_reset');
|
||||||
|
this.success = true;
|
||||||
|
this.errors = {};
|
||||||
|
},
|
||||||
|
onError: (result) => {
|
||||||
|
if (typeof result === 'string') {
|
||||||
|
snackbar.error('snackbar.course.people.progress_reset_failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const peopleApi = /* @__PURE__ */ new PeopleApi();
|
export const peopleApi = /* @__PURE__ */ new PeopleApi();
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '@cio/ui/base/button';
|
||||||
|
import { t } from '$lib/utils/functions/translations';
|
||||||
|
import { courseApi } from '$features/course/api';
|
||||||
|
import type { UserCourseAnalytics } from '$features/course/utils/types';
|
||||||
|
import ResetProgressDialog from './reset-progress-dialog.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
courseId,
|
||||||
|
personId,
|
||||||
|
userCourseAnalytics,
|
||||||
|
onSuccess = () => {}
|
||||||
|
}: {
|
||||||
|
courseId: string;
|
||||||
|
personId: string;
|
||||||
|
userCourseAnalytics: UserCourseAnalytics;
|
||||||
|
onSuccess?: () => void | Promise<void>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let dialogOpen = $state(false);
|
||||||
|
|
||||||
|
const memberId = $derived(courseApi.group.people.find((member) => member.profileId === personId)?.id ?? '');
|
||||||
|
const studentName = $derived(userCourseAnalytics.user.fullName || userCourseAnalytics.user.email || '');
|
||||||
|
const canReset = $derived(Boolean(memberId && userCourseAnalytics.progressImpact));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Button variant="destructive" onclick={() => (dialogOpen = true)} disabled={!canReset}>
|
||||||
|
{$t('course.navItem.people.reset_progress.button')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{#if canReset}
|
||||||
|
<ResetProgressDialog bind:open={dialogOpen} {courseId} {memberId} {studentName} {userCourseAnalytics} {onSuccess} />
|
||||||
|
{/if}
|
||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as Dialog from '@cio/ui/base/dialog';
|
||||||
|
import { Button } from '@cio/ui/base/button';
|
||||||
|
import { t } from '$lib/utils/functions/translations';
|
||||||
|
import { peopleApi } from '$features/course/api/people.svelte';
|
||||||
|
import type { UserCourseAnalytics } from '$features/course/utils/types';
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = $bindable(false),
|
||||||
|
courseId,
|
||||||
|
memberId,
|
||||||
|
studentName,
|
||||||
|
userCourseAnalytics,
|
||||||
|
onSuccess = () => {}
|
||||||
|
}: {
|
||||||
|
open?: boolean;
|
||||||
|
courseId: string;
|
||||||
|
memberId: string;
|
||||||
|
studentName: string;
|
||||||
|
userCourseAnalytics: UserCourseAnalytics;
|
||||||
|
onSuccess?: () => void | Promise<void>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const progressImpact = $derived(userCourseAnalytics.progressImpact);
|
||||||
|
|
||||||
|
const impactBullets = $derived.by(() => {
|
||||||
|
if (!progressImpact) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'lessons',
|
||||||
|
text: t.get('course.navItem.people.reset_progress.dialog.bullets.lessons', {
|
||||||
|
completed: progressImpact.completedLessons,
|
||||||
|
total: progressImpact.totalLessons
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'exercises',
|
||||||
|
text: t.get('course.navItem.people.reset_progress.dialog.bullets.exercises', {
|
||||||
|
count: progressImpact.exerciseSubmissions
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'comments',
|
||||||
|
text: t.get('course.navItem.people.reset_progress.dialog.bullets.comments', {
|
||||||
|
count: progressImpact.lessonComments
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'newsfeed',
|
||||||
|
text: t.get('course.navItem.people.reset_progress.dialog.bullets.newsfeed', {
|
||||||
|
count: progressImpact.courseNewsfeedActivity
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'video_progress',
|
||||||
|
text: t.get('course.navItem.people.reset_progress.dialog.bullets.video_progress', {
|
||||||
|
count: progressImpact.videoProgressLessons
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'attendance',
|
||||||
|
text: t.get('course.navItem.people.reset_progress.dialog.bullets.attendance', {
|
||||||
|
count: progressImpact.attendanceEntries
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'certificate',
|
||||||
|
text: t.get(
|
||||||
|
progressImpact.hasCertificationRecords
|
||||||
|
? 'course.navItem.people.reset_progress.dialog.bullets.certificate_yes'
|
||||||
|
: 'course.navItem.people.reset_progress.dialog.bullets.certificate_no'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleReset() {
|
||||||
|
if (!courseId || !memberId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await peopleApi.resetCourseProgress(courseId, memberId);
|
||||||
|
|
||||||
|
if (peopleApi.success) {
|
||||||
|
open = false;
|
||||||
|
await onSuccess();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open>
|
||||||
|
<Dialog.Content class="max-w-lg">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>
|
||||||
|
{$t('course.navItem.people.reset_progress.dialog.title', { studentName })}
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
{$t('course.navItem.people.reset_progress.dialog.intro')}
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
<ul class="list-disc space-y-2 pl-5 text-sm">
|
||||||
|
{#each impactBullets as bullet (bullet.key)}
|
||||||
|
<li>{bullet.text}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p class="ui:text-destructive mt-4 text-sm">
|
||||||
|
{$t('course.navItem.people.reset_progress.dialog.warning')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button variant="outline" onclick={() => (open = false)} disabled={peopleApi.isLoading}>
|
||||||
|
{$t('course.navItem.people.reset_progress.dialog.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onclick={handleReset} loading={peopleApi.isLoading} disabled={peopleApi.isLoading}>
|
||||||
|
{$t('course.navItem.people.reset_progress.dialog.confirm')}
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
@@ -404,6 +404,12 @@ export type GetUserCourseAnalyticsResponse = InferResponseType<GetUserCourseAnal
|
|||||||
export type GetUserCourseAnalyticsSuccess = Extract<GetUserCourseAnalyticsResponse, { success: true }>;
|
export type GetUserCourseAnalyticsSuccess = Extract<GetUserCourseAnalyticsResponse, { success: true }>;
|
||||||
export type UserCourseAnalytics = GetUserCourseAnalyticsSuccess['data'];
|
export type UserCourseAnalytics = GetUserCourseAnalyticsSuccess['data'];
|
||||||
|
|
||||||
|
export type ResetMemberCourseProgressRequest =
|
||||||
|
(typeof classroomio.course)[':courseId']['members'][':memberId']['reset-progress']['$post'];
|
||||||
|
export type ResetMemberCourseProgressResponse = InferResponseType<ResetMemberCourseProgressRequest>;
|
||||||
|
export type ResetMemberCourseProgressSuccess = Extract<ResetMemberCourseProgressResponse, { success: true }>;
|
||||||
|
export type ResetMemberCourseProgressSummary = ResetMemberCourseProgressSuccess['data'];
|
||||||
|
|
||||||
export type GetCourseAnalyticsRequest = (typeof classroomio.course)[':courseId']['analytics']['$get'];
|
export type GetCourseAnalyticsRequest = (typeof classroomio.course)[':courseId']['analytics']['$get'];
|
||||||
export type GetCourseAnalyticsResponse = InferResponseType<GetCourseAnalyticsRequest>;
|
export type GetCourseAnalyticsResponse = InferResponseType<GetCourseAnalyticsRequest>;
|
||||||
export type GetCourseAnalyticsSuccess = Extract<GetCourseAnalyticsResponse, { success: true }>;
|
export type GetCourseAnalyticsSuccess = Extract<GetCourseAnalyticsResponse, { success: true }>;
|
||||||
|
|||||||
@@ -242,6 +242,10 @@
|
|||||||
"unpinned": "Frigjort",
|
"unpinned": "Frigjort",
|
||||||
"successfully": "Med succes",
|
"successfully": "Med succes",
|
||||||
"feed_delete_success": "Feedet blev slettet"
|
"feed_delete_success": "Feedet blev slettet"
|
||||||
|
},
|
||||||
|
"people": {
|
||||||
|
"progress_reset": "Fremskridt nulstillet",
|
||||||
|
"progress_reset_failed": "Kunne ikke nulstille fremskridt"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"landing_page_settings": {
|
"landing_page_settings": {
|
||||||
@@ -1100,6 +1104,26 @@
|
|||||||
"no": "Nej",
|
"no": "Nej",
|
||||||
"yes": "Ja"
|
"yes": "Ja"
|
||||||
},
|
},
|
||||||
|
"reset_progress": {
|
||||||
|
"button": "Nulstil fremskridt",
|
||||||
|
"dialog": {
|
||||||
|
"title": "Nulstil fremskridt for {studentName}?",
|
||||||
|
"intro": "Dette sletter permanent denne elevs fremskridt i dette kursus. Eleven forbliver tilmeldt og kan starte forfra fra lektion 1.",
|
||||||
|
"bullets": {
|
||||||
|
"lessons": "Gennemførte lektioner — {completed} af {total}",
|
||||||
|
"exercises": "Afleverede øvelser — {count} afleveringer",
|
||||||
|
"comments": "Lektionskommentarer fra denne elev — {count}",
|
||||||
|
"newsfeed": "Kursusnyhedsfeed-opslag og svar fra denne elev — {count}",
|
||||||
|
"video_progress": "Videofremskridt — {count} lektioner sporet",
|
||||||
|
"attendance": "Fremmødeposter — {count}",
|
||||||
|
"certificate_yes": "Certificerings-/compliance-data — ja",
|
||||||
|
"certificate_no": "Certificerings-/compliance-data — nej"
|
||||||
|
},
|
||||||
|
"warning": "Dette kan ikke fortrydes.",
|
||||||
|
"cancel": "Annuller",
|
||||||
|
"confirm": "Slet"
|
||||||
|
}
|
||||||
|
},
|
||||||
"invite_modal": {
|
"invite_modal": {
|
||||||
"title": "Inviter folk",
|
"title": "Inviter folk",
|
||||||
"invite": "Inviter undervisere",
|
"invite": "Inviter undervisere",
|
||||||
|
|||||||
@@ -242,6 +242,10 @@
|
|||||||
"unpinned": "Nicht angepinnt",
|
"unpinned": "Nicht angepinnt",
|
||||||
"successfully": "Erfolgreich",
|
"successfully": "Erfolgreich",
|
||||||
"feed_delete_success": "Feed erfolgreich gelöscht"
|
"feed_delete_success": "Feed erfolgreich gelöscht"
|
||||||
|
},
|
||||||
|
"people": {
|
||||||
|
"progress_reset": "Fortschritt zurückgesetzt",
|
||||||
|
"progress_reset_failed": "Fortschritt konnte nicht zurückgesetzt werden"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"landing_page_settings": {
|
"landing_page_settings": {
|
||||||
@@ -1100,6 +1104,26 @@
|
|||||||
"no": "Nein",
|
"no": "Nein",
|
||||||
"yes": "Ja"
|
"yes": "Ja"
|
||||||
},
|
},
|
||||||
|
"reset_progress": {
|
||||||
|
"button": "Fortschritt zurücksetzen",
|
||||||
|
"dialog": {
|
||||||
|
"title": "Fortschritt von {studentName} zurücksetzen?",
|
||||||
|
"intro": "Dadurch wird der Fortschritt dieses Teilnehmers in diesem Kurs dauerhaft gelöscht. Die Person bleibt eingeschrieben und kann bei Lektion 1 neu beginnen.",
|
||||||
|
"bullets": {
|
||||||
|
"lessons": "Abgeschlossene Lektionen — {completed} von {total}",
|
||||||
|
"exercises": "Eingereichte Übungen — {count} Abgaben",
|
||||||
|
"comments": "Lektionskommentare dieses Teilnehmers — {count}",
|
||||||
|
"newsfeed": "Kurs-Newsfeed-Beiträge und Antworten dieses Teilnehmers — {count}",
|
||||||
|
"video_progress": "Videofortschritt — {count} verfolgte Lektionen",
|
||||||
|
"attendance": "Anwesenheitseinträge — {count}",
|
||||||
|
"certificate_yes": "Zertifizierungs-/Compliance-Daten — ja",
|
||||||
|
"certificate_no": "Zertifizierungs-/Compliance-Daten — nein"
|
||||||
|
},
|
||||||
|
"warning": "Dies kann nicht rückgängig gemacht werden.",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"confirm": "Löschen"
|
||||||
|
}
|
||||||
|
},
|
||||||
"invite_modal": {
|
"invite_modal": {
|
||||||
"title": "Leute einladen",
|
"title": "Leute einladen",
|
||||||
"invite": "Laden Sie Tutoren ein",
|
"invite": "Laden Sie Tutoren ein",
|
||||||
|
|||||||
@@ -3308,6 +3308,26 @@
|
|||||||
"no": "No",
|
"no": "No",
|
||||||
"yes": "Yes"
|
"yes": "Yes"
|
||||||
},
|
},
|
||||||
|
"reset_progress": {
|
||||||
|
"button": "Reset progress",
|
||||||
|
"dialog": {
|
||||||
|
"title": "Reset progress for {studentName}?",
|
||||||
|
"intro": "This permanently deletes this student's progress in this course. They will stay enrolled and can restart from lesson 1.",
|
||||||
|
"bullets": {
|
||||||
|
"lessons": "Lessons completed — {completed} of {total}",
|
||||||
|
"exercises": "Exercises submitted — {count} submissions",
|
||||||
|
"comments": "Lesson comments by this student — {count}",
|
||||||
|
"newsfeed": "Course newsfeed posts and replies by this student — {count}",
|
||||||
|
"video_progress": "Video watch progress — {count} lessons tracked",
|
||||||
|
"attendance": "Attendance entries — {count}",
|
||||||
|
"certificate_yes": "Certification / compliance records — yes",
|
||||||
|
"certificate_no": "Certification / compliance records — no"
|
||||||
|
},
|
||||||
|
"warning": "This cannot be undone.",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirm": "Delete"
|
||||||
|
}
|
||||||
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"tutor": "Tutor",
|
"tutor": "Tutor",
|
||||||
@@ -4196,6 +4216,10 @@
|
|||||||
"toggle_error": "Failed to toggle pinned feed",
|
"toggle_error": "Failed to toggle pinned feed",
|
||||||
"news_feed_fail": "Failed to fetch news feeds",
|
"news_feed_fail": "Failed to fetch news feeds",
|
||||||
"feed_fetch_error": "An error occured while fetching feed"
|
"feed_fetch_error": "An error occured while fetching feed"
|
||||||
|
},
|
||||||
|
"people": {
|
||||||
|
"progress_reset": "Progress reset",
|
||||||
|
"progress_reset_failed": "Failed to reset progress"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lms": {
|
"lms": {
|
||||||
|
|||||||
@@ -242,6 +242,10 @@
|
|||||||
"unpinned": "Desanclar",
|
"unpinned": "Desanclar",
|
||||||
"successfully": "Con éxito",
|
"successfully": "Con éxito",
|
||||||
"feed_delete_success": "Feed eliminado correctamente"
|
"feed_delete_success": "Feed eliminado correctamente"
|
||||||
|
},
|
||||||
|
"people": {
|
||||||
|
"progress_reset": "Progreso restablecido",
|
||||||
|
"progress_reset_failed": "No se pudo restablecer el progreso"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"landing_page_settings": {
|
"landing_page_settings": {
|
||||||
@@ -1100,6 +1104,26 @@
|
|||||||
"no": "No",
|
"no": "No",
|
||||||
"yes": "si"
|
"yes": "si"
|
||||||
},
|
},
|
||||||
|
"reset_progress": {
|
||||||
|
"button": "Restablecer progreso",
|
||||||
|
"dialog": {
|
||||||
|
"title": "¿Restablecer el progreso de {studentName}?",
|
||||||
|
"intro": "Esto elimina permanentemente el progreso de este estudiante en este curso. Seguirá inscrito y podrá empezar de nuevo desde la lección 1.",
|
||||||
|
"bullets": {
|
||||||
|
"lessons": "Lecciones completadas — {completed} de {total}",
|
||||||
|
"exercises": "Ejercicios enviados — {count} entregas",
|
||||||
|
"comments": "Comentarios de lección de este estudiante — {count}",
|
||||||
|
"newsfeed": "Publicaciones y respuestas del feed del curso de este estudiante — {count}",
|
||||||
|
"video_progress": "Progreso de visualización de video — {count} lecciones registradas",
|
||||||
|
"attendance": "Registros de asistencia — {count}",
|
||||||
|
"certificate_yes": "Registros de certificación/cumplimiento — sí",
|
||||||
|
"certificate_no": "Registros de certificación/cumplimiento — no"
|
||||||
|
},
|
||||||
|
"warning": "Esto no se puede deshacer.",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"confirm": "Eliminar"
|
||||||
|
}
|
||||||
|
},
|
||||||
"invite_modal": {
|
"invite_modal": {
|
||||||
"title": "invitar gente",
|
"title": "invitar gente",
|
||||||
"invite": "Invitar tutores",
|
"invite": "Invitar tutores",
|
||||||
|
|||||||
@@ -242,6 +242,10 @@
|
|||||||
"unpinned": "Désépinglé",
|
"unpinned": "Désépinglé",
|
||||||
"successfully": "Avec succès",
|
"successfully": "Avec succès",
|
||||||
"feed_delete_success": "Flux supprimé avec succès"
|
"feed_delete_success": "Flux supprimé avec succès"
|
||||||
|
},
|
||||||
|
"people": {
|
||||||
|
"progress_reset": "Progression réinitialisée",
|
||||||
|
"progress_reset_failed": "Échec de la réinitialisation de la progression"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"landing_page_settings": {
|
"landing_page_settings": {
|
||||||
@@ -1100,6 +1104,26 @@
|
|||||||
"no": "Non",
|
"no": "Non",
|
||||||
"yes": "Oui"
|
"yes": "Oui"
|
||||||
},
|
},
|
||||||
|
"reset_progress": {
|
||||||
|
"button": "Réinitialiser la progression",
|
||||||
|
"dialog": {
|
||||||
|
"title": "Réinitialiser la progression de {studentName} ?",
|
||||||
|
"intro": "Cela supprime définitivement la progression de cet apprenant dans ce cours. Il restera inscrit et pourra recommencer à la leçon 1.",
|
||||||
|
"bullets": {
|
||||||
|
"lessons": "Leçons terminées — {completed} sur {total}",
|
||||||
|
"exercises": "Exercices soumis — {count} soumissions",
|
||||||
|
"comments": "Commentaires de leçon de cet apprenant — {count}",
|
||||||
|
"newsfeed": "Publications et réponses du fil d’actualité du cours de cet apprenant — {count}",
|
||||||
|
"video_progress": "Progression de visionnage vidéo — {count} leçons suivies",
|
||||||
|
"attendance": "Entrées de présence — {count}",
|
||||||
|
"certificate_yes": "Dossiers de certification/conformité — oui",
|
||||||
|
"certificate_no": "Dossiers de certification/conformité — non"
|
||||||
|
},
|
||||||
|
"warning": "Cette action est irréversible.",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"confirm": "Supprimer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"invite_modal": {
|
"invite_modal": {
|
||||||
"title": "Inviter des personnes",
|
"title": "Inviter des personnes",
|
||||||
"invite": "Inviter des tuteurs",
|
"invite": "Inviter des tuteurs",
|
||||||
|
|||||||
@@ -242,6 +242,10 @@
|
|||||||
"unpinned": "अनपिन किया गया",
|
"unpinned": "अनपिन किया गया",
|
||||||
"successfully": "सफलतापूर्वक",
|
"successfully": "सफलतापूर्वक",
|
||||||
"feed_delete_success": "फ़ीड सफलतापूर्वक हटा दी गई"
|
"feed_delete_success": "फ़ीड सफलतापूर्वक हटा दी गई"
|
||||||
|
},
|
||||||
|
"people": {
|
||||||
|
"progress_reset": "प्रगति रीसेट की गई",
|
||||||
|
"progress_reset_failed": "प्रगति रीसेट नहीं हो सकी"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"landing_page_settings": {
|
"landing_page_settings": {
|
||||||
@@ -1100,6 +1104,26 @@
|
|||||||
"no": "नहीं",
|
"no": "नहीं",
|
||||||
"yes": "हाँ"
|
"yes": "हाँ"
|
||||||
},
|
},
|
||||||
|
"reset_progress": {
|
||||||
|
"button": "प्रगति रीसेट करें",
|
||||||
|
"dialog": {
|
||||||
|
"title": "{studentName} की प्रगति रीसेट करें?",
|
||||||
|
"intro": "यह इस कोर्स में इस छात्र की प्रगति को स्थायी रूप से हटा देगा। वे नामांकित रहेंगे और पाठ 1 से फिर से शुरू कर सकेंगे।",
|
||||||
|
"bullets": {
|
||||||
|
"lessons": "पूर्ण पाठ — {completed} में से {total}",
|
||||||
|
"exercises": "जमा किए गए अभ्यास — {count} सबमिशन",
|
||||||
|
"comments": "इस छात्र के पाठ टिप्पणियाँ — {count}",
|
||||||
|
"newsfeed": "इस छात्र की कोर्स न्यूज़फ़ीड पोस्ट और जवाब — {count}",
|
||||||
|
"video_progress": "वीडियो देखने की प्रगति — {count} ट्रैक किए गए पाठ",
|
||||||
|
"attendance": "उपस्थिति प्रविष्टियाँ — {count}",
|
||||||
|
"certificate_yes": "प्रमाणन/अनुपालन रिकॉर्ड — हाँ",
|
||||||
|
"certificate_no": "प्रमाणन/अनुपालन रिकॉर्ड — नहीं"
|
||||||
|
},
|
||||||
|
"warning": "इसे पूर्ववत नहीं किया जा सकता।",
|
||||||
|
"cancel": "रद्द करें",
|
||||||
|
"confirm": "हटाएँ"
|
||||||
|
}
|
||||||
|
},
|
||||||
"invite_modal": {
|
"invite_modal": {
|
||||||
"title": "लोगों को आमंत्रित करें",
|
"title": "लोगों को आमंत्रित करें",
|
||||||
"invite": "शिक्षकों को आमंत्रित करें",
|
"invite": "शिक्षकों को आमंत्रित करें",
|
||||||
|
|||||||
@@ -242,6 +242,10 @@
|
|||||||
"unpinned": "Odpięty",
|
"unpinned": "Odpięty",
|
||||||
"successfully": "Pomyślnie",
|
"successfully": "Pomyślnie",
|
||||||
"feed_delete_success": "Kanał został pomyślnie usunięty"
|
"feed_delete_success": "Kanał został pomyślnie usunięty"
|
||||||
|
},
|
||||||
|
"people": {
|
||||||
|
"progress_reset": "Postęp został zresetowany",
|
||||||
|
"progress_reset_failed": "Nie udało się zresetować postępu"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"landing_page_settings": {
|
"landing_page_settings": {
|
||||||
@@ -1100,6 +1104,26 @@
|
|||||||
"no": "Nie",
|
"no": "Nie",
|
||||||
"yes": "Tak"
|
"yes": "Tak"
|
||||||
},
|
},
|
||||||
|
"reset_progress": {
|
||||||
|
"button": "Zresetuj postęp",
|
||||||
|
"dialog": {
|
||||||
|
"title": "Zresetować postęp użytkownika {studentName}?",
|
||||||
|
"intro": "Spowoduje to trwałe usunięcie postępu tego uczestnika w tym kursie. Pozostanie zapisany i będzie mógł zacząć od lekcji 1.",
|
||||||
|
"bullets": {
|
||||||
|
"lessons": "Ukończone lekcje — {completed} z {total}",
|
||||||
|
"exercises": "Przesłane ćwiczenia — {count} zgłoszeń",
|
||||||
|
"comments": "Komentarze do lekcji tego uczestnika — {count}",
|
||||||
|
"newsfeed": "Wpisy i odpowiedzi na kanale kursu tego uczestnika — {count}",
|
||||||
|
"video_progress": "Postęp oglądania wideo — {count} śledzonych lekcji",
|
||||||
|
"attendance": "Wpisy obecności — {count}",
|
||||||
|
"certificate_yes": "Rekordy certyfikacji/zgodności — tak",
|
||||||
|
"certificate_no": "Rekordy certyfikacji/zgodności — nie"
|
||||||
|
},
|
||||||
|
"warning": "Tej operacji nie można cofnąć.",
|
||||||
|
"cancel": "Anuluj",
|
||||||
|
"confirm": "Usuń"
|
||||||
|
}
|
||||||
|
},
|
||||||
"invite_modal": {
|
"invite_modal": {
|
||||||
"title": "Zaproś ludzi",
|
"title": "Zaproś ludzi",
|
||||||
"invite": "Zaproś korepetytorów",
|
"invite": "Zaproś korepetytorów",
|
||||||
|
|||||||
@@ -242,6 +242,10 @@
|
|||||||
"unpinned": "Desafixado",
|
"unpinned": "Desafixado",
|
||||||
"successfully": "Com sucesso",
|
"successfully": "Com sucesso",
|
||||||
"feed_delete_success": "Feed excluído com sucesso"
|
"feed_delete_success": "Feed excluído com sucesso"
|
||||||
|
},
|
||||||
|
"people": {
|
||||||
|
"progress_reset": "Progresso redefinido",
|
||||||
|
"progress_reset_failed": "Falha ao redefinir o progresso"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"landing_page_settings": {
|
"landing_page_settings": {
|
||||||
@@ -1100,6 +1104,26 @@
|
|||||||
"no": "Não",
|
"no": "Não",
|
||||||
"yes": "Sim"
|
"yes": "Sim"
|
||||||
},
|
},
|
||||||
|
"reset_progress": {
|
||||||
|
"button": "Redefinir progresso",
|
||||||
|
"dialog": {
|
||||||
|
"title": "Redefinir o progresso de {studentName}?",
|
||||||
|
"intro": "Isso exclui permanentemente o progresso deste aluno neste curso. Ele permanecerá matriculado e poderá recomeçar da lição 1.",
|
||||||
|
"bullets": {
|
||||||
|
"lessons": "Lições concluídas — {completed} de {total}",
|
||||||
|
"exercises": "Exercícios enviados — {count} envios",
|
||||||
|
"comments": "Comentários de lição deste aluno — {count}",
|
||||||
|
"newsfeed": "Publicações e respostas no feed do curso deste aluno — {count}",
|
||||||
|
"video_progress": "Progresso de visualização de vídeo — {count} lições acompanhadas",
|
||||||
|
"attendance": "Registros de presença — {count}",
|
||||||
|
"certificate_yes": "Registros de certificação/conformidade — sim",
|
||||||
|
"certificate_no": "Registros de certificação/conformidade — não"
|
||||||
|
},
|
||||||
|
"warning": "Isso não pode ser desfeito.",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"confirm": "Excluir"
|
||||||
|
}
|
||||||
|
},
|
||||||
"invite_modal": {
|
"invite_modal": {
|
||||||
"title": "Convide pessoas",
|
"title": "Convide pessoas",
|
||||||
"invite": "Convidar tutores",
|
"invite": "Convidar tutores",
|
||||||
|
|||||||
@@ -242,6 +242,10 @@
|
|||||||
"unpinned": "Откреплено",
|
"unpinned": "Откреплено",
|
||||||
"successfully": "Успешно",
|
"successfully": "Успешно",
|
||||||
"feed_delete_success": "Фид успешно удален"
|
"feed_delete_success": "Фид успешно удален"
|
||||||
|
},
|
||||||
|
"people": {
|
||||||
|
"progress_reset": "Прогресс сброшен",
|
||||||
|
"progress_reset_failed": "Не удалось сбросить прогресс"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"landing_page_settings": {
|
"landing_page_settings": {
|
||||||
@@ -1100,6 +1104,26 @@
|
|||||||
"no": "Нет",
|
"no": "Нет",
|
||||||
"yes": "Да"
|
"yes": "Да"
|
||||||
},
|
},
|
||||||
|
"reset_progress": {
|
||||||
|
"button": "Сбросить прогресс",
|
||||||
|
"dialog": {
|
||||||
|
"title": "Сбросить прогресс для {studentName}?",
|
||||||
|
"intro": "Это навсегда удалит прогресс этого студента в данном курсе. Он останется зачисленным и сможет начать заново с урока 1.",
|
||||||
|
"bullets": {
|
||||||
|
"lessons": "Завершённые уроки — {completed} из {total}",
|
||||||
|
"exercises": "Отправленные упражнения — {count} отправок",
|
||||||
|
"comments": "Комментарии к урокам этого студента — {count}",
|
||||||
|
"newsfeed": "Посты и ответы в ленте курса от этого студента — {count}",
|
||||||
|
"video_progress": "Прогресс просмотра видео — {count} отслеживаемых уроков",
|
||||||
|
"attendance": "Записи посещаемости — {count}",
|
||||||
|
"certificate_yes": "Записи сертификации/соответствия — да",
|
||||||
|
"certificate_no": "Записи сертификации/соответствия — нет"
|
||||||
|
},
|
||||||
|
"warning": "Это действие нельзя отменить.",
|
||||||
|
"cancel": "Отмена",
|
||||||
|
"confirm": "Удалить"
|
||||||
|
}
|
||||||
|
},
|
||||||
"invite_modal": {
|
"invite_modal": {
|
||||||
"title": "Пригласить людей",
|
"title": "Пригласить людей",
|
||||||
"invite": "Пригласить репетиторов",
|
"invite": "Пригласить репетиторов",
|
||||||
|
|||||||
@@ -242,6 +242,10 @@
|
|||||||
"unpinned": "Đã bỏ ghim",
|
"unpinned": "Đã bỏ ghim",
|
||||||
"successfully": "thành công",
|
"successfully": "thành công",
|
||||||
"feed_delete_success": "Đã xóa nguồn cấp dữ liệu thành công"
|
"feed_delete_success": "Đã xóa nguồn cấp dữ liệu thành công"
|
||||||
|
},
|
||||||
|
"people": {
|
||||||
|
"progress_reset": "Đã đặt lại tiến độ",
|
||||||
|
"progress_reset_failed": "Không thể đặt lại tiến độ"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"landing_page_settings": {
|
"landing_page_settings": {
|
||||||
@@ -1100,6 +1104,26 @@
|
|||||||
"no": "Không",
|
"no": "Không",
|
||||||
"yes": "Có"
|
"yes": "Có"
|
||||||
},
|
},
|
||||||
|
"reset_progress": {
|
||||||
|
"button": "Đặt lại tiến độ",
|
||||||
|
"dialog": {
|
||||||
|
"title": "Đặt lại tiến độ cho {studentName}?",
|
||||||
|
"intro": "Thao tác này xóa vĩnh viễn tiến độ của học viên này trong khóa học. Họ vẫn được ghi danh và có thể bắt đầu lại từ bài 1.",
|
||||||
|
"bullets": {
|
||||||
|
"lessons": "Bài học đã hoàn thành — {completed}/{total}",
|
||||||
|
"exercises": "Bài tập đã nộp — {count} bài nộp",
|
||||||
|
"comments": "Bình luận bài học của học viên này — {count}",
|
||||||
|
"newsfeed": "Bài đăng và phản hồi bảng tin khóa học của học viên này — {count}",
|
||||||
|
"video_progress": "Tiến độ xem video — {count} bài học được theo dõi",
|
||||||
|
"attendance": "Mục điểm danh — {count}",
|
||||||
|
"certificate_yes": "Hồ sơ chứng nhận/tuân thủ — có",
|
||||||
|
"certificate_no": "Hồ sơ chứng nhận/tuân thủ — không"
|
||||||
|
},
|
||||||
|
"warning": "Không thể hoàn tác.",
|
||||||
|
"cancel": "Hủy",
|
||||||
|
"confirm": "Xóa"
|
||||||
|
}
|
||||||
|
},
|
||||||
"invite_modal": {
|
"invite_modal": {
|
||||||
"title": "Mời mọi người",
|
"title": "Mời mọi người",
|
||||||
"invite": "Mời gia sư",
|
"invite": "Mời gia sư",
|
||||||
|
|||||||
@@ -10,9 +10,13 @@
|
|||||||
import * as Page from '@cio/ui/base/page';
|
import * as Page from '@cio/ui/base/page';
|
||||||
import { courseApi } from '$features/course/api';
|
import { courseApi } from '$features/course/api';
|
||||||
import { profile } from '$lib/utils/store/user';
|
import { profile } from '$lib/utils/store/user';
|
||||||
|
import ResetProgressButton from '$features/course/components/people/reset-progress-button.svelte';
|
||||||
|
import type { UserCourseAnalytics } from '$features/course/utils/types';
|
||||||
|
|
||||||
let { data = $bindable(), children } = $props();
|
let { data = $bindable(), children } = $props();
|
||||||
|
|
||||||
|
let userCourseAnalytics = $derived(page.data.userCourseAnalytics as UserCourseAnalytics | null | undefined);
|
||||||
|
|
||||||
// Get back URL from query parameters
|
// Get back URL from query parameters
|
||||||
let backUrl = $derived(page.url.searchParams.get('back'));
|
let backUrl = $derived(page.url.searchParams.get('back'));
|
||||||
|
|
||||||
@@ -31,6 +35,10 @@
|
|||||||
async function refreshPeoplePage() {
|
async function refreshPeoplePage() {
|
||||||
await Promise.all([courseApi.refreshCourse(data.courseId, $profile.id), invalidateAll()]);
|
await Promise.all([courseApi.refreshCourse(data.courseId, $profile.id), invalidateAll()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleProgressReset() {
|
||||||
|
await invalidateAll();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Page.Root class="mx-auto w-[90%] md:max-w-3xl">
|
<Page.Root class="mx-auto w-[90%] md:max-w-3xl">
|
||||||
@@ -49,6 +57,16 @@
|
|||||||
</Page.HeaderContent>
|
</Page.HeaderContent>
|
||||||
<Page.Action>
|
<Page.Action>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
{#if data.personId && userCourseAnalytics}
|
||||||
|
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||||
|
<ResetProgressButton
|
||||||
|
courseId={data.courseId}
|
||||||
|
personId={data.personId}
|
||||||
|
{userCourseAnalytics}
|
||||||
|
onSuccess={handleProgressReset}
|
||||||
|
/>
|
||||||
|
</RoleBasedSecurity>
|
||||||
|
{/if}
|
||||||
{#if !data.personId}
|
{#if !data.personId}
|
||||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||||
<Button onclick={handleClick}>
|
<Button onclick={handleClick}>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
|
||||||
|
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||||
|
|
||||||
|
import type { GetUserCourseAnalyticsSuccess, UserCourseAnalytics } from '$features/course/utils/types';
|
||||||
|
|
||||||
|
export const load = async ({ params, cookies }) => {
|
||||||
|
const courseId = params.id || '';
|
||||||
|
const personId = params.personId || '';
|
||||||
|
|
||||||
|
if (!courseId || !personId) {
|
||||||
|
return {
|
||||||
|
userCourseAnalytics: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await safeServerApi<GetUserCourseAnalyticsSuccess>(() =>
|
||||||
|
classroomio.course[':courseId']['members'][':userId']['analytics'].$get(
|
||||||
|
{
|
||||||
|
param: { courseId, userId: personId },
|
||||||
|
query: { includeProgressImpact: 'true' }
|
||||||
|
},
|
||||||
|
getApiHeaders(cookies, '')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const userCourseAnalytics: UserCourseAnalytics | null = result.ok && result.body.data ? result.body.data : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
userCourseAnalytics
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch user course analytics:', error);
|
||||||
|
return {
|
||||||
|
userCourseAnalytics: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,42 +1,9 @@
|
|||||||
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
|
export const load = async ({ parent }) => {
|
||||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
const parentData = await parent();
|
||||||
|
|
||||||
import type { GetUserCourseAnalyticsSuccess, UserCourseAnalytics } from '$features/course/utils/types';
|
|
||||||
|
|
||||||
export const load = async ({ params, cookies }) => {
|
|
||||||
const courseId = params.id || '';
|
|
||||||
const userId = params.personId;
|
|
||||||
|
|
||||||
if (!courseId || !userId) {
|
|
||||||
return {
|
|
||||||
courseId,
|
|
||||||
userId,
|
|
||||||
userCourseAnalytics: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await safeServerApi<GetUserCourseAnalyticsSuccess>(() =>
|
|
||||||
classroomio.course[':courseId']['members'][':userId']['analytics'].$get(
|
|
||||||
{
|
|
||||||
param: { courseId, userId }
|
|
||||||
},
|
|
||||||
getApiHeaders(cookies, '')
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const userCourseAnalytics: UserCourseAnalytics | null = result.ok && result.body.data ? result.body.data : null;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
courseId,
|
courseId: parentData.courseId,
|
||||||
userId,
|
userId: parentData.personId,
|
||||||
userCourseAnalytics
|
userCourseAnalytics: parentData.userCourseAnalytics
|
||||||
};
|
};
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch user course analytics:', error);
|
|
||||||
return {
|
|
||||||
courseId,
|
|
||||||
userId,
|
|
||||||
userCourseAnalytics: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,11 +24,15 @@ import {
|
|||||||
getProfileCourseProgress,
|
getProfileCourseProgress,
|
||||||
getUserExercisesStats
|
getUserExercisesStats
|
||||||
} from '@cio/db/queries/analytics/analytics';
|
} from '@cio/db/queries/analytics/analytics';
|
||||||
|
import { getStudentCourseProgressImpactCounts } from '@cio/db/queries/course/reset-progress';
|
||||||
|
import { getCourseGroupId } from '@cio/db/queries/course/people';
|
||||||
|
|
||||||
import { ContentType, ROLE } from '@cio/utils/constants';
|
import { ContentType, ROLE } from '@cio/utils/constants';
|
||||||
import type { TCourse } from '@cio/db/types';
|
import type { TCourse } from '@cio/db/types';
|
||||||
import type { TCourseCreate } from '@cio/utils/validation/course';
|
import type { TCourseCreate } from '@cio/utils/validation/course';
|
||||||
import { db } from '@cio/db/drizzle';
|
import { db } from '@cio/db/drizzle';
|
||||||
|
import * as schema from '@cio/db/schema';
|
||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
import { exerciseBelongsToCourse } from '@cio/db/queries/course/certification-exercise';
|
import { exerciseBelongsToCourse } from '@cio/db/queries/course/certification-exercise';
|
||||||
import { updateExercise, updateExercisesSectionId } from '@cio/db/queries/exercise/exercise';
|
import { updateExercise, updateExercisesSectionId } from '@cio/db/queries/exercise/exercise';
|
||||||
import { getProfileById } from '@cio/db/queries/auth';
|
import { getProfileById } from '@cio/db/queries/auth';
|
||||||
@@ -549,7 +553,11 @@ export async function getCourseAnalytics(courseId: string) {
|
|||||||
* @param userId User ID (profile ID)
|
* @param userId User ID (profile ID)
|
||||||
* @returns User course analytics data
|
* @returns User course analytics data
|
||||||
*/
|
*/
|
||||||
export async function getUserCourseAnalytics(courseId: string, userId: string) {
|
export async function getUserCourseAnalytics(
|
||||||
|
courseId: string,
|
||||||
|
userId: string,
|
||||||
|
options: { includeProgressImpact?: boolean } = {}
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
// Get user profile
|
// Get user profile
|
||||||
const profile = await getProfileById(userId);
|
const profile = await getProfileById(userId);
|
||||||
@@ -581,6 +589,28 @@ export async function getUserCourseAnalytics(courseId: string, userId: string) {
|
|||||||
const completedExercises = userExercisesStats.filter((exercise) => exercise.isCompleted).length;
|
const completedExercises = userExercisesStats.filter((exercise) => exercise.isCompleted).length;
|
||||||
const totalExercises = courseProgress.exercises_count || 0;
|
const totalExercises = courseProgress.exercises_count || 0;
|
||||||
|
|
||||||
|
let progressImpact = null;
|
||||||
|
|
||||||
|
if (options.includeProgressImpact) {
|
||||||
|
const groupId = await getCourseGroupId(courseId);
|
||||||
|
|
||||||
|
if (groupId) {
|
||||||
|
const [groupMember] = await db
|
||||||
|
.select({ id: schema.groupmember.id })
|
||||||
|
.from(schema.groupmember)
|
||||||
|
.where(and(eq(schema.groupmember.groupId, groupId), eq(schema.groupmember.profileId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (groupMember) {
|
||||||
|
progressImpact = await getStudentCourseProgressImpactCounts({
|
||||||
|
courseId,
|
||||||
|
groupMemberId: groupMember.id,
|
||||||
|
profileId: userId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: {
|
user: {
|
||||||
id: userId,
|
id: userId,
|
||||||
@@ -593,7 +623,8 @@ export async function getUserCourseAnalytics(courseId: string, userId: string) {
|
|||||||
userExercisesStats,
|
userExercisesStats,
|
||||||
totalExercises,
|
totalExercises,
|
||||||
completedExercises,
|
completedExercises,
|
||||||
progressPercentage
|
progressPercentage,
|
||||||
|
progressImpact
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export * from './content';
|
|||||||
export * from './progression';
|
export * from './progression';
|
||||||
export * from './content-batch';
|
export * from './content-batch';
|
||||||
export * from './people';
|
export * from './people';
|
||||||
|
export * from './reset-progress';
|
||||||
export * from './invite';
|
export * from './invite';
|
||||||
export * from './public-course';
|
export * from './public-course';
|
||||||
export * from './session';
|
export * from './session';
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
import * as schema from '@db/schema';
|
||||||
|
|
||||||
|
import { and, count, eq, inArray, sql } from '@db/drizzle';
|
||||||
|
import type { DbOrTxClient } from '@db/drizzle';
|
||||||
|
import { db } from '@db/drizzle';
|
||||||
|
|
||||||
|
export type StudentCourseProgressImpactCounts = {
|
||||||
|
completedLessons: number;
|
||||||
|
totalLessons: number;
|
||||||
|
exerciseSubmissions: number;
|
||||||
|
lessonComments: number;
|
||||||
|
courseNewsfeedActivity: number;
|
||||||
|
videoProgressLessons: number;
|
||||||
|
attendanceEntries: number;
|
||||||
|
hasCertificationRecords: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResetStudentCourseProgressSummary = {
|
||||||
|
lessonCompletion: number;
|
||||||
|
lessonVideoProgress: number;
|
||||||
|
submissions: number;
|
||||||
|
groupAttendance: number;
|
||||||
|
lessonComments: number;
|
||||||
|
courseNewsfeed: number;
|
||||||
|
courseNewsfeedComments: number;
|
||||||
|
courseCertificateIssues: number;
|
||||||
|
courseCompletionRecords: number;
|
||||||
|
aiChatConversations: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getCourseLessonIds(tx: DbOrTxClient, courseId: string): Promise<string[]> {
|
||||||
|
const lessons = await tx
|
||||||
|
.select({ id: schema.lesson.id })
|
||||||
|
.from(schema.lesson)
|
||||||
|
.where(eq(schema.lesson.courseId, courseId));
|
||||||
|
|
||||||
|
return lessons.map((lesson) => lesson.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCourseNewsfeedIds(tx: DbOrTxClient, courseId: string): Promise<string[]> {
|
||||||
|
const newsfeeds = await tx
|
||||||
|
.select({ id: schema.courseNewsfeed.id })
|
||||||
|
.from(schema.courseNewsfeed)
|
||||||
|
.where(eq(schema.courseNewsfeed.courseId, courseId));
|
||||||
|
|
||||||
|
return newsfeeds.map((newsfeed) => newsfeed.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStudentCourseNewsfeedPostIds(
|
||||||
|
tx: DbOrTxClient,
|
||||||
|
courseId: string,
|
||||||
|
groupMemberId: string
|
||||||
|
): Promise<string[]> {
|
||||||
|
const posts = await tx
|
||||||
|
.select({ id: schema.courseNewsfeed.id })
|
||||||
|
.from(schema.courseNewsfeed)
|
||||||
|
.where(and(eq(schema.courseNewsfeed.courseId, courseId), eq(schema.courseNewsfeed.authorId, groupMemberId)));
|
||||||
|
|
||||||
|
return posts.map((post) => post.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns counts of progress-like data that would be removed by a course reset.
|
||||||
|
*/
|
||||||
|
export async function getStudentCourseProgressImpactCounts(input: {
|
||||||
|
courseId: string;
|
||||||
|
groupMemberId: string;
|
||||||
|
profileId: string;
|
||||||
|
}): Promise<StudentCourseProgressImpactCounts> {
|
||||||
|
const { courseId, groupMemberId, profileId } = input;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lessonIds = await getCourseLessonIds(db, courseId);
|
||||||
|
const newsfeedIds = await getCourseNewsfeedIds(db, courseId);
|
||||||
|
|
||||||
|
const lessonScope =
|
||||||
|
lessonIds.length > 0
|
||||||
|
? and(eq(schema.lessonCompletion.profileId, profileId), inArray(schema.lessonCompletion.lessonId, lessonIds))
|
||||||
|
: sql`false`;
|
||||||
|
|
||||||
|
const [completedLessonsResult, totalLessonsResult] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(schema.lessonCompletion)
|
||||||
|
.where(and(lessonScope, eq(schema.lessonCompletion.isComplete, true))),
|
||||||
|
db.select({ total: count() }).from(schema.lesson).where(eq(schema.lesson.courseId, courseId))
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [exerciseSubmissionsResult, lessonCommentsResult, newsfeedPostsResult, newsfeedCommentsResult] =
|
||||||
|
await Promise.all([
|
||||||
|
db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(schema.submission)
|
||||||
|
.where(and(eq(schema.submission.courseId, courseId), eq(schema.submission.submittedBy, groupMemberId))),
|
||||||
|
lessonIds.length > 0
|
||||||
|
? db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(schema.lessonComment)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.lessonComment.groupmemberId, groupMemberId),
|
||||||
|
inArray(schema.lessonComment.lessonId, lessonIds)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: Promise.resolve([{ total: 0 }]),
|
||||||
|
db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(schema.courseNewsfeed)
|
||||||
|
.where(and(eq(schema.courseNewsfeed.courseId, courseId), eq(schema.courseNewsfeed.authorId, groupMemberId))),
|
||||||
|
newsfeedIds.length > 0
|
||||||
|
? db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(schema.courseNewsfeedComment)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.courseNewsfeedComment.authorId, groupMemberId),
|
||||||
|
inArray(schema.courseNewsfeedComment.courseNewsfeedId, newsfeedIds)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: Promise.resolve([{ total: 0 }])
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [videoProgressResult, attendanceResult, certificationResult] = await Promise.all([
|
||||||
|
lessonIds.length > 0
|
||||||
|
? db
|
||||||
|
.select({
|
||||||
|
total: sql<number>`COUNT(DISTINCT ${schema.lessonVideoProgress.lessonId})::int`.as('total')
|
||||||
|
})
|
||||||
|
.from(schema.lessonVideoProgress)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.lessonVideoProgress.profileId, profileId),
|
||||||
|
inArray(schema.lessonVideoProgress.lessonId, lessonIds)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: Promise.resolve([{ total: 0 }]),
|
||||||
|
db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(schema.groupAttendance)
|
||||||
|
.where(and(eq(schema.groupAttendance.courseId, courseId), eq(schema.groupAttendance.studentId, groupMemberId))),
|
||||||
|
db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(schema.courseCompletionRecord)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.courseCompletionRecord.courseId, courseId),
|
||||||
|
eq(schema.courseCompletionRecord.profileId, profileId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const courseNewsfeedActivity = (newsfeedPostsResult[0]?.total ?? 0) + (newsfeedCommentsResult[0]?.total ?? 0);
|
||||||
|
|
||||||
|
const certificateIssuesResult = await db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(schema.courseCertificateIssue)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.courseCertificateIssue.courseId, courseId),
|
||||||
|
eq(schema.courseCertificateIssue.profileId, profileId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const certificationCount = (certificationResult[0]?.total ?? 0) + (certificateIssuesResult[0]?.total ?? 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
completedLessons: completedLessonsResult[0]?.total ?? 0,
|
||||||
|
totalLessons: totalLessonsResult[0]?.total ?? 0,
|
||||||
|
exerciseSubmissions: exerciseSubmissionsResult[0]?.total ?? 0,
|
||||||
|
lessonComments: lessonCommentsResult[0]?.total ?? 0,
|
||||||
|
courseNewsfeedActivity,
|
||||||
|
videoProgressLessons: videoProgressResult[0]?.total ?? 0,
|
||||||
|
attendanceEntries: attendanceResult[0]?.total ?? 0,
|
||||||
|
hasCertificationRecords: certificationCount > 0
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getStudentCourseProgressImpactCounts error:', error);
|
||||||
|
throw new Error(
|
||||||
|
`Failed to get student course progress impact counts: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all learner progress for one student in one course inside a single transaction.
|
||||||
|
*/
|
||||||
|
export async function resetStudentCourseProgress(input: {
|
||||||
|
courseId: string;
|
||||||
|
groupMemberId: string;
|
||||||
|
profileId: string;
|
||||||
|
}): Promise<ResetStudentCourseProgressSummary> {
|
||||||
|
const { courseId, groupMemberId, profileId } = input;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await db.transaction(async (tx) => {
|
||||||
|
const lessonIds = await getCourseLessonIds(tx, courseId);
|
||||||
|
const newsfeedIds = await getCourseNewsfeedIds(tx, courseId);
|
||||||
|
const studentNewsfeedPostIds = await getStudentCourseNewsfeedPostIds(tx, courseId, groupMemberId);
|
||||||
|
|
||||||
|
const deletedCommentsOnStudentPosts =
|
||||||
|
studentNewsfeedPostIds.length > 0
|
||||||
|
? await tx
|
||||||
|
.delete(schema.courseNewsfeedComment)
|
||||||
|
.where(inArray(schema.courseNewsfeedComment.courseNewsfeedId, studentNewsfeedPostIds))
|
||||||
|
.returning({ id: schema.courseNewsfeedComment.id })
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const deletedStudentNewsfeedComments =
|
||||||
|
newsfeedIds.length > 0
|
||||||
|
? await tx
|
||||||
|
.delete(schema.courseNewsfeedComment)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.courseNewsfeedComment.authorId, groupMemberId),
|
||||||
|
inArray(schema.courseNewsfeedComment.courseNewsfeedId, newsfeedIds)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning({ id: schema.courseNewsfeedComment.id })
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const deletedNewsfeedPosts = await tx
|
||||||
|
.delete(schema.courseNewsfeed)
|
||||||
|
.where(and(eq(schema.courseNewsfeed.courseId, courseId), eq(schema.courseNewsfeed.authorId, groupMemberId)))
|
||||||
|
.returning({ id: schema.courseNewsfeed.id });
|
||||||
|
|
||||||
|
const deletedSubmissions = await tx
|
||||||
|
.delete(schema.submission)
|
||||||
|
.where(and(eq(schema.submission.courseId, courseId), eq(schema.submission.submittedBy, groupMemberId)))
|
||||||
|
.returning({ id: schema.submission.id });
|
||||||
|
|
||||||
|
const deletedLessonCompletions =
|
||||||
|
lessonIds.length > 0
|
||||||
|
? await tx
|
||||||
|
.delete(schema.lessonCompletion)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.lessonCompletion.profileId, profileId),
|
||||||
|
inArray(schema.lessonCompletion.lessonId, lessonIds)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning({ id: schema.lessonCompletion.id })
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const deletedVideoProgress =
|
||||||
|
lessonIds.length > 0
|
||||||
|
? await tx
|
||||||
|
.delete(schema.lessonVideoProgress)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.lessonVideoProgress.profileId, profileId),
|
||||||
|
inArray(schema.lessonVideoProgress.lessonId, lessonIds)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning({ id: schema.lessonVideoProgress.id })
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const deletedAttendance = await tx
|
||||||
|
.delete(schema.groupAttendance)
|
||||||
|
.where(and(eq(schema.groupAttendance.courseId, courseId), eq(schema.groupAttendance.studentId, groupMemberId)))
|
||||||
|
.returning({ id: schema.groupAttendance.id });
|
||||||
|
|
||||||
|
const deletedLessonComments =
|
||||||
|
lessonIds.length > 0
|
||||||
|
? await tx
|
||||||
|
.delete(schema.lessonComment)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.lessonComment.groupmemberId, groupMemberId),
|
||||||
|
inArray(schema.lessonComment.lessonId, lessonIds)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning({ id: schema.lessonComment.id })
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const deletedCompletionRecords = await tx
|
||||||
|
.delete(schema.courseCompletionRecord)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.courseCompletionRecord.courseId, courseId),
|
||||||
|
eq(schema.courseCompletionRecord.profileId, profileId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning({ id: schema.courseCompletionRecord.id });
|
||||||
|
|
||||||
|
const deletedCertificateIssues = await tx
|
||||||
|
.delete(schema.courseCertificateIssue)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.courseCertificateIssue.courseId, courseId),
|
||||||
|
eq(schema.courseCertificateIssue.profileId, profileId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning({ id: schema.courseCertificateIssue.id });
|
||||||
|
|
||||||
|
const deletedAiChatConversations = await tx
|
||||||
|
.delete(schema.aiChatConversation)
|
||||||
|
.where(and(eq(schema.aiChatConversation.courseId, courseId), eq(schema.aiChatConversation.userId, profileId)))
|
||||||
|
.returning({ id: schema.aiChatConversation.id });
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(schema.groupmember)
|
||||||
|
.set({
|
||||||
|
certificateEarnedAt: null,
|
||||||
|
certificationEmailSentAt: null
|
||||||
|
})
|
||||||
|
.where(eq(schema.groupmember.id, groupMemberId));
|
||||||
|
|
||||||
|
return {
|
||||||
|
lessonCompletion: deletedLessonCompletions.length,
|
||||||
|
lessonVideoProgress: deletedVideoProgress.length,
|
||||||
|
submissions: deletedSubmissions.length,
|
||||||
|
groupAttendance: deletedAttendance.length,
|
||||||
|
lessonComments: deletedLessonComments.length,
|
||||||
|
courseNewsfeed: deletedNewsfeedPosts.length,
|
||||||
|
courseNewsfeedComments: deletedCommentsOnStudentPosts.length + deletedStudentNewsfeedComments.length,
|
||||||
|
courseCertificateIssues: deletedCertificateIssues.length,
|
||||||
|
courseCompletionRecords: deletedCompletionRecords.length,
|
||||||
|
aiChatConversations: deletedAiChatConversations.length
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('resetStudentCourseProgress error:', error);
|
||||||
|
throw new Error(
|
||||||
|
`Failed to reset student course progress: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -468,3 +468,12 @@ export const ZCourseUserAnalyticsParam = z.object({
|
|||||||
userId: z.string().uuid()
|
userId: z.string().uuid()
|
||||||
});
|
});
|
||||||
export type TCourseUserAnalyticsParam = z.infer<typeof ZCourseUserAnalyticsParam>;
|
export type TCourseUserAnalyticsParam = z.infer<typeof ZCourseUserAnalyticsParam>;
|
||||||
|
|
||||||
|
export const ZCourseUserAnalyticsQuery = z.object({
|
||||||
|
includeProgressImpact: z
|
||||||
|
.enum(['true', 'false'])
|
||||||
|
.optional()
|
||||||
|
.default('false')
|
||||||
|
.transform((value) => value === 'true')
|
||||||
|
});
|
||||||
|
export type TCourseUserAnalyticsQuery = z.infer<typeof ZCourseUserAnalyticsQuery>;
|
||||||
|
|||||||
@@ -26,3 +26,9 @@ export const ZUpdateCourseMember = z.object({
|
|||||||
email: z.email().optional()
|
email: z.email().optional()
|
||||||
});
|
});
|
||||||
export type TUpdateCourseMember = z.infer<typeof ZUpdateCourseMember>;
|
export type TUpdateCourseMember = z.infer<typeof ZUpdateCourseMember>;
|
||||||
|
|
||||||
|
export const ZResetCourseMemberProgressParam = z.object({
|
||||||
|
courseId: z.string().uuid(),
|
||||||
|
memberId: z.string().uuid()
|
||||||
|
});
|
||||||
|
export type TResetCourseMemberProgressParam = z.infer<typeof ZResetCourseMemberProgressParam>;
|
||||||
|
|||||||
Reference in New Issue
Block a user