This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE "launcher_manual_documents" (
|
||||
"id" UUID NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"is_published" BOOLEAN NOT NULL DEFAULT true,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "launcher_manual_documents_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "launcher_manual_documents_key_key" ON "launcher_manual_documents"("key");
|
||||
CREATE INDEX "launcher_manual_documents_is_published_sort_order_idx" ON "launcher_manual_documents"("is_published", "sort_order");
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE "launcher_manual_documents"
|
||||
ADD COLUMN "app_key" TEXT NOT NULL DEFAULT 'Launcher';
|
||||
|
||||
DROP INDEX IF EXISTS "launcher_manual_documents_is_published_sort_order_idx";
|
||||
CREATE INDEX "launcher_manual_documents_app_key_is_published_sort_order_idx"
|
||||
ON "launcher_manual_documents"("app_key", "is_published", "sort_order");
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS "launcher_manual_documents_key_key";
|
||||
ALTER TABLE "launcher_manual_documents" DROP COLUMN IF EXISTS "key";
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE "launcher_manual_documents"
|
||||
ADD COLUMN IF NOT EXISTS "created_by_id" UUID,
|
||||
ADD COLUMN IF NOT EXISTS "created_by_name" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "created_by_email" TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "launcher_manual_documents_created_by_email_idx"
|
||||
ON "launcher_manual_documents"("created_by_email");
|
||||
@@ -0,0 +1,9 @@
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('public.organizations') IS NOT NULL THEN
|
||||
ALTER TABLE "organizations"
|
||||
ADD COLUMN IF NOT EXISTS "created_by_id" UUID,
|
||||
ADD COLUMN IF NOT EXISTS "created_by_name" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "created_by_email" TEXT;
|
||||
END IF;
|
||||
END $$;
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
ALTER TABLE "organizations"
|
||||
ADD COLUMN "instructor_invite_code" TEXT;
|
||||
|
||||
UPDATE "organizations"
|
||||
SET "instructor_invite_code" = UPPER(SUBSTRING(MD5("id"::text || CLOCK_TIMESTAMP()::text) FROM 1 FOR 6))
|
||||
WHERE "instructor_invite_code" IS NULL;
|
||||
|
||||
ALTER TABLE "organizations"
|
||||
ALTER COLUMN "instructor_invite_code" SET NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX "organizations_instructor_invite_code_key"
|
||||
ON "organizations"("instructor_invite_code");
|
||||
+10
-8
@@ -114,14 +114,16 @@ model UserLicense {
|
||||
}
|
||||
|
||||
model Organization {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String
|
||||
type OrganizationType @default(OTHER)
|
||||
domain String?
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
affiliations UserAffiliation[]
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String
|
||||
type OrganizationType @default(OTHER)
|
||||
domain String?
|
||||
defaultLicenseCode String? @map("default_license_code")
|
||||
instructorInviteCode String @unique @map("instructor_invite_code")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
affiliations UserAffiliation[]
|
||||
|
||||
@@unique([name, type])
|
||||
@@index([isActive, type])
|
||||
|
||||
+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