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:
@@ -19,14 +19,15 @@ import { syncUserWithProfile } from './auth/hooks/sync-user';
|
||||
import { tokenExchange } from './auth/plugins/token-exchange';
|
||||
import { trackLoginHook } from './auth/hooks/track-login';
|
||||
|
||||
export { mintLoginLinkToken } from './auth/login-link';
|
||||
|
||||
/**
|
||||
* Cloud (multi-tenant) only. Routes OAuth/SSO callbacks to the canonical
|
||||
* production URL while completing the flow on whichever tenant host the
|
||||
* user signed in from (<org>.myclassroomio.com or a BYOD domain).
|
||||
*
|
||||
* Self-hosted instances run with one apex (api.<domain> + app.<domain>)
|
||||
* and use AUTH_COOKIE_DOMAIN for cross-subdomain cookies, so the proxy
|
||||
* isn't needed there.
|
||||
* Self-hosted instances proxy browser auth through the dashboard origin, so
|
||||
* the OAuth proxy plugin is only needed for cloud tenant/BYOD domains.
|
||||
*/
|
||||
function buildOAuthProxyPlugin() {
|
||||
if (process.env.PUBLIC_IS_SELFHOSTED === 'true') {
|
||||
@@ -74,12 +75,9 @@ export const auth: ReturnType<typeof betterAuth> = betterAuth({
|
||||
},
|
||||
advanced: {
|
||||
cookiePrefix: 'classroomio',
|
||||
// Cloud (multi-tenant): host-only cookies on each tenant/BYOD domain.
|
||||
// Self-hosted: cross-subdomain cookies under AUTH_COOKIE_DOMAIN so the
|
||||
// session set on `api.<apex>` is also sent to `app.<apex>`.
|
||||
crossSubDomainCookies: process.env.AUTH_COOKIE_DOMAIN?.trim()
|
||||
? { enabled: true, domain: process.env.AUTH_COOKIE_DOMAIN.trim() }
|
||||
: { enabled: false },
|
||||
// Browser auth is first-party through tenant-router or the dashboard proxy,
|
||||
// so cookies should stay host-only on the dashboard/public-site origin.
|
||||
crossSubDomainCookies: { enabled: false },
|
||||
database: {
|
||||
generateId: false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { SignJWT } from 'jose';
|
||||
|
||||
/**
|
||||
* Default lifetime for a minted login-link token. Login links are single-purpose
|
||||
* cross-host sign-in handoffs, so they are intentionally short-lived.
|
||||
*/
|
||||
const DEFAULT_TTL_MINUTES = 10;
|
||||
|
||||
function getLoginLinkSecret(): Uint8Array {
|
||||
const secret = process.env.BETTER_AUTH_SECRET?.trim();
|
||||
|
||||
if (!secret) {
|
||||
throw new Error('BETTER_AUTH_SECRET is required for login links');
|
||||
}
|
||||
|
||||
return new TextEncoder().encode(secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a short-lived HS256 JWT consumed by the `login-link` auth plugin
|
||||
* (`GET /api/auth/login-link?token=…`). The payload matches `ZLoginLinkPayload`
|
||||
* (`sub` + `email` + `type`); the consumer re-verifies the user and email before
|
||||
* creating a session, so callers must only ever mint for a user they have
|
||||
* already authenticated (self sign-in), never an arbitrary target.
|
||||
*/
|
||||
export async function mintLoginLinkToken({
|
||||
userId,
|
||||
email,
|
||||
ttlMinutes = DEFAULT_TTL_MINUTES
|
||||
}: {
|
||||
userId: string;
|
||||
email: string;
|
||||
ttlMinutes?: number;
|
||||
}): Promise<string> {
|
||||
return new SignJWT({
|
||||
email: email.toLowerCase().trim(),
|
||||
type: 'login-link'
|
||||
})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setSubject(userId)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(`${ttlMinutes}m`)
|
||||
.sign(getLoginLinkSecret());
|
||||
}
|
||||
@@ -1,4 +1,11 @@
|
||||
const { TRUSTED_ORIGINS: TRUSTED_ORIGINS_STRING, PUBLIC_SERVER_URL } = process.env;
|
||||
const {
|
||||
DASHBOARD_ORIGIN,
|
||||
PUBLIC_IS_SELFHOSTED,
|
||||
PUBLIC_SERVER_URL,
|
||||
TRUSTED_ORIGINS: TRUSTED_ORIGINS_STRING
|
||||
} = process.env;
|
||||
|
||||
const dashboardOrigin = DASHBOARD_ORIGIN?.trim().replace(/\/$/, '');
|
||||
|
||||
const DEFAULT_TRUSTED_ORIGINS = [
|
||||
'http://localhost:5173',
|
||||
@@ -16,13 +23,15 @@ const DEFAULT_TRUSTED_ORIGINS = [
|
||||
'https://login.okta.com'
|
||||
];
|
||||
|
||||
export const TRUSTED_ORIGINS = TRUSTED_ORIGINS_STRING
|
||||
? [
|
||||
...DEFAULT_TRUSTED_ORIGINS,
|
||||
...TRUSTED_ORIGINS_STRING.split(',')
|
||||
.map((s) => s.trim())
|
||||
export const TRUSTED_ORIGINS = [
|
||||
...DEFAULT_TRUSTED_ORIGINS,
|
||||
...(TRUSTED_ORIGINS_STRING
|
||||
? TRUSTED_ORIGINS_STRING.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean)
|
||||
]
|
||||
: DEFAULT_TRUSTED_ORIGINS;
|
||||
: []),
|
||||
...(dashboardOrigin ? [dashboardOrigin] : [])
|
||||
];
|
||||
|
||||
export const BASE_URL = PUBLIC_SERVER_URL || 'http://localhost:3002';
|
||||
export const BASE_URL =
|
||||
PUBLIC_IS_SELFHOSTED === 'true' && dashboardOrigin ? dashboardOrigin : PUBLIC_SERVER_URL || 'http://localhost:3002';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -68,6 +68,7 @@ export const organizationInviteEventType = pgEnum('ORGANIZATION_INVITE_EVENT_TYP
|
||||
'EMAIL_FAILED',
|
||||
'ABUSE_BLOCKED'
|
||||
]);
|
||||
export const organizationInviteType = pgEnum('ORGANIZATION_INVITE_TYPE', ['EMAIL', 'LINK']);
|
||||
|
||||
export const user = pgTable('user', {
|
||||
id: uuid()
|
||||
@@ -677,6 +678,8 @@ export const course = pgTable(
|
||||
created_at: number;
|
||||
description: string;
|
||||
}[];
|
||||
skills?: string[];
|
||||
tools?: string[];
|
||||
lessonTabsOrder?: {
|
||||
id: 1 | 2 | 3 | 4;
|
||||
name: string;
|
||||
@@ -1034,6 +1037,8 @@ export const asset = pgTable(
|
||||
provider: varchar().default('upload').notNull(),
|
||||
storageProvider: varchar('storage_provider').default('s3').notNull(),
|
||||
storageKey: text('storage_key'),
|
||||
hlsManifestKey: text('hls_manifest_key'),
|
||||
hlsAudioKey: text('hls_audio_key'),
|
||||
sourceUrl: text('source_url'),
|
||||
mimeType: text('mime_type'),
|
||||
byteSize: bigint('byte_size', { mode: 'number' }),
|
||||
@@ -1386,7 +1391,8 @@ export const organizationInvite = pgTable(
|
||||
organizationId: uuid('organization_id').notNull(),
|
||||
// Role assigned when invite is accepted (e.g ADMIN, TUTOR)
|
||||
roleId: bigint('role_id', { mode: 'number' }).notNull(),
|
||||
email: text().notNull(),
|
||||
type: organizationInviteType('type').default('EMAIL').notNull(),
|
||||
email: text(),
|
||||
tokenHash: text('token_hash').notNull(),
|
||||
createdByProfileId: uuid('created_by_profile_id').notNull(),
|
||||
acceptedByProfileId: uuid('accepted_by_profile_id'),
|
||||
|
||||
@@ -3,8 +3,8 @@ import 'dotenv/config';
|
||||
import * as schema from '../schema';
|
||||
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { SignJWT } from 'jose';
|
||||
import { db } from '../drizzle';
|
||||
import { mintLoginLinkToken } from '../auth/login-link';
|
||||
|
||||
const LOGIN_LINK_TTL_MINUTES = 10;
|
||||
|
||||
@@ -12,16 +12,6 @@ function getBaseUrl(): string {
|
||||
return process.env.PUBLIC_SERVER_URL?.trim() || 'http://localhost:3002';
|
||||
}
|
||||
|
||||
function getSecret(): Uint8Array {
|
||||
const secret = process.env.BETTER_AUTH_SECRET?.trim();
|
||||
|
||||
if (!secret) {
|
||||
throw new Error('BETTER_AUTH_SECRET is required');
|
||||
}
|
||||
|
||||
return new TextEncoder().encode(secret);
|
||||
}
|
||||
|
||||
function getArgs() {
|
||||
const email = process.argv[2]?.trim().toLowerCase();
|
||||
const redirect = process.argv[3]?.trim() || '/';
|
||||
@@ -42,15 +32,11 @@ async function main() {
|
||||
throw new Error(`User not found for email: ${email}`);
|
||||
}
|
||||
|
||||
const token = await new SignJWT({
|
||||
const token = await mintLoginLinkToken({
|
||||
userId: user.id,
|
||||
email,
|
||||
type: 'login-link'
|
||||
})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setSubject(user.id)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(`${LOGIN_LINK_TTL_MINUTES}m`)
|
||||
.sign(getSecret());
|
||||
ttlMinutes: LOGIN_LINK_TTL_MINUTES
|
||||
});
|
||||
|
||||
const url = new URL('/api/auth/login-link', getBaseUrl());
|
||||
url.searchParams.set('token', token);
|
||||
|
||||
Reference in New Issue
Block a user