feat: implement automation key management with middleware and routes for organization API keys, invite improvements
This commit is contained in:
@@ -26,8 +26,5 @@ export const markUserAndProfileEmailVerified = async (userId: string, dbClient:
|
||||
const verifiedAt = new Date().toISOString();
|
||||
|
||||
await dbClient.update(schema.user).set({ emailVerified: true }).where(eq(schema.user.id, userId));
|
||||
await dbClient
|
||||
.update(schema.profile)
|
||||
.set({ isEmailVerified: true, verifiedAt })
|
||||
.where(eq(schema.profile.id, userId));
|
||||
await dbClient.update(schema.profile).set({ isEmailVerified: true, verifiedAt }).where(eq(schema.profile.id, userId));
|
||||
};
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@db/drizzle';
|
||||
import type { TCourseImportDraft, TNewCourseImportDraft } from '@db/types';
|
||||
|
||||
export async function createCourseImportDraft(data: TNewCourseImportDraft): Promise<TCourseImportDraft> {
|
||||
try {
|
||||
const [draft] = await db.insert(schema.courseImportDraft).values(data).returning();
|
||||
|
||||
if (!draft) {
|
||||
throw new Error('Failed to create course import draft');
|
||||
}
|
||||
|
||||
return draft;
|
||||
} catch (error) {
|
||||
console.error('createCourseImportDraft error:', error);
|
||||
throw new Error('Failed to create course import draft');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCourseImportDraftById(orgId: string, draftId: string): Promise<TCourseImportDraft | null> {
|
||||
try {
|
||||
const [draft] = await db
|
||||
.select()
|
||||
.from(schema.courseImportDraft)
|
||||
.where(and(eq(schema.courseImportDraft.organizationId, orgId), eq(schema.courseImportDraft.id, draftId)))
|
||||
.limit(1);
|
||||
|
||||
return draft || null;
|
||||
} catch (error) {
|
||||
console.error('getCourseImportDraftById error:', error);
|
||||
throw new Error('Failed to fetch course import draft');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCourseImportDraftByIdempotencyKey(
|
||||
orgId: string,
|
||||
idempotencyKey: string
|
||||
): Promise<TCourseImportDraft | null> {
|
||||
try {
|
||||
const [draft] = await db
|
||||
.select()
|
||||
.from(schema.courseImportDraft)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.courseImportDraft.organizationId, orgId),
|
||||
eq(schema.courseImportDraft.idempotencyKey, idempotencyKey)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return draft || null;
|
||||
} catch (error) {
|
||||
console.error('getCourseImportDraftByIdempotencyKey error:', error);
|
||||
throw new Error('Failed to fetch course import draft by idempotency key');
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCourseImportDraft(
|
||||
orgId: string,
|
||||
draftId: string,
|
||||
data: Partial<TCourseImportDraft>
|
||||
): Promise<TCourseImportDraft | null> {
|
||||
try {
|
||||
const [draft] = await db
|
||||
.update(schema.courseImportDraft)
|
||||
.set({
|
||||
...data,
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
.where(and(eq(schema.courseImportDraft.organizationId, orgId), eq(schema.courseImportDraft.id, draftId)))
|
||||
.returning();
|
||||
|
||||
return draft || null;
|
||||
} catch (error) {
|
||||
console.error('updateCourseImportDraft error:', error);
|
||||
throw new Error('Failed to update course import draft');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './course-import';
|
||||
@@ -11,3 +11,4 @@ export * from './mark';
|
||||
export * from './newsfeed';
|
||||
export * from './submission';
|
||||
export * from './tag';
|
||||
export * from './course-import';
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import { and, desc, eq, isNull } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@db/drizzle';
|
||||
import type { TNewOrganizationApiKey, TOrganizationApiKey, TOrganizationApiKeyType } from '@db/types';
|
||||
|
||||
export const createOrganizationApiKey = async (data: TNewOrganizationApiKey): Promise<TOrganizationApiKey> => {
|
||||
try {
|
||||
const [row] = await db.insert(schema.organizationApiKey).values(data).returning();
|
||||
|
||||
if (!row) {
|
||||
throw new Error('Failed to create organization API key');
|
||||
}
|
||||
|
||||
return row;
|
||||
} catch (error) {
|
||||
console.error('createOrganizationApiKey error:', error);
|
||||
throw new Error('Failed to create organization API key');
|
||||
}
|
||||
};
|
||||
|
||||
export const listOrganizationApiKeys = async (
|
||||
organizationId: string,
|
||||
type?: TOrganizationApiKeyType
|
||||
): Promise<TOrganizationApiKey[]> => {
|
||||
try {
|
||||
const conditions = [eq(schema.organizationApiKey.organizationId, organizationId)];
|
||||
|
||||
if (type) {
|
||||
conditions.push(eq(schema.organizationApiKey.type, type));
|
||||
}
|
||||
|
||||
return db
|
||||
.select()
|
||||
.from(schema.organizationApiKey)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(schema.organizationApiKey.createdAt));
|
||||
} catch (error) {
|
||||
console.error('listOrganizationApiKeys error:', error);
|
||||
throw new Error('Failed to list organization API keys');
|
||||
}
|
||||
};
|
||||
|
||||
export const getOrganizationApiKeyById = async (
|
||||
organizationId: string,
|
||||
keyId: string
|
||||
): Promise<TOrganizationApiKey | null> => {
|
||||
try {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(schema.organizationApiKey)
|
||||
.where(and(eq(schema.organizationApiKey.organizationId, organizationId), eq(schema.organizationApiKey.id, keyId)))
|
||||
.limit(1);
|
||||
|
||||
return row || null;
|
||||
} catch (error) {
|
||||
console.error('getOrganizationApiKeyById error:', error);
|
||||
throw new Error('Failed to get organization API key by id');
|
||||
}
|
||||
};
|
||||
|
||||
export const getActiveOrganizationApiKeyByHash = async (secretHash: string): Promise<TOrganizationApiKey | null> => {
|
||||
try {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(schema.organizationApiKey)
|
||||
.where(and(eq(schema.organizationApiKey.secretHash, secretHash), isNull(schema.organizationApiKey.revokedAt)))
|
||||
.limit(1);
|
||||
|
||||
return row || null;
|
||||
} catch (error) {
|
||||
console.error('getActiveOrganizationApiKeyByHash error:', error);
|
||||
throw new Error('Failed to get organization API key by hash');
|
||||
}
|
||||
};
|
||||
|
||||
export const updateOrganizationApiKey = async (
|
||||
organizationId: string,
|
||||
keyId: string,
|
||||
data: Partial<TOrganizationApiKey>
|
||||
): Promise<TOrganizationApiKey | null> => {
|
||||
try {
|
||||
const [row] = await db
|
||||
.update(schema.organizationApiKey)
|
||||
.set({
|
||||
...data,
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
.where(and(eq(schema.organizationApiKey.organizationId, organizationId), eq(schema.organizationApiKey.id, keyId)))
|
||||
.returning();
|
||||
|
||||
return row || null;
|
||||
} catch (error) {
|
||||
console.error('updateOrganizationApiKey error:', error);
|
||||
throw new Error('Failed to update organization API key');
|
||||
}
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './organization';
|
||||
export * from './quiz';
|
||||
export * from './invite';
|
||||
export * from './automation-key';
|
||||
|
||||
@@ -25,6 +25,9 @@ import { sql } from 'drizzle-orm';
|
||||
export const courseType = pgEnum('COURSE_TYPE', ['SELF_PACED', 'LIVE_CLASS']);
|
||||
export const locale = pgEnum('LOCALE', ['en', 'hi', 'fr', 'pt', 'de', 'vi', 'ru', 'es', 'pl', 'da']);
|
||||
export const plan = pgEnum('PLAN', ['EARLY_ADOPTER', 'ENTERPRISE', 'BASIC']);
|
||||
export const courseImportSourceType = pgEnum('COURSE_IMPORT_SOURCE_TYPE', ['prompt', 'pdf']);
|
||||
export const courseImportDraftStatus = pgEnum('COURSE_IMPORT_DRAFT_STATUS', ['DRAFT', 'PUBLISHED', 'ARCHIVED']);
|
||||
export const organizationApiKeyType = pgEnum('ORGANIZATION_API_KEY_TYPE', ['mcp', 'api', 'zapier']);
|
||||
export const courseInviteEventType = pgEnum('COURSE_INVITE_EVENT_TYPE', [
|
||||
'CREATED',
|
||||
'REVOKED',
|
||||
@@ -1996,3 +1999,77 @@ export const organizationTokenAuth = pgTable(
|
||||
},
|
||||
(table) => [index('idx_organization_token_auth_org_id').on(table.organizationId)]
|
||||
);
|
||||
|
||||
export const organizationApiKey = pgTable(
|
||||
'organization_api_key',
|
||||
{
|
||||
id: uuid('id')
|
||||
.default(sql`gen_random_uuid()`)
|
||||
.primaryKey()
|
||||
.notNull(),
|
||||
organizationId: uuid('organization_id')
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: 'cascade' }),
|
||||
createdByProfileId: uuid('created_by_profile_id')
|
||||
.notNull()
|
||||
.references(() => profile.id),
|
||||
type: organizationApiKeyType('type').notNull(),
|
||||
label: varchar('label', { length: 120 }).notNull(),
|
||||
secretPrefix: varchar('secret_prefix', { length: 32 }).notNull(),
|
||||
secretHash: text('secret_hash').notNull(),
|
||||
scopes: jsonb('scopes').default([]).notNull().$type<string[]>(),
|
||||
lastUsedAt: timestamp('last_used_at', { withTimezone: true, mode: 'string' }),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true, mode: 'string' }),
|
||||
revokedAt: timestamp('revoked_at', { withTimezone: true, mode: 'string' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' })
|
||||
.default(sql`timezone('utc'::text, now())`)
|
||||
.notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' })
|
||||
.default(sql`timezone('utc'::text, now())`)
|
||||
.notNull()
|
||||
},
|
||||
(table) => [
|
||||
index('idx_organization_api_key_org_id').on(table.organizationId),
|
||||
index('idx_organization_api_key_type').on(table.type),
|
||||
uniqueIndex('organization_api_key_secret_hash_unique').on(table.secretHash)
|
||||
]
|
||||
);
|
||||
|
||||
export const courseImportDraft = pgTable(
|
||||
'course_import_draft',
|
||||
{
|
||||
id: uuid()
|
||||
.default(sql`gen_random_uuid()`)
|
||||
.primaryKey()
|
||||
.notNull(),
|
||||
organizationId: uuid('organization_id')
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: 'cascade' }),
|
||||
createdByProfileId: uuid('created_by_profile_id')
|
||||
.notNull()
|
||||
.references(() => profile.id),
|
||||
sourceType: courseImportSourceType('source_type').notNull(),
|
||||
status: courseImportDraftStatus('status').default('DRAFT').notNull(),
|
||||
title: varchar().notNull(),
|
||||
locale: locale().default('en').notNull(),
|
||||
idempotencyKey: varchar('idempotency_key'),
|
||||
summary: jsonb().default({}).notNull().$type<Record<string, unknown>>(),
|
||||
draft: jsonb().notNull().$type<Record<string, unknown>>(),
|
||||
warnings: jsonb().default([]).notNull().$type<Array<Record<string, unknown>>>(),
|
||||
sourceArtifacts: jsonb().default([]).notNull().$type<Array<Record<string, unknown>>>(),
|
||||
publishedCourseId: uuid('published_course_id').references(() => course.id, { onDelete: 'set null' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' })
|
||||
.default(sql`timezone('utc'::text, now())`)
|
||||
.notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' })
|
||||
.default(sql`timezone('utc'::text, now())`)
|
||||
.notNull()
|
||||
},
|
||||
(table) => [
|
||||
index('idx_course_import_draft_org_id').on(table.organizationId),
|
||||
index('idx_course_import_draft_status').on(table.status),
|
||||
uniqueIndex('course_import_draft_org_idempotency_key')
|
||||
.on(table.organizationId, table.idempotencyKey)
|
||||
.where(sql`${table.idempotencyKey} IS NOT NULL`)
|
||||
]
|
||||
);
|
||||
|
||||
@@ -169,6 +169,12 @@ export type TNewOrganizationSsoConfig = typeof schema.organizationSsoConfig.$inf
|
||||
export type TOrganizationTokenAuth = typeof schema.organizationTokenAuth.$inferSelect;
|
||||
export type TNewOrganizationTokenAuth = typeof schema.organizationTokenAuth.$inferInsert;
|
||||
|
||||
export type TOrganizationApiKey = typeof schema.organizationApiKey.$inferSelect;
|
||||
export type TNewOrganizationApiKey = typeof schema.organizationApiKey.$inferInsert;
|
||||
|
||||
export type TCourseImportDraft = typeof schema.courseImportDraft.$inferSelect;
|
||||
export type TNewCourseImportDraft = typeof schema.courseImportDraft.$inferInsert;
|
||||
|
||||
export type TCourseType = (typeof schema.courseType.enumValues)[number];
|
||||
|
||||
export type TLocale = (typeof schema.locale.enumValues)[number];
|
||||
@@ -180,3 +186,9 @@ export type TCourseInviteEventType = (typeof schema.courseInviteEventType.enumVa
|
||||
export type TOrganizationInviteEventType = (typeof schema.organizationInviteEventType.enumValues)[number];
|
||||
|
||||
export type TSsoProvider = (typeof schema.organizationSsoProviderType.enumValues)[number];
|
||||
|
||||
export type TOrganizationApiKeyType = (typeof schema.organizationApiKeyType.enumValues)[number];
|
||||
|
||||
export type TCourseImportSourceType = (typeof schema.courseImportSourceType.enumValues)[number];
|
||||
|
||||
export type TCourseImportDraftStatus = (typeof schema.courseImportDraftStatus.enumValues)[number];
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
bind:this={ref}
|
||||
data-slot="field-separator"
|
||||
data-content={hasContent}
|
||||
class={cn('ui:relative ui:-my-2 ui:h-5 ui:text-sm ui:group-data-[variant=outline]/field-group:-mb-2', className)}
|
||||
class={cn('ui:relative ui:my-2 ui:h-5 ui:text-sm ui:group-data-[variant=outline]/field-group:-mb-2', className)}
|
||||
{...restProps}
|
||||
>
|
||||
<Separator class="ui:absolute ui:inset-0 ui:top-1/2" />
|
||||
|
||||
@@ -1,37 +1,22 @@
|
||||
/**
|
||||
* Server-side file type validation utilities for preventing malicious uploads
|
||||
* Specifically designed to prevent SVG XSS attacks and other security issues
|
||||
*/
|
||||
import { ALLOWED_IMAGE_TYPES } from '../validation/constants';
|
||||
|
||||
export const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'] as const;
|
||||
|
||||
/**
|
||||
* Validate file type by MIME type
|
||||
* @param file - File object to validate
|
||||
* @returns boolean indicating if file type is allowed
|
||||
*/
|
||||
export function validateImageType(file: File): boolean {
|
||||
type FileLike = {
|
||||
type: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export function validateImageType(file: FileLike): boolean {
|
||||
return ALLOWED_IMAGE_TYPES.includes(file.type as (typeof ALLOWED_IMAGE_TYPES)[number]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file extension
|
||||
* @param filename - Filename to validate
|
||||
* @returns boolean indicating if extension is allowed
|
||||
*/
|
||||
export function validateImageExtension(filename: string): boolean {
|
||||
const extension = filename.toLowerCase().substring(filename.lastIndexOf('.'));
|
||||
return ALLOWED_EXTENSIONS.includes(extension as (typeof ALLOWED_EXTENSIONS)[number]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprehensive file validation
|
||||
* @param file - File object to validate
|
||||
* @returns object with validation result and error message
|
||||
*/
|
||||
export function validateImageUpload(file: File): { isValid: boolean; error?: string } {
|
||||
// Check file type
|
||||
export function validateImageUpload(file: FileLike): { isValid: boolean; error?: string } {
|
||||
if (!validateImageType(file)) {
|
||||
return {
|
||||
isValid: false,
|
||||
@@ -39,7 +24,6 @@ export function validateImageUpload(file: File): { isValid: boolean; error?: str
|
||||
};
|
||||
}
|
||||
|
||||
// Check file extension (double check for security)
|
||||
if (!validateImageExtension(file.name)) {
|
||||
return {
|
||||
isValid: false,
|
||||
@@ -47,7 +31,6 @@ export function validateImageUpload(file: File): { isValid: boolean; error?: str
|
||||
};
|
||||
}
|
||||
|
||||
// Additional security checks
|
||||
if (file.name.toLowerCase().includes('.svg')) {
|
||||
return {
|
||||
isValid: false,
|
||||
@@ -58,23 +41,15 @@ export function validateImageUpload(file: File): { isValid: boolean; error?: str
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize filename to prevent directory traversal and other issues
|
||||
* @param filename - Original filename
|
||||
* @returns sanitized filename
|
||||
*/
|
||||
export function sanitizeFilename(filename: string): string {
|
||||
// Remove path traversal attempts
|
||||
let sanitized = filename.replace(/[/\\]/g, '');
|
||||
|
||||
// Remove special characters that could cause issues
|
||||
let sanitized = filename.replace(/[\/\\]/g, '');
|
||||
sanitized = sanitized.replace(/[<>:"|?*]/g, '');
|
||||
|
||||
// Limit length
|
||||
if (sanitized.length > 255) {
|
||||
const ext = sanitized.substring(sanitized.lastIndexOf('.'));
|
||||
const name = sanitized.substring(0, sanitized.lastIndexOf('.'));
|
||||
sanitized = name.substring(0, 255 - ext.length) + ext;
|
||||
const extIndex = sanitized.lastIndexOf('.');
|
||||
const ext = extIndex !== -1 ? sanitized.substring(extIndex) : '';
|
||||
const name = extIndex !== -1 ? sanitized.substring(0, extIndex) : sanitized;
|
||||
sanitized = `${name.substring(0, 255 - ext.length)}${ext}`;
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './fileValidation';
|
||||
export * from './array';
|
||||
export * from './fileValidation';
|
||||
export * from './slug';
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export type GenerateSlugOptions = {
|
||||
fallback?: string;
|
||||
appendTimestamp?: boolean;
|
||||
};
|
||||
|
||||
export function generateSlug(source: string | null | undefined, options: GenerateSlugOptions = {}): string {
|
||||
const { fallback = 'item', appendTimestamp = false } = options;
|
||||
|
||||
const base =
|
||||
(source ?? fallback)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '') || fallback;
|
||||
|
||||
return appendTimestamp ? `${base}-${Date.now()}` : base;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
import { ZCourseMetadata } from '../course/course';
|
||||
|
||||
const ZSupportedLocale = z.enum(['en', 'hi', 'fr', 'pt', 'de', 'vi', 'ru', 'es', 'pl', 'da']);
|
||||
|
||||
export const ZCourseImportWarning = z.object({
|
||||
code: z.string().min(1),
|
||||
message: z.string().min(1),
|
||||
severity: z.enum(['info', 'warning', 'error'])
|
||||
});
|
||||
export type TCourseImportWarning = z.infer<typeof ZCourseImportWarning>;
|
||||
|
||||
export const ZCourseImportSourceReference = z.object({
|
||||
type: z.enum(['prompt', 'pdf']),
|
||||
label: z.string().min(1),
|
||||
pageStart: z.number().int().min(1).optional(),
|
||||
pageEnd: z.number().int().min(1).optional()
|
||||
});
|
||||
export type TCourseImportSourceReference = z.infer<typeof ZCourseImportSourceReference>;
|
||||
|
||||
export const ZCourseImportDraftCourse = z.object({
|
||||
title: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
type: z.enum(['LIVE_CLASS', 'SELF_PACED']),
|
||||
locale: ZSupportedLocale.default('en'),
|
||||
metadata: ZCourseMetadata.optional()
|
||||
});
|
||||
export type TCourseImportDraftCourse = z.infer<typeof ZCourseImportDraftCourse>;
|
||||
|
||||
export const ZCourseImportDraftSection = z.object({
|
||||
externalId: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
order: z.number().int().min(0)
|
||||
});
|
||||
export type TCourseImportDraftSection = z.infer<typeof ZCourseImportDraftSection>;
|
||||
|
||||
export const ZCourseImportDraftLesson = z.object({
|
||||
externalId: z.string().min(1),
|
||||
sectionExternalId: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
order: z.number().int().min(0),
|
||||
isUnlocked: z.boolean().optional(),
|
||||
public: z.boolean().optional()
|
||||
});
|
||||
export type TCourseImportDraftLesson = z.infer<typeof ZCourseImportDraftLesson>;
|
||||
|
||||
export const ZCourseImportDraftLessonLanguage = z.object({
|
||||
lessonExternalId: z.string().min(1),
|
||||
locale: ZSupportedLocale,
|
||||
content: z.string().min(1)
|
||||
});
|
||||
export type TCourseImportDraftLessonLanguage = z.infer<typeof ZCourseImportDraftLessonLanguage>;
|
||||
|
||||
export const ZCourseImportDraftPayload = z
|
||||
.object({
|
||||
course: ZCourseImportDraftCourse,
|
||||
sections: z.array(ZCourseImportDraftSection).min(1),
|
||||
lessons: z.array(ZCourseImportDraftLesson).min(1),
|
||||
lessonLanguages: z.array(ZCourseImportDraftLessonLanguage).min(1),
|
||||
exercises: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||
sourceReferences: z.array(ZCourseImportSourceReference).optional(),
|
||||
warnings: z.array(ZCourseImportWarning).default([])
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
const sectionIds = new Set<string>();
|
||||
value.sections.forEach((section, index) => {
|
||||
if (sectionIds.has(section.externalId)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['sections', index, 'externalId'],
|
||||
message: 'Section externalId must be unique'
|
||||
});
|
||||
}
|
||||
sectionIds.add(section.externalId);
|
||||
});
|
||||
|
||||
const lessonIds = new Set<string>();
|
||||
value.lessons.forEach((lesson, index) => {
|
||||
if (lessonIds.has(lesson.externalId)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['lessons', index, 'externalId'],
|
||||
message: 'Lesson externalId must be unique'
|
||||
});
|
||||
}
|
||||
lessonIds.add(lesson.externalId);
|
||||
|
||||
if (!sectionIds.has(lesson.sectionExternalId)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['lessons', index, 'sectionExternalId'],
|
||||
message: 'Lesson sectionExternalId must reference an existing section'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
value.lessonLanguages.forEach((lessonLanguage, index) => {
|
||||
if (!lessonIds.has(lessonLanguage.lessonExternalId)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['lessonLanguages', index, 'lessonExternalId'],
|
||||
message: 'Lesson language must reference an existing lesson'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
export type TCourseImportDraftPayload = z.infer<typeof ZCourseImportDraftPayload>;
|
||||
|
||||
export const ZCourseImportDraftCreate = z.object({
|
||||
sourceType: z.enum(['prompt', 'pdf']),
|
||||
idempotencyKey: z.string().min(1).optional(),
|
||||
summary: z.record(z.string(), z.unknown()).optional(),
|
||||
sourceArtifacts: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||
draft: ZCourseImportDraftPayload
|
||||
});
|
||||
export type TCourseImportDraftCreate = z.infer<typeof ZCourseImportDraftCreate>;
|
||||
|
||||
export const ZCourseImportDraftGetParam = z.object({
|
||||
draftId: z.string().uuid()
|
||||
});
|
||||
export type TCourseImportDraftGetParam = z.infer<typeof ZCourseImportDraftGetParam>;
|
||||
|
||||
export const ZCourseImportDraftUpdate = z.object({
|
||||
summary: z.record(z.string(), z.unknown()).optional(),
|
||||
sourceArtifacts: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||
warnings: z.array(ZCourseImportWarning).optional(),
|
||||
draft: ZCourseImportDraftPayload.optional()
|
||||
});
|
||||
export type TCourseImportDraftUpdate = z.infer<typeof ZCourseImportDraftUpdate>;
|
||||
|
||||
export const ZCourseImportDraftPublish = z.object({
|
||||
title: z.string().min(1).optional(),
|
||||
description: z.string().min(1).optional(),
|
||||
type: z.enum(['LIVE_CLASS', 'SELF_PACED']).optional(),
|
||||
metadata: ZCourseMetadata.optional()
|
||||
});
|
||||
export type TCourseImportDraftPublish = z.infer<typeof ZCourseImportDraftPublish>;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './course-import';
|
||||
@@ -4,6 +4,7 @@ export * from './account';
|
||||
export * from './attendance';
|
||||
export * from './assets';
|
||||
export * from './course';
|
||||
export * from './course-import';
|
||||
export * from './exercise';
|
||||
export * from './lesson';
|
||||
export * from './mail';
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZOrganizationApiKeyType = z.enum(['mcp', 'api', 'zapier']);
|
||||
export type TOrganizationApiKeyType = z.infer<typeof ZOrganizationApiKeyType>;
|
||||
|
||||
export const ZOrganizationApiKeyScope = z.enum([
|
||||
'course_import:draft:create',
|
||||
'course_import:draft:read',
|
||||
'course_import:draft:update',
|
||||
'course_import:draft:publish',
|
||||
'public_api:*'
|
||||
]);
|
||||
export type TOrganizationApiKeyScope = z.infer<typeof ZOrganizationApiKeyScope>;
|
||||
|
||||
export const ZListOrganizationApiKeysQuery = z.object({
|
||||
type: ZOrganizationApiKeyType.optional()
|
||||
});
|
||||
export type TListOrganizationApiKeysQuery = z.infer<typeof ZListOrganizationApiKeysQuery>;
|
||||
|
||||
export const ZCreateOrganizationApiKey = z.object({
|
||||
type: ZOrganizationApiKeyType,
|
||||
label: z.string().min(1).max(120).optional(),
|
||||
expiresAt: z.string().datetime().optional(),
|
||||
scopes: z.array(ZOrganizationApiKeyScope).min(1).optional()
|
||||
});
|
||||
export type TCreateOrganizationApiKey = z.infer<typeof ZCreateOrganizationApiKey>;
|
||||
|
||||
export const ZOrganizationApiKeyParam = z.object({
|
||||
keyId: z.string().uuid()
|
||||
});
|
||||
export type TOrganizationApiKeyParam = z.infer<typeof ZOrganizationApiKeyParam>;
|
||||
@@ -5,3 +5,4 @@ export * from './invite';
|
||||
export * from './sso';
|
||||
export * from './login-link';
|
||||
export * from './token-auth';
|
||||
export * from './automation-key';
|
||||
|
||||
Reference in New Issue
Block a user