feat: implement automation key management with middleware and routes for organization API keys, invite improvements
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import {
|
||||
createCourseImportDraft,
|
||||
getCourseImportDraftById,
|
||||
getCourseImportDraftByIdempotencyKey,
|
||||
updateCourseImportDraft
|
||||
} from '@cio/db/queries/course-import';
|
||||
import type { TCourseImportDraft, TLocale } from '@db/types';
|
||||
import type {
|
||||
TCourseImportDraftCreate,
|
||||
TCourseImportDraftPayload,
|
||||
TCourseImportDraftPublish,
|
||||
TCourseImportDraftUpdate
|
||||
} from '@cio/utils/validation/course-import';
|
||||
import { ZCourseImportDraftPayload } from '@cio/utils/validation/course-import';
|
||||
import { createCourse, updateCourse } from '@api/services/course/course';
|
||||
import { createCourseSection } from '@api/services/course/section';
|
||||
import { createLesson } from '@api/services/lesson';
|
||||
import { upsertLessonLanguageService } from '@api/services/lesson-language';
|
||||
|
||||
type DraftSummary = {
|
||||
sectionCount: number;
|
||||
lessonCount: number;
|
||||
localeCount: number;
|
||||
warningCount: number;
|
||||
};
|
||||
|
||||
function buildDraftSummary(draft: TCourseImportDraftPayload): DraftSummary {
|
||||
return {
|
||||
sectionCount: draft.sections.length,
|
||||
lessonCount: draft.lessons.length,
|
||||
localeCount: new Set(draft.lessonLanguages.map((item) => item.locale)).size,
|
||||
warningCount: draft.warnings.length
|
||||
};
|
||||
}
|
||||
|
||||
function parseStoredDraft(record: TCourseImportDraft): TCourseImportDraftPayload {
|
||||
return ZCourseImportDraftPayload.parse(record.draft);
|
||||
}
|
||||
|
||||
export async function createCourseImportDraftService(orgId: string, profileId: string, data: TCourseImportDraftCreate) {
|
||||
try {
|
||||
if (data.idempotencyKey) {
|
||||
const existing = await getCourseImportDraftByIdempotencyKey(orgId, data.idempotencyKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
return await createCourseImportDraft({
|
||||
organizationId: orgId,
|
||||
createdByProfileId: profileId,
|
||||
sourceType: data.sourceType,
|
||||
status: 'DRAFT',
|
||||
title: data.draft.course.title,
|
||||
locale: data.draft.course.locale,
|
||||
idempotencyKey: data.idempotencyKey,
|
||||
summary: {
|
||||
...buildDraftSummary(data.draft),
|
||||
...(data.summary ?? {})
|
||||
},
|
||||
draft: data.draft,
|
||||
warnings: data.draft.warnings,
|
||||
sourceArtifacts: data.sourceArtifacts ?? data.draft.sourceReferences ?? []
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to create course import draft',
|
||||
ErrorCodes.COURSE_IMPORT_CREATE_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCourseImportDraftService(orgId: string, draftId: string) {
|
||||
try {
|
||||
const draft = await getCourseImportDraftById(orgId, draftId);
|
||||
if (!draft) {
|
||||
throw new AppError('Course import draft not found', ErrorCodes.COURSE_IMPORT_DRAFT_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return draft;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to fetch course import draft',
|
||||
ErrorCodes.COURSE_IMPORT_FETCH_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCourseImportDraftService(orgId: string, draftId: string, data: TCourseImportDraftUpdate) {
|
||||
try {
|
||||
const existing = await getCourseImportDraftService(orgId, draftId);
|
||||
|
||||
if (existing.status === 'PUBLISHED') {
|
||||
throw new AppError('Published drafts cannot be edited', ErrorCodes.FORBIDDEN, 403);
|
||||
}
|
||||
|
||||
const nextDraft = data.draft ?? parseStoredDraft(existing);
|
||||
const nextWarnings = data.warnings ?? nextDraft.warnings;
|
||||
const updated = await updateCourseImportDraft(orgId, draftId, {
|
||||
title: nextDraft.course.title,
|
||||
locale: nextDraft.course.locale,
|
||||
draft: nextDraft,
|
||||
warnings: nextWarnings,
|
||||
summary: {
|
||||
...(existing.summary ?? {}),
|
||||
...buildDraftSummary(nextDraft),
|
||||
...(data.summary ?? {})
|
||||
},
|
||||
sourceArtifacts: data.sourceArtifacts ?? existing.sourceArtifacts
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
throw new AppError('Course import draft not found', ErrorCodes.COURSE_IMPORT_DRAFT_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return updated;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to update course import draft',
|
||||
ErrorCodes.COURSE_IMPORT_UPDATE_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function publishCourseImportDraftService(
|
||||
orgId: string,
|
||||
profileId: string,
|
||||
draftId: string,
|
||||
overrides: TCourseImportDraftPublish
|
||||
) {
|
||||
try {
|
||||
const existing = await getCourseImportDraftService(orgId, draftId);
|
||||
|
||||
if (existing.status === 'PUBLISHED' && existing.publishedCourseId) {
|
||||
return {
|
||||
courseId: existing.publishedCourseId,
|
||||
createdSections: 0,
|
||||
createdLessons: 0,
|
||||
localeCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
const draft = parseStoredDraft(existing);
|
||||
const courseTitle = overrides.title ?? draft.course.title;
|
||||
const courseDescription = overrides.description ?? draft.course.description;
|
||||
const courseType = overrides.type ?? draft.course.type;
|
||||
const courseMetadata = overrides.metadata ?? draft.course.metadata;
|
||||
|
||||
const sectionExternalIds = new Set(draft.sections.map((section) => section.externalId));
|
||||
draft.lessons.forEach((lesson) => {
|
||||
if (!sectionExternalIds.has(lesson.sectionExternalId)) {
|
||||
throw new AppError(`Lesson "${lesson.title}" references an unknown section`, ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
});
|
||||
|
||||
const courseResult = await createCourse(profileId, {
|
||||
title: courseTitle,
|
||||
description: courseDescription,
|
||||
type: courseType,
|
||||
organizationId: orgId
|
||||
});
|
||||
|
||||
const courseId = courseResult.course.id;
|
||||
|
||||
if (courseMetadata) {
|
||||
await updateCourse(courseId, { metadata: courseMetadata });
|
||||
}
|
||||
|
||||
const sectionIdMap = new Map<string, string>();
|
||||
for (const section of [...draft.sections].sort((a, b) => a.order - b.order)) {
|
||||
const createdSection = await createCourseSection(courseId, {
|
||||
courseId,
|
||||
title: section.title,
|
||||
order: section.order
|
||||
});
|
||||
|
||||
sectionIdMap.set(section.externalId, createdSection.id);
|
||||
}
|
||||
|
||||
const lessonIdMap = new Map<string, string>();
|
||||
for (const lesson of [...draft.lessons].sort((a, b) => a.order - b.order)) {
|
||||
const sectionId = sectionIdMap.get(lesson.sectionExternalId);
|
||||
if (!sectionId) {
|
||||
throw new AppError('Referenced section was not created', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
const createdLesson = await createLesson(courseId, {
|
||||
courseId,
|
||||
title: lesson.title,
|
||||
sectionId,
|
||||
order: lesson.order,
|
||||
isUnlocked: lesson.isUnlocked,
|
||||
public: lesson.public
|
||||
});
|
||||
|
||||
lessonIdMap.set(lesson.externalId, createdLesson.id);
|
||||
}
|
||||
|
||||
for (const lessonLanguage of draft.lessonLanguages) {
|
||||
const lessonId = lessonIdMap.get(lessonLanguage.lessonExternalId);
|
||||
if (!lessonId) {
|
||||
throw new AppError('Referenced lesson was not created', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
await upsertLessonLanguageService(lessonId, {
|
||||
locale: lessonLanguage.locale as TLocale,
|
||||
content: lessonLanguage.content
|
||||
});
|
||||
}
|
||||
|
||||
await updateCourseImportDraft(orgId, draftId, {
|
||||
status: 'PUBLISHED',
|
||||
publishedCourseId: courseId,
|
||||
summary: {
|
||||
...(existing.summary ?? {}),
|
||||
...buildDraftSummary(draft),
|
||||
publishedCourseId: courseId
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
courseId,
|
||||
createdSections: sectionIdMap.size,
|
||||
createdLessons: lessonIdMap.size,
|
||||
localeCount: new Set(draft.lessonLanguages.map((item) => item.locale)).size
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to publish course import draft',
|
||||
ErrorCodes.COURSE_IMPORT_PUBLISH_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
||||
import { getCourseTeachers } from '@cio/db/queries/course/people';
|
||||
import { getProfileById } from '@cio/db/queries/auth';
|
||||
import { buildEmailFromName, sendEmail } from '@cio/email';
|
||||
import { getProfileByEmail } from '@cio/db/queries/auth';
|
||||
import { generateSlug } from '@cio/utils/functions';
|
||||
|
||||
type InviteStatus = 'ACTIVE' | 'EXPIRED' | 'USED_UP' | 'REVOKED';
|
||||
type InvitePreset = 'ONE_TIME_24H' | 'MULTI_USE_7D' | 'MULTI_USE_30D' | 'CUSTOM';
|
||||
@@ -38,6 +40,39 @@ const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const ANOMALY_WINDOW_MINUTES = 60;
|
||||
const MAX_PREVIEW_IP_DIVERSITY = 25;
|
||||
|
||||
async function generateUniqueCourseSlug(baseSlug: string): Promise<string> {
|
||||
let suffix = 0;
|
||||
let candidate = baseSlug;
|
||||
|
||||
while (true) {
|
||||
const existing = await db
|
||||
.select({ id: schema.course.id })
|
||||
.from(schema.course)
|
||||
.where(eq(schema.course.slug, candidate))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
suffix += 1;
|
||||
candidate = `${baseSlug}-${suffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCourseSlug(courseId: string, title: string | null | undefined): Promise<string> {
|
||||
const baseSlug = generateSlug(title, { fallback: 'course' });
|
||||
const uniqueSlug = await generateUniqueCourseSlug(baseSlug);
|
||||
|
||||
const [updated] = await db
|
||||
.update(schema.course)
|
||||
.set({ slug: uniqueSlug, updatedAt: new Date().toISOString() })
|
||||
.where(eq(schema.course.id, courseId))
|
||||
.returning({ slug: schema.course.slug });
|
||||
|
||||
return updated?.slug ?? uniqueSlug;
|
||||
}
|
||||
|
||||
interface TInviteRequestContext {
|
||||
ipAddress?: string | null;
|
||||
userAgent?: string | null;
|
||||
@@ -481,11 +516,9 @@ export async function createStudentInvite(courseId: string, createdByProfileId:
|
||||
);
|
||||
}
|
||||
|
||||
const courseSlug = course[0].slug;
|
||||
const courseTitle = course[0].title;
|
||||
const courseSlug = course[0].slug ?? (await ensureCourseSlug(course[0].id, courseTitle));
|
||||
const orgSiteName = courseOrgData.orgSiteName ?? null;
|
||||
if (!courseSlug) {
|
||||
throw new AppError('Course must have a slug to create invite links', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
if (recipients.valid.length === 0) {
|
||||
const createdInvite = await createSingleInvite(
|
||||
@@ -803,6 +836,17 @@ export async function previewStudentInvite(token: string, context: TInviteReques
|
||||
});
|
||||
|
||||
const status = getInviteStatus(data.invite);
|
||||
const recipientEmail = data.invite.allowedEmails?.[0]?.toLowerCase().trim() || null;
|
||||
let recipientExists = false;
|
||||
|
||||
if (recipientEmail) {
|
||||
try {
|
||||
const profile = await getProfileByEmail(recipientEmail);
|
||||
recipientExists = !!profile;
|
||||
} catch (error) {
|
||||
console.error('previewStudentInvite getProfileByEmail error:', error);
|
||||
}
|
||||
}
|
||||
const courseMetadata = data.course.metadata as { allowNewStudent?: boolean } | null;
|
||||
|
||||
return {
|
||||
@@ -828,6 +872,10 @@ export async function previewStudentInvite(token: string, context: TInviteReques
|
||||
status: data.course.status,
|
||||
slug: data.course.slug ?? undefined,
|
||||
cost: data.course.cost
|
||||
},
|
||||
inviteContext: {
|
||||
recipientEmail,
|
||||
recipientExists
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import type {
|
||||
TCreateOrganizationApiKey,
|
||||
TListOrganizationApiKeysQuery,
|
||||
TOrganizationApiKeyScope,
|
||||
TOrganizationApiKeyType
|
||||
} from '@cio/utils/validation/organization';
|
||||
import type { TOrganizationApiKey } from '@db/types';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import {
|
||||
createOrganizationApiKey,
|
||||
getActiveOrganizationApiKeyByHash,
|
||||
getOrganizationApiKeyById,
|
||||
listOrganizationApiKeys,
|
||||
updateOrganizationApiKey
|
||||
} from '@cio/db/queries/organization';
|
||||
|
||||
const DEFAULT_SCOPES: Record<TOrganizationApiKeyType, TOrganizationApiKeyScope[]> = {
|
||||
mcp: [
|
||||
'course_import:draft:create',
|
||||
'course_import:draft:read',
|
||||
'course_import:draft:update',
|
||||
'course_import:draft:publish'
|
||||
],
|
||||
api: ['public_api:*'],
|
||||
zapier: ['public_api:*']
|
||||
};
|
||||
|
||||
function getDefaultLabel(type: TOrganizationApiKeyType) {
|
||||
switch (type) {
|
||||
case 'mcp':
|
||||
return 'MCP key';
|
||||
case 'api':
|
||||
return 'API key';
|
||||
case 'zapier':
|
||||
return 'Zapier key';
|
||||
}
|
||||
}
|
||||
|
||||
function generateOrganizationApiKeySecret(type: TOrganizationApiKeyType) {
|
||||
return `cio_${type}_${randomBytes(24).toString('base64url')}`;
|
||||
}
|
||||
|
||||
function hashSecret(secret: string) {
|
||||
return createHash('sha256').update(secret).digest('hex');
|
||||
}
|
||||
|
||||
function getSecretPrefix(secret: string) {
|
||||
return secret.slice(0, 16);
|
||||
}
|
||||
|
||||
function isExpired(expiresAt: string | null) {
|
||||
return Boolean(expiresAt && new Date(expiresAt).getTime() <= Date.now());
|
||||
}
|
||||
|
||||
export type OrganizationApiKeySummary = Omit<TOrganizationApiKey, 'secretHash'>;
|
||||
|
||||
function sanitizeOrganizationApiKey(key: TOrganizationApiKey): OrganizationApiKeySummary {
|
||||
const { secretHash: _secretHash, ...rest } = key;
|
||||
return rest;
|
||||
}
|
||||
|
||||
export function organizationApiKeyHasScopes(
|
||||
keyScopes: string[],
|
||||
requiredScopes: readonly TOrganizationApiKeyScope[]
|
||||
): boolean {
|
||||
if (requiredScopes.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const scopeSet = new Set(keyScopes);
|
||||
return requiredScopes.every((scope) => scopeSet.has(scope));
|
||||
}
|
||||
|
||||
export async function listOrganizationApiKeysService(
|
||||
organizationId: string,
|
||||
query: TListOrganizationApiKeysQuery
|
||||
): Promise<OrganizationApiKeySummary[]> {
|
||||
const keys = await listOrganizationApiKeys(organizationId, query.type);
|
||||
return keys.map(sanitizeOrganizationApiKey);
|
||||
}
|
||||
|
||||
export async function createOrganizationApiKeyService(
|
||||
organizationId: string,
|
||||
createdByProfileId: string,
|
||||
payload: TCreateOrganizationApiKey
|
||||
): Promise<{ key: OrganizationApiKeySummary; secret: string }> {
|
||||
const secret = generateOrganizationApiKeySecret(payload.type);
|
||||
const key = await createOrganizationApiKey({
|
||||
organizationId,
|
||||
createdByProfileId,
|
||||
type: payload.type,
|
||||
label: payload.label?.trim() || getDefaultLabel(payload.type),
|
||||
secretPrefix: getSecretPrefix(secret),
|
||||
secretHash: hashSecret(secret),
|
||||
scopes: payload.scopes ?? DEFAULT_SCOPES[payload.type],
|
||||
expiresAt: payload.expiresAt ?? null
|
||||
});
|
||||
|
||||
return {
|
||||
key: sanitizeOrganizationApiKey(key),
|
||||
secret
|
||||
};
|
||||
}
|
||||
|
||||
export async function revokeOrganizationApiKeyService(organizationId: string, keyId: string) {
|
||||
const existing = await getOrganizationApiKeyById(organizationId, keyId);
|
||||
if (!existing) {
|
||||
throw new AppError('Organization API key not found', ErrorCodes.ORGANIZATION_API_KEY_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const updated = await updateOrganizationApiKey(organizationId, keyId, {
|
||||
revokedAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
throw new AppError('Failed to revoke organization API key', ErrorCodes.ORGANIZATION_API_KEY_REVOKE_FAILED, 500);
|
||||
}
|
||||
|
||||
return sanitizeOrganizationApiKey(updated);
|
||||
}
|
||||
|
||||
export async function rotateOrganizationApiKeyService(organizationId: string, keyId: string) {
|
||||
const existing = await getOrganizationApiKeyById(organizationId, keyId);
|
||||
if (!existing) {
|
||||
throw new AppError('Organization API key not found', ErrorCodes.ORGANIZATION_API_KEY_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const secret = generateOrganizationApiKeySecret(existing.type);
|
||||
const updated = await updateOrganizationApiKey(organizationId, keyId, {
|
||||
secretPrefix: getSecretPrefix(secret),
|
||||
secretHash: hashSecret(secret),
|
||||
revokedAt: null,
|
||||
lastUsedAt: null
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
throw new AppError('Failed to rotate organization API key', ErrorCodes.ORGANIZATION_API_KEY_ROTATE_FAILED, 500);
|
||||
}
|
||||
|
||||
return {
|
||||
key: sanitizeOrganizationApiKey(updated),
|
||||
secret
|
||||
};
|
||||
}
|
||||
|
||||
export async function authenticateOrganizationApiKeyService(rawSecret: string): Promise<TOrganizationApiKey> {
|
||||
const key = await getActiveOrganizationApiKeyByHash(hashSecret(rawSecret));
|
||||
|
||||
if (!key) {
|
||||
throw new AppError('Invalid organization API key', ErrorCodes.UNAUTHORIZED, 401);
|
||||
}
|
||||
|
||||
if (isExpired(key.expiresAt ?? null)) {
|
||||
throw new AppError('Organization API key has expired', ErrorCodes.UNAUTHORIZED, 401);
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
export async function touchOrganizationApiKeyLastUsedService(key: TOrganizationApiKey): Promise<void> {
|
||||
await updateOrganizationApiKey(key.organizationId, key.id, {
|
||||
lastUsedAt: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user