feat: implement automation key management with middleware and routes for organization API keys, invite improvements

This commit is contained in:
Rotimi Best
2026-03-13 18:40:07 +01:00
parent 77a1188573
commit 492f8dc5ed
71 changed files with 3655 additions and 456 deletions
+1 -4
View File
@@ -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';
+1
View File
@@ -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';
+77
View File
@@ -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`)
]
);
+12
View File
@@ -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];