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 {
|
model Organization {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
name String
|
name String
|
||||||
type OrganizationType @default(OTHER)
|
type OrganizationType @default(OTHER)
|
||||||
domain String?
|
domain String?
|
||||||
isActive Boolean @default(true) @map("is_active")
|
defaultLicenseCode String? @map("default_license_code")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
instructorInviteCode String @unique @map("instructor_invite_code")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
isActive Boolean @default(true) @map("is_active")
|
||||||
affiliations UserAffiliation[]
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
|
affiliations UserAffiliation[]
|
||||||
|
|
||||||
@@unique([name, type])
|
@@unique([name, type])
|
||||||
@@index([isActive, type])
|
@@index([isActive, type])
|
||||||
|
|||||||
+64
-1
@@ -9,6 +9,7 @@ import {
|
|||||||
} from '../storage/r2-public-files'
|
} from '../storage/r2-public-files'
|
||||||
import { hashPassword, verifyPassword } from '../security/password'
|
import { hashPassword, verifyPassword } from '../security/password'
|
||||||
import { redis } from '../redis'
|
import { redis } from '../redis'
|
||||||
|
import { randomBytes } from 'crypto'
|
||||||
import { createOpaqueToken, hashToken } from '../security/tokens'
|
import { createOpaqueToken, hashToken } from '../security/tokens'
|
||||||
import type { AuthUser } from '../types'
|
import type { AuthUser } from '../types'
|
||||||
|
|
||||||
@@ -204,6 +205,52 @@ export async function createUser(input: { email: string; password: string; name?
|
|||||||
return toAuthUser(user)
|
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: {
|
export async function findOrCreateOAuthUser(input: {
|
||||||
provider: string
|
provider: string
|
||||||
providerUserId: 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 }) {
|
export async function createOrganization(input: { name: string; type: 'COMPANY' | 'SCHOOL' | 'GOVERNMENT' | 'LAB' | 'OTHER'; domain?: string | null }) {
|
||||||
|
const instructorInviteCode = await createInstructorInviteCode()
|
||||||
return prisma.organization.create({
|
return prisma.organization.create({
|
||||||
data: {
|
data: {
|
||||||
name: input.name.trim(),
|
name: input.name.trim(),
|
||||||
type: input.type,
|
type: input.type,
|
||||||
domain: input.domain?.trim() || null
|
domain: input.domain?.trim() || null,
|
||||||
|
instructorInviteCode
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
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: {
|
export async function assignUserAffiliation(input: {
|
||||||
userId: string
|
userId: string
|
||||||
organizationId: string
|
organizationId: string
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
createHandoffToken,
|
createHandoffToken,
|
||||||
createRefreshToken,
|
createRefreshToken,
|
||||||
createUser,
|
createUser,
|
||||||
|
createInstructorUser,
|
||||||
deleteUserProfileImage,
|
deleteUserProfileImage,
|
||||||
exchangeOAuthLoginToken,
|
exchangeOAuthLoginToken,
|
||||||
exchangeHandoffToken,
|
exchangeHandoffToken,
|
||||||
@@ -52,6 +53,10 @@ const signupSchema = z.object({
|
|||||||
verificationCode: z.string().regex(/^\d{6}$/)
|
verificationCode: z.string().regex(/^\d{6}$/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const instructorSignupSchema = signupSchema.extend({
|
||||||
|
instructorInviteCode: z.string().trim().regex(/^[A-Za-z0-9]{6}$/)
|
||||||
|
})
|
||||||
|
|
||||||
const emailVerificationSendSchema = z.object({
|
const emailVerificationSendSchema = z.object({
|
||||||
email: z.string().email()
|
email: z.string().email()
|
||||||
})
|
})
|
||||||
@@ -606,6 +611,31 @@ authRoutes.post('/signup', async (c) => {
|
|||||||
return c.json(await accessTokenResponse(user), 201)
|
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) => {
|
authRoutes.post('/refresh', async (c) => {
|
||||||
const refreshToken = await readRefreshToken(c)
|
const refreshToken = await readRefreshToken(c)
|
||||||
if (!refreshToken) return c.json({ error: 'missing_refresh_token' }, 401)
|
if (!refreshToken) return c.json({ error: 'missing_refresh_token' }, 401)
|
||||||
|
|||||||
Reference in New Issue
Block a user