feat: certification rules, content sanitization, handle draft course updates and reset content, better redis logging

This commit is contained in:
Rotimi Best
2026-03-30 08:24:55 +01:00
parent ff2ad59377
commit 5a8f50f67b
131 changed files with 5643 additions and 2134 deletions
@@ -1,5 +1,6 @@
import type {
AssignAudienceCoursesRequest,
DeleteAudienceMemberRequest,
DeleteTeamRequest,
DomainRequestRequest,
GetAudienceRequest,
@@ -8,6 +9,8 @@ import type {
InviteTeamRequest,
OrgPublicCourses,
OrganizationAudience,
OrganizationAudiencePagination,
OrganizationAudienceQuery,
OrganizationTeamMembers,
ResendAudienceInviteRequest,
RevokeAudienceInviteRequest
@@ -36,6 +39,7 @@ import { resolve } from '$app/paths';
import { snackbar } from '$features/ui/snackbar/store';
import { t } from '$lib/utils/functions/translations';
import { uploadImage } from '$lib/utils/services/upload';
import { DEFAULT_ORG_AUDIENCE_QUERY, toAudienceRequestQuery } from '../utils/audience-query-utils';
export interface TOrgUpdateForm {
name?: string;
@@ -59,9 +63,16 @@ export interface TOrgUpdateForm {
class OrgApi extends BaseApiWithErrors {
teamMembers = $state<OrganizationTeamMembers>([]);
audience = $state<OrganizationAudience>([]);
audiencePagination = $state<OrganizationAudiencePagination | null>(null);
publicCourses: OrgPublicCourses = $state([]);
isFetchingOrgPublicCourses = $state(false);
private activeAudienceRequestController: AbortController | null = null;
cancelAudienceRequest() {
this.activeAudienceRequestController?.abort();
this.activeAudienceRequestController = null;
}
/**
* Gets organization team members (non-students)
@@ -91,16 +102,52 @@ class OrgApi extends BaseApiWithErrors {
* @param orgId Organization ID
* @returns Audience array
*/
async getOrgAudience(orgId?: string) {
async getOrgAudience(
orgId?: string,
query?: Partial<OrganizationAudienceQuery>,
options: { abortPrevious?: boolean; signal?: AbortSignal } = {}
) {
if (!orgId) return;
return this.execute<GetAudienceRequest>({
requestFn: () => classroomio.organization.audience.$get(),
const requestQuery = toAudienceRequestQuery({
...DEFAULT_ORG_AUDIENCE_QUERY,
...query
});
let requestSignal = options.signal;
let requestController: AbortController | null = null;
if (options.abortPrevious) {
this.cancelAudienceRequest();
requestController = new AbortController();
this.activeAudienceRequestController = requestController;
requestSignal = requestController.signal;
}
const response = await this.execute<GetAudienceRequest>({
requestFn: () =>
classroomio.organization.audience.$get(
{
query: requestQuery!
},
{
init: {
signal: requestSignal
}
}
),
logContext: 'fetching organization audience',
onSuccess: (response) => {
this.audience = response.data;
this.audiencePagination = response.pagination;
}
});
if (requestController && this.activeAudienceRequestController === requestController) {
this.activeAudienceRequestController = null;
}
return response;
}
/**
@@ -384,7 +431,6 @@ class OrgApi extends BaseApiWithErrors {
* Imports users into the organization as students
*/
async importAudienceMembers(data: TImportAudienceMembers) {
console.log('importAudienceMembers', data);
return this.execute<ImportAudienceRequest>({
requestFn: () =>
classroomio.organization.audience.import.$post({
@@ -396,7 +442,8 @@ class OrgApi extends BaseApiWithErrors {
snackbar.success(
t.get('audience.import.snackbar_success', {
imported: d.imported,
skipped: d.skipped,
assigned: d.assigned ?? 0,
pendingInvitesRenewed: d.pendingInvitesRenewed ?? 0,
emailsSent: d.emailsSent
})
);
@@ -460,8 +507,6 @@ class OrgApi extends BaseApiWithErrors {
} else {
snackbar.error('audience.invite.resend_snackbar_email_failed');
}
const orgId = get(currentOrg).id;
if (orgId) this.getOrgAudience(orgId);
},
onError: (result) => {
if (typeof result === 'string') {
@@ -480,8 +525,6 @@ class OrgApi extends BaseApiWithErrors {
logContext: 'revoking audience invite',
onSuccess: () => {
snackbar.success('audience.invite.revoke_snackbar_success');
const orgId = get(currentOrg).id;
if (orgId) this.getOrgAudience(orgId);
},
onError: (result) => {
if (typeof result === 'string') {
@@ -490,6 +533,27 @@ class OrgApi extends BaseApiWithErrors {
}
});
}
async deleteAudienceMember(memberId: number) {
return this.execute<DeleteAudienceMemberRequest>({
requestFn: () =>
classroomio.organization.audience[':memberId'].$delete({
param: { memberId: memberId.toString() }
}),
logContext: 'removing audience member',
onSuccess: () => {
snackbar.success('audience.delete.snackbar_success');
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error(result);
return;
}
snackbar.error('error' in result ? result.error : result.message);
}
});
}
}
export const orgApi = new OrgApi();
@@ -0,0 +1,58 @@
import type { OrganizationAudienceQuery, OrganizationAudienceRequestQuery } from './types';
export const DEFAULT_ORG_AUDIENCE_QUERY: OrganizationAudienceQuery = {
page: 1,
limit: 20,
sortBy: 'createdAt',
sortOrder: 'desc'
};
export function getAudienceQueryFromSearchParams(
searchParams: URLSearchParams,
defaults: OrganizationAudienceQuery = DEFAULT_ORG_AUDIENCE_QUERY
): OrganizationAudienceQuery {
const page = Number(searchParams.get('page'));
const limit = Number(searchParams.get('limit'));
const sortBy = searchParams.get('sortBy');
const sortOrder = searchParams.get('sortOrder');
const search = searchParams.get('search')?.trim() || undefined;
return {
page: Number.isFinite(page) && page > 0 ? Math.floor(page) : defaults.page,
limit: Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : defaults.limit,
sortBy: sortBy === 'name' || sortBy === 'email' || sortBy === 'createdAt' ? sortBy : defaults.sortBy,
sortOrder: sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : defaults.sortOrder,
search
};
}
export function getAudienceSearchParams(query: OrganizationAudienceQuery): URLSearchParams {
const searchParams = new URLSearchParams();
searchParams.set('page', String(query.page));
searchParams.set('limit', String(query.limit));
searchParams.set('sortBy', query.sortBy);
searchParams.set('sortOrder', query.sortOrder);
if (query.search) {
searchParams.set('search', query.search);
}
return searchParams;
}
export function toAudienceRequestQuery(
query?: Partial<OrganizationAudienceQuery>
): OrganizationAudienceRequestQuery | undefined {
if (!query) {
return undefined;
}
return {
page: query.page != null ? String(query.page) : undefined,
limit: query.limit != null ? String(query.limit) : undefined,
search: query.search,
sortBy: query.sortBy,
sortOrder: query.sortOrder
};
}
@@ -1,4 +1,4 @@
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
import { classroomio, type InferRequestType, type InferResponseType } from '$lib/utils/services/api';
// Team member types
export type GetTeamRequest = (typeof classroomio.organization)['team']['$get'];
@@ -20,9 +20,19 @@ export type OrganizationTeamMembers = Array<
export type GetAudienceRequest = (typeof classroomio.organization)['audience']['$get'];
export type OrganizationAudienceResponse = InferResponseType<GetAudienceRequest> | null;
export type OrganizationAudienceSuccess = Extract<InferResponseType<GetAudienceRequest>, { success: true }>;
export type OrganizationAudienceRequestQuery = NonNullable<InferRequestType<GetAudienceRequest>['query']>;
export type OrganizationAudienceSortBy = 'createdAt' | 'name' | 'email';
export type OrganizationAudienceSortOrder = 'asc' | 'desc';
export type OrganizationAudienceQuery = {
page: number;
limit: number;
search?: string;
sortBy: OrganizationAudienceSortBy;
sortOrder: OrganizationAudienceSortOrder;
};
export type OrganizationAudience = OrganizationAudienceSuccess['data'];
export type OrganizationAudiencePagination = OrganizationAudienceSuccess['pagination'];
export type OrganizationAudienceMember = OrganizationAudience[number];
// Org Public Courses types
@@ -43,6 +53,8 @@ export type InviteTeamData = InviteTeamSuccess['data'];
export type DeleteTeamRequest = (typeof classroomio.organization)['team'][':memberId']['$delete'];
export type DeleteTeamSuccess = Extract<InferResponseType<DeleteTeamRequest>, { success: true }>;
export type DeleteAudienceMemberRequest = (typeof classroomio.organization)['audience'][':memberId']['$delete'];
export type DeleteAudienceMemberSuccess = Extract<InferResponseType<DeleteAudienceMemberRequest>, { success: true }>;
// update organization types
export type UpdateOrganizationRequest = (typeof classroomio.organization)['$put'];