feat: certificates, landing pages, ai tutor, website update, compliance

This commit is contained in:
Rotimi Best
2026-05-18 03:47:22 +01:00
parent c31ad63955
commit ac3a2c14a7
644 changed files with 56050 additions and 5439 deletions
@@ -0,0 +1 @@
export * from './jobs.svelte';
@@ -0,0 +1,78 @@
import { BaseApi, classroomio } from '$lib/utils/services/api';
import type {
AssetMediaJobs,
CancelMediaJobRequest,
GetMediaJobRequest,
ListAssetMediaJobsRequest,
ListMediaJobsRequest,
MediaJobEnvelope,
MediaJobsBatch
} from '../utils/types';
class JobsApi extends BaseApi {
/** Latest envelope per polled job id. */
envelopes = $state<Record<string, MediaJobEnvelope>>({});
/** Per-asset latest runs (for lesson editor badges). */
envelopesByAsset = $state<Record<string, AssetMediaJobs>>({});
async getMediaJob(jobId: string): Promise<MediaJobEnvelope | null> {
let envelope: MediaJobEnvelope | null = null;
await this.execute<GetMediaJobRequest>({
requestFn: () => classroomio.jobs.media[':jobId'].$get({ param: { jobId } }),
logContext: 'fetching media job',
onSuccess: (response) => {
envelope = response.data;
this.envelopes = { ...this.envelopes, [response.data.job.id]: response.data };
}
});
return envelope;
}
async getMediaJobs(jobIds: string[]): Promise<MediaJobsBatch> {
if (jobIds.length === 0) return {};
let envelopes: MediaJobsBatch = {};
await this.execute<ListMediaJobsRequest>({
requestFn: () => classroomio.jobs.media.$get({ query: { ids: jobIds.join(',') } }),
logContext: 'fetching media jobs (batch)',
onSuccess: (response) => {
envelopes = response.data;
this.envelopes = { ...this.envelopes, ...response.data };
}
});
return envelopes;
}
async getMediaJobsForAsset(assetId: string): Promise<AssetMediaJobs> {
let envelopes: AssetMediaJobs = [];
await this.execute<ListAssetMediaJobsRequest>({
requestFn: () => classroomio.jobs.media.asset[':assetId'].$get({ param: { assetId } }),
logContext: 'fetching media jobs for asset',
onSuccess: (response) => {
envelopes = response.data;
this.envelopesByAsset = { ...this.envelopesByAsset, [assetId]: response.data };
}
});
return envelopes;
}
async cancelMediaJob(jobId: string): Promise<MediaJobEnvelope | null> {
let envelope: MediaJobEnvelope | null = null;
await this.execute<CancelMediaJobRequest>({
requestFn: () => classroomio.jobs.media[':jobId'].cancel.$post({ param: { jobId } }),
logContext: 'cancelling media job',
onSuccess: (response) => {
envelope = response.data;
this.envelopes = { ...this.envelopes, [response.data.job.id]: response.data };
}
});
return envelope;
}
}
export const jobsApi = new JobsApi();
@@ -0,0 +1,3 @@
export * from './api/jobs.svelte';
export * from './utils/poller';
export type { MediaJobEnvelope, AssetMediaJobs, MediaJobsBatch } from './utils/types';
@@ -0,0 +1,99 @@
/**
* Reusable poller for the standardized job envelope. Honors the
* `nextPollMs` hint the API returns, stops on terminal states, and exposes
* a manual `poke()` that triggers an immediate refresh on user interaction.
*
* Designed to live in `.svelte.ts` callers so reactive consumers can pass an
* `onUpdate` handler that mutates `$state` cleanly.
*/
export interface JobLikeStatus {
status: 'queued' | 'running' | 'completed' | 'failed' | 'canceled';
}
export interface JobLikeEnvelope<TJob extends JobLikeStatus> {
job: TJob;
nextPollMs: number;
}
export interface JobPollerOptions<TEnvelope extends JobLikeEnvelope<JobLikeStatus>> {
/** Fetcher called on each poll. Should resolve to the envelope or null when missing. */
fetch: () => Promise<TEnvelope | null>;
/** Called whenever a fresh envelope arrives (including the first response). */
onUpdate?: (envelope: TEnvelope) => void;
/** Called when the poll throws. Defaults to console.error. */
onError?: (error: unknown) => void;
/** Override the floor between polls. Defaults to 500ms. */
minIntervalMs?: number;
/** Override the ceiling between polls. Defaults to 30s. */
maxIntervalMs?: number;
}
export class JobPoller<TEnvelope extends JobLikeEnvelope<JobLikeStatus>> {
private timer: ReturnType<typeof setTimeout> | null = null;
private inFlight = false;
private stopped = false;
private readonly minIntervalMs: number;
private readonly maxIntervalMs: number;
constructor(private readonly options: JobPollerOptions<TEnvelope>) {
this.minIntervalMs = options.minIntervalMs ?? 500;
this.maxIntervalMs = options.maxIntervalMs ?? 30_000;
}
start(): void {
if (this.stopped) return;
void this.tick();
}
/** Trigger an immediate poll on user interaction (e.g. tab focus). */
poke(): void {
if (this.stopped) return;
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
void this.tick();
}
stop(): void {
this.stopped = true;
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
private clampInterval(suggestedMs: number): number {
return Math.min(Math.max(suggestedMs, this.minIntervalMs), this.maxIntervalMs);
}
private async tick(): Promise<void> {
if (this.stopped || this.inFlight) return;
this.inFlight = true;
let envelope: TEnvelope | null = null;
try {
envelope = await this.options.fetch();
if (envelope) this.options.onUpdate?.(envelope);
} catch (error) {
const handler = this.options.onError ?? ((err) => console.error('JobPoller error:', err));
handler(error);
} finally {
this.inFlight = false;
}
if (this.stopped) return;
if (
envelope &&
(envelope.job.status === 'completed' || envelope.job.status === 'failed' || envelope.job.status === 'canceled')
) {
return;
}
const delay = this.clampInterval(envelope?.nextPollMs ?? 2_000);
this.timer = setTimeout(() => void this.tick(), delay);
}
}
@@ -0,0 +1,23 @@
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
export type GetMediaJobRequest = (typeof classroomio.jobs.media)[':jobId']['$get'];
export type GetMediaJobSuccess = Extract<InferResponseType<GetMediaJobRequest>, { success: true }>;
export type MediaJobEnvelope = GetMediaJobSuccess['data'];
export type ListMediaJobsRequest = (typeof classroomio.jobs.media)['$get'];
export type ListMediaJobsSuccess = Extract<InferResponseType<ListMediaJobsRequest>, { success: true }>;
export type MediaJobsBatch = ListMediaJobsSuccess['data'];
export type ListAssetMediaJobsRequest = (typeof classroomio.jobs.media)['asset'][':assetId']['$get'];
export type ListAssetMediaJobsSuccess = Extract<InferResponseType<ListAssetMediaJobsRequest>, { success: true }>;
export type AssetMediaJobs = ListAssetMediaJobsSuccess['data'];
export type CancelMediaJobRequest = (typeof classroomio.jobs.media)[':jobId']['cancel']['$post'];
export type CancelMediaJobSuccess = Extract<InferResponseType<CancelMediaJobRequest>, { success: true }>;
export type StartAssetTranscriptionRequest =
(typeof classroomio.jobs.media)['asset'][':assetId']['transcribe']['$post'];
export type StartAssetTranscriptionSuccess = Extract<
InferResponseType<StartAssetTranscriptionRequest>,
{ success: true }
>;