This commit is contained in:
+64
-1
@@ -9,6 +9,7 @@ import {
|
||||
} from '../storage/r2-public-files'
|
||||
import { hashPassword, verifyPassword } from '../security/password'
|
||||
import { redis } from '../redis'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { createOpaqueToken, hashToken } from '../security/tokens'
|
||||
import type { AuthUser } from '../types'
|
||||
|
||||
@@ -204,6 +205,52 @@ export async function createUser(input: { email: string; password: string; name?
|
||||
return toAuthUser(user)
|
||||
}
|
||||
|
||||
export async function createInstructorUser(input: {
|
||||
email: string
|
||||
password: string
|
||||
name?: string | null
|
||||
instructorInviteCode: string
|
||||
}): Promise<AuthUser | null> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const organization = await tx.organization.findFirst({
|
||||
where: {
|
||||
isActive: true,
|
||||
instructorInviteCode: input.instructorInviteCode.trim().toUpperCase()
|
||||
},
|
||||
select: { id: true, defaultLicenseCode: true }
|
||||
})
|
||||
if (!organization) return null
|
||||
|
||||
const requestedLicenseCode = organization.defaultLicenseCode ?? 'FREE'
|
||||
const license = await tx.license.findFirst({
|
||||
where: { code: requestedLicenseCode as 'FREE' | 'EDU' | 'PRO', isActive: true },
|
||||
select: { code: true }
|
||||
})
|
||||
|
||||
const user = await tx.user.create({
|
||||
data: {
|
||||
email: input.email.toLowerCase(),
|
||||
passwordHash: await hashPassword(input.password),
|
||||
name: input.name?.trim() || null,
|
||||
affiliations: {
|
||||
create: { organizationId: organization.id, isPrimary: true }
|
||||
},
|
||||
appRoles: {
|
||||
create: { appKey: 'LMS', roleCode: 'INSTRUCTOR' }
|
||||
},
|
||||
userLicenses: {
|
||||
create: {
|
||||
license: { connect: { code: license?.code ?? 'FREE' } }
|
||||
}
|
||||
}
|
||||
},
|
||||
select: userSelect()
|
||||
})
|
||||
|
||||
return toAuthUser(user)
|
||||
})
|
||||
}
|
||||
|
||||
export async function findOrCreateOAuthUser(input: {
|
||||
provider: string
|
||||
providerUserId: string
|
||||
@@ -487,11 +534,13 @@ export async function listOrganizations() {
|
||||
}
|
||||
|
||||
export async function createOrganization(input: { name: string; type: 'COMPANY' | 'SCHOOL' | 'GOVERNMENT' | 'LAB' | 'OTHER'; domain?: string | null }) {
|
||||
const instructorInviteCode = await createInstructorInviteCode()
|
||||
return prisma.organization.create({
|
||||
data: {
|
||||
name: input.name.trim(),
|
||||
type: input.type,
|
||||
domain: input.domain?.trim() || null
|
||||
domain: input.domain?.trim() || null,
|
||||
instructorInviteCode
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -503,6 +552,20 @@ export async function createOrganization(input: { name: string; type: 'COMPANY'
|
||||
})
|
||||
}
|
||||
|
||||
async function createInstructorInviteCode(): Promise<string> {
|
||||
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
const code = Array.from(randomBytes(6), (byte) => alphabet[byte % alphabet.length]).join('')
|
||||
const existing = await prisma.organization.findUnique({
|
||||
where: { instructorInviteCode: code },
|
||||
select: { id: true }
|
||||
})
|
||||
if (!existing) return code
|
||||
}
|
||||
|
||||
throw new Error('failed_to_generate_instructor_invite_code')
|
||||
}
|
||||
|
||||
export async function assignUserAffiliation(input: {
|
||||
userId: string
|
||||
organizationId: string
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
createHandoffToken,
|
||||
createRefreshToken,
|
||||
createUser,
|
||||
createInstructorUser,
|
||||
deleteUserProfileImage,
|
||||
exchangeOAuthLoginToken,
|
||||
exchangeHandoffToken,
|
||||
@@ -52,6 +53,10 @@ const signupSchema = z.object({
|
||||
verificationCode: z.string().regex(/^\d{6}$/)
|
||||
})
|
||||
|
||||
const instructorSignupSchema = signupSchema.extend({
|
||||
instructorInviteCode: z.string().trim().regex(/^[A-Za-z0-9]{6}$/)
|
||||
})
|
||||
|
||||
const emailVerificationSendSchema = z.object({
|
||||
email: z.string().email()
|
||||
})
|
||||
@@ -606,6 +611,31 @@ authRoutes.post('/signup', async (c) => {
|
||||
return c.json(await accessTokenResponse(user), 201)
|
||||
})
|
||||
|
||||
authRoutes.post('/instructor-signup', async (c) => {
|
||||
const body = instructorSignupSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
|
||||
|
||||
const email = body.data.email.toLowerCase()
|
||||
const existingUser = await findUserByEmail(email)
|
||||
if (existingUser) return c.json({ error: 'email_already_registered' }, 409)
|
||||
|
||||
const emailVerified = await verifyEmailVerificationCode(email, body.data.verificationCode)
|
||||
if (!emailVerified) return c.json({ error: 'invalid_email_verification_code' }, 400)
|
||||
|
||||
const user = await createInstructorUser({
|
||||
email,
|
||||
password: body.data.password,
|
||||
name: body.data.name,
|
||||
instructorInviteCode: body.data.instructorInviteCode
|
||||
})
|
||||
if (!user) return c.json({ error: 'invalid_instructor_invite_code' }, 400)
|
||||
|
||||
const refreshToken = await createRefreshToken(user.id)
|
||||
setRefreshCookie(c, refreshToken)
|
||||
|
||||
return c.json(await accessTokenResponse(user), 201)
|
||||
})
|
||||
|
||||
authRoutes.post('/refresh', async (c) => {
|
||||
const refreshToken = await readRefreshToken(c)
|
||||
if (!refreshToken) return c.json({ error: 'missing_refresh_token' }, 401)
|
||||
|
||||
Reference in New Issue
Block a user