From f379bba89a23281f85d02dfe32789d6a7efa4df6 Mon Sep 17 00:00:00 2001 From: chyoo Date: Fri, 7 Aug 2026 11:56:00 +0900 Subject: [PATCH] add instructor invite code.. --- .../migration.sql | 15 +++++ .../migration.sql | 6 ++ .../migration.sql | 2 + .../migration.sql | 7 ++ .../migration.sql | 9 +++ .../migration.sql | 12 ++++ prisma/schema.prisma | 18 ++--- src/auth/repository.ts | 65 ++++++++++++++++++- src/auth/routes.ts | 30 +++++++++ 9 files changed, 155 insertions(+), 9 deletions(-) create mode 100644 prisma/migrations/20260714090000_add_launcher_manual_documents/migration.sql create mode 100644 prisma/migrations/20260714093000_add_launcher_manual_document_app_key/migration.sql create mode 100644 prisma/migrations/20260714100000_drop_launcher_manual_document_key/migration.sql create mode 100644 prisma/migrations/20260714110000_add_launcher_manual_document_creator/migration.sql create mode 100644 prisma/migrations/20260714113000_add_organization_creator/migration.sql create mode 100644 prisma/migrations/20260807150000_add_organization_instructor_invite_code/migration.sql diff --git a/prisma/migrations/20260714090000_add_launcher_manual_documents/migration.sql b/prisma/migrations/20260714090000_add_launcher_manual_documents/migration.sql new file mode 100644 index 0000000..da786a0 --- /dev/null +++ b/prisma/migrations/20260714090000_add_launcher_manual_documents/migration.sql @@ -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"); diff --git a/prisma/migrations/20260714093000_add_launcher_manual_document_app_key/migration.sql b/prisma/migrations/20260714093000_add_launcher_manual_document_app_key/migration.sql new file mode 100644 index 0000000..98c6ab1 --- /dev/null +++ b/prisma/migrations/20260714093000_add_launcher_manual_document_app_key/migration.sql @@ -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"); diff --git a/prisma/migrations/20260714100000_drop_launcher_manual_document_key/migration.sql b/prisma/migrations/20260714100000_drop_launcher_manual_document_key/migration.sql new file mode 100644 index 0000000..06bf806 --- /dev/null +++ b/prisma/migrations/20260714100000_drop_launcher_manual_document_key/migration.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS "launcher_manual_documents_key_key"; +ALTER TABLE "launcher_manual_documents" DROP COLUMN IF EXISTS "key"; diff --git a/prisma/migrations/20260714110000_add_launcher_manual_document_creator/migration.sql b/prisma/migrations/20260714110000_add_launcher_manual_document_creator/migration.sql new file mode 100644 index 0000000..69605c1 --- /dev/null +++ b/prisma/migrations/20260714110000_add_launcher_manual_document_creator/migration.sql @@ -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"); diff --git a/prisma/migrations/20260714113000_add_organization_creator/migration.sql b/prisma/migrations/20260714113000_add_organization_creator/migration.sql new file mode 100644 index 0000000..a16868f --- /dev/null +++ b/prisma/migrations/20260714113000_add_organization_creator/migration.sql @@ -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 $$; diff --git a/prisma/migrations/20260807150000_add_organization_instructor_invite_code/migration.sql b/prisma/migrations/20260807150000_add_organization_instructor_invite_code/migration.sql new file mode 100644 index 0000000..b5c3bba --- /dev/null +++ b/prisma/migrations/20260807150000_add_organization_instructor_invite_code/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a438bd4..299c758 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) diff --git a/src/auth/repository.ts b/src/auth/repository.ts index af47f27..5d7ab92 100644 --- a/src/auth/repository.ts +++ b/src/auth/repository.ts @@ -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 { + 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 { + 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 diff --git a/src/auth/routes.ts b/src/auth/routes.ts index 8dddb98..4f2e5ad 100644 --- a/src/auth/routes.ts +++ b/src/auth/routes.ts @@ -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)