feat: HLS streaming, transcripts, agent course generation & landing-page course previews (#663)
New features: - HLS video encoding/streaming (hls routes, hls-encoder) and a media transcripts pipeline (transcripts route + media-transcript queries) - AI assistant chat vs. run model: agent-course-generation worker/queue, run-monitor pane, chat-changes card, content ask-ai bar - Per-theme course-page previews across org landing-page themes (bold, classic, corporate, editorial, etc.) with a shared course token/component set - View-as-student mode and login-link / link-invite invite flows Refactor: - Extract packages/core consolidating config (env, storage, dashboard-url), services and utils previously duplicated under apps/api/src/config - Remove legacy agent service modules (chat-tools, chat-context, context-window, document, fetch-url, lesson-content) - Relax Cross-Origin-Resource-Policy to allow cross-origin media embedding - Add format script to tenant-router so turbo run format covers it
This commit is contained in:
@@ -253,6 +253,21 @@ export async function upsertAgentRunStep(input: UpsertAgentRunStepInput): Promis
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAgentRunStep(runId: string, stepKey: string): Promise<TAiAgentRunStep | null> {
|
||||
try {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(schema.aiAgentRunStep)
|
||||
.where(and(eq(schema.aiAgentRunStep.runId, runId), eq(schema.aiAgentRunStep.stepKey, stepKey)))
|
||||
.limit(1);
|
||||
|
||||
return row ?? null;
|
||||
} catch (error) {
|
||||
console.error('getAgentRunStep error:', error);
|
||||
throw new Error('Failed to get agent run step');
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAgentRunSteps(runId: string): Promise<TAiAgentRunStep[]> {
|
||||
try {
|
||||
return await db
|
||||
|
||||
@@ -217,6 +217,131 @@ export async function updateAsset(
|
||||
}
|
||||
}
|
||||
|
||||
export interface InitHlsAssetInput {
|
||||
organizationId: string;
|
||||
createdByProfileId: string;
|
||||
title: string | null;
|
||||
byteSize: number | null;
|
||||
mimeType: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert an asset row in `processing` status for an HLS upload. The
|
||||
* storage layout (storageKey / hlsManifestKey / hlsAudioKey) is filled in
|
||||
* by finalizeHlsAsset once the browser encoder has written everything to
|
||||
* R2 — we just need an id to namespace the segments under.
|
||||
*/
|
||||
export async function createHlsAssetPlaceholder(
|
||||
input: InitHlsAssetInput,
|
||||
dbClient: DbOrTxClient = db
|
||||
): Promise<TAsset> {
|
||||
try {
|
||||
const [created] = await dbClient
|
||||
.insert(schema.asset)
|
||||
.values({
|
||||
organizationId: input.organizationId,
|
||||
kind: 'video',
|
||||
provider: 'upload',
|
||||
storageProvider: 's3',
|
||||
title: input.title,
|
||||
byteSize: input.byteSize,
|
||||
mimeType: input.mimeType,
|
||||
isExternal: false,
|
||||
status: 'processing',
|
||||
createdByProfileId: input.createdByProfileId
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!created) {
|
||||
throw new Error('Failed to create HLS asset placeholder');
|
||||
}
|
||||
|
||||
return created;
|
||||
} catch (error) {
|
||||
console.error('createHlsAssetPlaceholder error:', error);
|
||||
throw new Error(
|
||||
`Failed to create HLS asset placeholder: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface FinalizeHlsAssetInput {
|
||||
manifestKey: string;
|
||||
audioKey: string | null;
|
||||
durationSeconds: number | null;
|
||||
aspectRatio: string | null;
|
||||
byteSize: number;
|
||||
thumbnailUrl: string | null;
|
||||
thumbnailCandidates: string[];
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function finalizeHlsAsset(
|
||||
assetId: string,
|
||||
orgId: string,
|
||||
input: FinalizeHlsAssetInput,
|
||||
dbClient: DbOrTxClient = db
|
||||
): Promise<TAsset | null> {
|
||||
try {
|
||||
const [updated] = await dbClient
|
||||
.update(schema.asset)
|
||||
.set({
|
||||
hlsManifestKey: input.manifestKey,
|
||||
hlsAudioKey: input.audioKey,
|
||||
// storageKey stays null for HLS assets so lesson-media.ts (which
|
||||
// signs storageKey as the canonical video URL) doesn't overwrite
|
||||
// the HLS manifest URL. The transcription enqueue path reads
|
||||
// hlsAudioKey for HLS assets — see startTranscriptionOnlyMediaJob.
|
||||
durationSeconds: input.durationSeconds,
|
||||
aspectRatio: input.aspectRatio,
|
||||
byteSize: input.byteSize,
|
||||
thumbnailUrl: input.thumbnailUrl,
|
||||
thumbnailCandidates: input.thumbnailCandidates,
|
||||
metadata: input.metadata,
|
||||
status: 'active',
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
.where(and(eq(schema.asset.id, assetId), eq(schema.asset.organizationId, orgId)))
|
||||
.returning();
|
||||
|
||||
return updated || null;
|
||||
} catch (error) {
|
||||
console.error('finalizeHlsAsset error:', error);
|
||||
throw new Error(`Failed to finalize HLS asset: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export interface FinalizeHls1080Input {
|
||||
byteSize: number;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function finalizeHls1080Rendition(
|
||||
assetId: string,
|
||||
orgId: string,
|
||||
input: FinalizeHls1080Input,
|
||||
dbClient: DbOrTxClient = db
|
||||
): Promise<TAsset | null> {
|
||||
try {
|
||||
const [updated] = await dbClient
|
||||
.update(schema.asset)
|
||||
.set({
|
||||
byteSize: input.byteSize,
|
||||
metadata: input.metadata,
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
.where(and(eq(schema.asset.id, assetId), eq(schema.asset.organizationId, orgId)))
|
||||
.returning();
|
||||
|
||||
return updated || null;
|
||||
} catch (error) {
|
||||
console.error('finalizeHls1080Rendition error:', error);
|
||||
throw new Error(
|
||||
`Failed to finalize HLS 1080p rendition: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function archiveAsset(
|
||||
assetId: string,
|
||||
orgId: string,
|
||||
|
||||
@@ -56,7 +56,8 @@ export interface TStudentCourse extends TBaseCourse {
|
||||
export const getPublishedCoursesBySiteName = async (
|
||||
siteName: string,
|
||||
courseIds?: string[],
|
||||
limit?: number
|
||||
limit?: number,
|
||||
offset?: number
|
||||
): Promise<TBaseCourse[]> => {
|
||||
try {
|
||||
if (courseIds && courseIds.length === 0) {
|
||||
@@ -95,6 +96,7 @@ export const getPublishedCoursesBySiteName = async (
|
||||
.groupBy(schema.course.id)
|
||||
.orderBy(desc(schema.course.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset ?? 0)
|
||||
: await db
|
||||
.select({
|
||||
course: schema.course,
|
||||
@@ -300,6 +302,8 @@ export type CourseWithRelations = TCourse & {
|
||||
id: string;
|
||||
name: string;
|
||||
siteName: string | null;
|
||||
customDomain: string | null;
|
||||
isCustomDomainVerified: boolean | null;
|
||||
theme: string | null;
|
||||
} | null;
|
||||
};
|
||||
@@ -342,6 +346,8 @@ export async function getCourseWithRelations(
|
||||
id: string;
|
||||
name: string;
|
||||
siteName: string | null;
|
||||
customDomain: string | null;
|
||||
isCustomDomainVerified: boolean | null;
|
||||
theme: string | null;
|
||||
} | null;
|
||||
};
|
||||
@@ -362,6 +368,8 @@ export async function getCourseWithRelations(
|
||||
id: schema.organization.id,
|
||||
name: schema.organization.name,
|
||||
siteName: schema.organization.siteName,
|
||||
customDomain: schema.organization.customDomain,
|
||||
isCustomDomainVerified: schema.organization.isCustomDomainVerified,
|
||||
theme: schema.organization.theme
|
||||
}
|
||||
})
|
||||
@@ -402,6 +410,8 @@ export async function getCourseWithRelations(
|
||||
id: groupData[0].organization.id,
|
||||
name: groupData[0].organization.name,
|
||||
siteName: groupData[0].organization.siteName ?? null,
|
||||
customDomain: groupData[0].organization.customDomain ?? null,
|
||||
isCustomDomainVerified: groupData[0].organization.isCustomDomainVerified ?? null,
|
||||
theme: groupData[0].organization.theme ?? null
|
||||
}
|
||||
: null;
|
||||
@@ -616,6 +626,8 @@ export type TCourseCertificationRow = {
|
||||
} | null;
|
||||
title: string;
|
||||
orgSiteName: string | null;
|
||||
orgCustomDomain: string | null;
|
||||
orgIsCustomDomainVerified: boolean | null;
|
||||
orgName: string;
|
||||
};
|
||||
|
||||
@@ -628,6 +640,8 @@ export async function getCourseCertificationRow(courseId: string): Promise<TCour
|
||||
certificate: schema.course.certificate,
|
||||
title: schema.course.title,
|
||||
orgSiteName: schema.organization.siteName,
|
||||
orgCustomDomain: schema.organization.customDomain,
|
||||
orgIsCustomDomainVerified: schema.organization.isCustomDomainVerified,
|
||||
orgName: schema.organization.name
|
||||
})
|
||||
.from(schema.course)
|
||||
@@ -1065,6 +1079,8 @@ export async function getCourseWithOrgData(courseId: string): Promise<{
|
||||
courseTitle: string | null;
|
||||
orgName: string | null;
|
||||
orgSiteName: string | null;
|
||||
orgCustomDomain: string | null;
|
||||
orgIsCustomDomainVerified: boolean | null;
|
||||
groupId: string | null;
|
||||
} | null> {
|
||||
try {
|
||||
@@ -1073,6 +1089,8 @@ export async function getCourseWithOrgData(courseId: string): Promise<{
|
||||
courseTitle: schema.course.title,
|
||||
orgName: schema.organization.name,
|
||||
orgSiteName: schema.organization.siteName,
|
||||
orgCustomDomain: schema.organization.customDomain,
|
||||
orgIsCustomDomainVerified: schema.organization.isCustomDomainVerified,
|
||||
groupId: schema.course.groupId
|
||||
})
|
||||
.from(schema.course)
|
||||
|
||||
@@ -348,6 +348,8 @@ export type TCourseInviteAcceptBundle = {
|
||||
id: string;
|
||||
name: string;
|
||||
siteName: string;
|
||||
customDomain: string | null;
|
||||
isCustomDomainVerified: boolean | null;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -370,7 +372,9 @@ export async function selectCourseInviteAcceptBundleByTokenHash(
|
||||
organization: {
|
||||
id: schema.organization.id,
|
||||
name: schema.organization.name,
|
||||
siteName: schema.organization.siteName
|
||||
siteName: schema.organization.siteName,
|
||||
customDomain: schema.organization.customDomain,
|
||||
isCustomDomainVerified: schema.organization.isCustomDomainVerified
|
||||
}
|
||||
})
|
||||
.from(schema.courseInvite)
|
||||
@@ -392,7 +396,9 @@ export async function selectCourseInviteAcceptBundleByTokenHash(
|
||||
},
|
||||
organization: {
|
||||
...row.organization,
|
||||
siteName: row.organization.siteName ?? ''
|
||||
siteName: row.organization.siteName ?? '',
|
||||
customDomain: row.organization.customDomain ?? null,
|
||||
isCustomDomainVerified: row.organization.isCustomDomainVerified ?? null
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -295,6 +295,12 @@ export interface PublicLessonContent {
|
||||
/** S3 object key for uploads; used server-side for presigning (stripped before API response). */
|
||||
key?: string;
|
||||
assetId?: string;
|
||||
/**
|
||||
* When true, `link` points at an HLS master playlist served by the
|
||||
* tenant-router Worker (prod) or API streaming route (local). The
|
||||
* shared media player attaches hls.js based on this flag.
|
||||
*/
|
||||
hls?: boolean;
|
||||
transcript?: {
|
||||
vttUrl: string;
|
||||
vttUrlExpiresAt: string;
|
||||
|
||||
@@ -54,6 +54,38 @@ export async function upsertMediaTranscript(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update only the editable content (segments + derived text) of an asset's
|
||||
* transcript. Scoped by asset + organization. Returns the updated row or
|
||||
* `null` when no transcript exists for the asset.
|
||||
*/
|
||||
export async function updateMediaTranscriptContent(
|
||||
assetId: string,
|
||||
organizationId: string,
|
||||
patch: { segments: TMediaTranscript['segments']; text: string },
|
||||
dbClient: DbOrTxClient = db
|
||||
): Promise<TMediaTranscript | null> {
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
const [row] = await dbClient
|
||||
.update(schema.mediaTranscript)
|
||||
.set({
|
||||
segments: patch.segments,
|
||||
text: patch.text,
|
||||
updatedAt: now
|
||||
})
|
||||
.where(
|
||||
and(eq(schema.mediaTranscript.assetId, assetId), eq(schema.mediaTranscript.organizationId, organizationId))
|
||||
)
|
||||
.returning();
|
||||
|
||||
return row ?? null;
|
||||
} catch (error) {
|
||||
console.error('updateMediaTranscriptContent error:', error);
|
||||
throw new Error('Failed to update media transcript content');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMediaTranscriptByAsset(
|
||||
assetId: string,
|
||||
organizationId: string,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './organization';
|
||||
export * from './quiz';
|
||||
export * from './invite';
|
||||
export * from './link-invite';
|
||||
export * from './automation-key';
|
||||
export * from './automation-usage';
|
||||
|
||||
@@ -257,10 +257,12 @@ export async function getLatestOrgInvitesByEmails(
|
||||
|
||||
const latestByEmail = new Map<string, TLatestOrgInviteByEmail>();
|
||||
for (const row of rows) {
|
||||
if (!row.email) continue;
|
||||
|
||||
const key = row.email.toLowerCase();
|
||||
const existing = latestByEmail.get(key);
|
||||
if (!existing || new Date(row.createdAt) > new Date(existing.createdAt)) {
|
||||
latestByEmail.set(key, row);
|
||||
latestByEmail.set(key, { ...row, email: row.email });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import { and, eq, isNull } from 'drizzle-orm';
|
||||
import { db, type DbOrTxClient } from '@db/drizzle';
|
||||
import type { TOrganizationInvite } from '@db/types';
|
||||
|
||||
export type TLinkInviteRow = TOrganizationInvite & {
|
||||
organization: { id: string; name: string; siteName: string };
|
||||
};
|
||||
|
||||
export async function getOrgLinkInvite(organizationId: string): Promise<TOrganizationInvite | null> {
|
||||
try {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(schema.organizationInvite)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.organizationInvite.organizationId, organizationId),
|
||||
eq(schema.organizationInvite.type, 'LINK'),
|
||||
eq(schema.organizationInvite.isRevoked, false)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return row ?? null;
|
||||
} catch (error) {
|
||||
console.error('getOrgLinkInvite error:', error);
|
||||
throw new Error(`Failed to get org link invite: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createLinkInvite(data: {
|
||||
organizationId: string;
|
||||
roleId: number;
|
||||
tokenHash: string;
|
||||
createdByProfileId: string;
|
||||
expiresAt: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}): Promise<TOrganizationInvite> {
|
||||
try {
|
||||
const [created] = await db
|
||||
.insert(schema.organizationInvite)
|
||||
.values({
|
||||
...data,
|
||||
type: 'LINK',
|
||||
email: null,
|
||||
isRevoked: false
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!created) {
|
||||
throw new Error('Failed to create link invite');
|
||||
}
|
||||
|
||||
return created;
|
||||
} catch (error) {
|
||||
console.error('createLinkInvite error:', error);
|
||||
throw new Error(`Failed to create link invite: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function setLinkInviteRevoked(
|
||||
organizationId: string,
|
||||
isRevoked: boolean,
|
||||
profileId: string
|
||||
): Promise<TOrganizationInvite | null> {
|
||||
try {
|
||||
const [updated] = await db
|
||||
.update(schema.organizationInvite)
|
||||
.set({
|
||||
isRevoked,
|
||||
revokedByProfileId: isRevoked ? profileId : null,
|
||||
revokedAt: isRevoked ? new Date().toISOString() : null,
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
.where(
|
||||
and(eq(schema.organizationInvite.organizationId, organizationId), eq(schema.organizationInvite.type, 'LINK'))
|
||||
)
|
||||
.returning();
|
||||
|
||||
return updated ?? null;
|
||||
} catch (error) {
|
||||
console.error('setLinkInviteRevoked error:', error);
|
||||
throw new Error(`Failed to update link invite: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOrgLinkInviteWithOrg(
|
||||
dbClient: DbOrTxClient,
|
||||
tokenHash: string
|
||||
): Promise<{ invite: TOrganizationInvite; organization: { id: string; name: string; siteName: string } } | null> {
|
||||
try {
|
||||
const [row] = await dbClient
|
||||
.select({
|
||||
invite: schema.organizationInvite,
|
||||
organization: {
|
||||
id: schema.organization.id,
|
||||
name: schema.organization.name,
|
||||
siteName: schema.organization.siteName
|
||||
}
|
||||
})
|
||||
.from(schema.organizationInvite)
|
||||
.innerJoin(schema.organization, eq(schema.organizationInvite.organizationId, schema.organization.id))
|
||||
.where(and(eq(schema.organizationInvite.tokenHash, tokenHash), eq(schema.organizationInvite.type, 'LINK')))
|
||||
.limit(1);
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
...row,
|
||||
organization: { ...row.organization, siteName: row.organization.siteName ?? '' }
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getOrgLinkInviteWithOrg error:', error);
|
||||
throw new Error(`Failed to load link invite: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
@@ -913,9 +913,7 @@ export const getOrganizationPlanBySubscriptionId = async (
|
||||
return plan ?? null;
|
||||
} catch (error) {
|
||||
console.error('getOrganizationPlanBySubscriptionId error:', error);
|
||||
throw new Error(
|
||||
`Failed to fetch organization plan: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
throw new Error(`Failed to fetch organization plan: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -104,6 +104,8 @@ export async function listAssignmentsForReminderScan(): Promise<
|
||||
programName: string;
|
||||
organizationName: string;
|
||||
organizationSiteName: string | null;
|
||||
organizationCustomDomain: string | null;
|
||||
organizationIsCustomDomainVerified: boolean | null;
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
@@ -116,7 +118,9 @@ export async function listAssignmentsForReminderScan(): Promise<
|
||||
programId: schema.program.id,
|
||||
programName: schema.program.name,
|
||||
organizationName: schema.organization.name,
|
||||
organizationSiteName: schema.organization.siteName
|
||||
organizationSiteName: schema.organization.siteName,
|
||||
organizationCustomDomain: schema.organization.customDomain,
|
||||
organizationIsCustomDomainVerified: schema.organization.isCustomDomainVerified
|
||||
})
|
||||
.from(schema.programGoalAssignment)
|
||||
.innerJoin(schema.programMember, eq(schema.programGoalAssignment.programMemberId, schema.programMember.id))
|
||||
@@ -140,7 +144,9 @@ export async function listAssignmentsForReminderScan(): Promise<
|
||||
programId: row.programId,
|
||||
programName: row.programName,
|
||||
organizationName: row.organizationName,
|
||||
organizationSiteName: row.organizationSiteName
|
||||
organizationSiteName: row.organizationSiteName,
|
||||
organizationCustomDomain: row.organizationCustomDomain,
|
||||
organizationIsCustomDomainVerified: row.organizationIsCustomDomainVerified
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('listAssignmentsForReminderScan error:', error);
|
||||
|
||||
Reference in New Issue
Block a user