init studio auth backend

This commit is contained in:
2026-07-01 17:36:34 +09:00
commit 87f6f46d3c
35 changed files with 2492 additions and 0 deletions
@@ -0,0 +1,47 @@
CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'USER');
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password_hash" TEXT NOT NULL,
"name" TEXT,
"role" "UserRole" NOT NULL DEFAULT 'USER',
"is_active" BOOLEAN NOT NULL DEFAULT true,
"last_login_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "refresh_tokens" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"token_hash" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"revoked_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "refresh_tokens_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "handoff_tokens" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"token_hash" TEXT NOT NULL,
"audience_app" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"used_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "handoff_tokens_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
CREATE UNIQUE INDEX "refresh_tokens_token_hash_key" ON "refresh_tokens"("token_hash");
CREATE INDEX "refresh_tokens_user_id_idx" ON "refresh_tokens"("user_id");
CREATE UNIQUE INDEX "handoff_tokens_token_hash_key" ON "handoff_tokens"("token_hash");
CREATE INDEX "handoff_tokens_user_id_idx" ON "handoff_tokens"("user_id");
ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "handoff_tokens" ADD CONSTRAINT "handoff_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,16 @@
CREATE TABLE "login_notices" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"body" TEXT NOT NULL,
"link_url" TEXT,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"starts_at" TIMESTAMP(3),
"ends_at" TIMESTAMP(3),
"priority" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "login_notices_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "login_notices_is_active_priority_idx" ON "login_notices"("is_active", "priority");
@@ -0,0 +1,49 @@
-- CreateEnum
CREATE TYPE "OrganizationType" AS ENUM ('COMPANY', 'SCHOOL', 'GOVERNMENT', 'LAB', 'OTHER');
-- CreateTable
CREATE TABLE "organizations" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" "OrganizationType" NOT NULL DEFAULT 'OTHER',
"domain" TEXT,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "organizations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_affiliations" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"organization_id" TEXT NOT NULL,
"department" TEXT,
"position" TEXT,
"is_primary" BOOLEAN NOT NULL DEFAULT false,
"starts_at" TIMESTAMP(3),
"ends_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "user_affiliations_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "organizations_name_type_key" ON "organizations"("name", "type");
-- CreateIndex
CREATE INDEX "organizations_is_active_type_idx" ON "organizations"("is_active", "type");
-- CreateIndex
CREATE INDEX "user_affiliations_user_id_is_primary_idx" ON "user_affiliations"("user_id", "is_primary");
-- CreateIndex
CREATE INDEX "user_affiliations_organization_id_idx" ON "user_affiliations"("organization_id");
-- AddForeignKey
ALTER TABLE "user_affiliations" ADD CONSTRAINT "user_affiliations_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_affiliations" ADD CONSTRAINT "user_affiliations_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,22 @@
COMMENT ON TYPE "OrganizationType" IS '사용자 소속 유형. 회사, 학교, 공공기관, 연구소, 기타를 구분한다.';
COMMENT ON TABLE "organizations" IS '사용자에게 부여할 수 있는 소속 마스터. 회사, 학교, 기관, 연구소 등을 통합 관리한다.';
COMMENT ON COLUMN "organizations"."id" IS '소속 고유 ID';
COMMENT ON COLUMN "organizations"."name" IS '소속명';
COMMENT ON COLUMN "organizations"."type" IS '소속 유형';
COMMENT ON COLUMN "organizations"."domain" IS '소속 이메일/웹 도메인. 선택 입력값';
COMMENT ON COLUMN "organizations"."is_active" IS '소속 사용 여부';
COMMENT ON COLUMN "organizations"."created_at" IS '소속 생성 일시';
COMMENT ON COLUMN "organizations"."updated_at" IS '소속 수정 일시';
COMMENT ON TABLE "user_affiliations" IS '사용자와 소속의 연결 정보. 사용자 1명은 여러 소속을 가질 수 있다.';
COMMENT ON COLUMN "user_affiliations"."id" IS '사용자 소속 연결 고유 ID';
COMMENT ON COLUMN "user_affiliations"."user_id" IS '사용자 ID';
COMMENT ON COLUMN "user_affiliations"."organization_id" IS '소속 ID';
COMMENT ON COLUMN "user_affiliations"."department" IS '부서, 학과, 조직 단위명';
COMMENT ON COLUMN "user_affiliations"."position" IS '직책, 직급, 역할명';
COMMENT ON COLUMN "user_affiliations"."is_primary" IS '대표 소속 여부';
COMMENT ON COLUMN "user_affiliations"."starts_at" IS '소속 시작 일시';
COMMENT ON COLUMN "user_affiliations"."ends_at" IS '소속 종료 일시. NULL이면 현재 유효한 소속';
COMMENT ON COLUMN "user_affiliations"."created_at" IS '사용자 소속 연결 생성 일시';
COMMENT ON COLUMN "user_affiliations"."updated_at" IS '사용자 소속 연결 수정 일시';
@@ -0,0 +1,41 @@
COMMENT ON TYPE "UserRole" IS 'auth 사용자 권한. 관리자와 일반 사용자를 구분한다.';
COMMENT ON TABLE "users" IS '중앙 인증 사용자 계정';
COMMENT ON COLUMN "users"."id" IS '사용자 고유 ID';
COMMENT ON COLUMN "users"."email" IS '로그인 이메일';
COMMENT ON COLUMN "users"."password_hash" IS '해시 처리된 비밀번호';
COMMENT ON COLUMN "users"."name" IS '사용자 이름';
COMMENT ON COLUMN "users"."role" IS '사용자 권한';
COMMENT ON COLUMN "users"."is_active" IS '계정 활성 여부';
COMMENT ON COLUMN "users"."last_login_at" IS '마지막 로그인 일시';
COMMENT ON COLUMN "users"."created_at" IS '계정 생성 일시';
COMMENT ON COLUMN "users"."updated_at" IS '계정 수정 일시';
COMMENT ON TABLE "refresh_tokens" IS '로그인 세션 유지를 위한 refresh token 저장소';
COMMENT ON COLUMN "refresh_tokens"."id" IS 'refresh token 레코드 고유 ID';
COMMENT ON COLUMN "refresh_tokens"."user_id" IS '사용자 ID';
COMMENT ON COLUMN "refresh_tokens"."token_hash" IS 'refresh token 해시값';
COMMENT ON COLUMN "refresh_tokens"."expires_at" IS 'refresh token 만료 일시';
COMMENT ON COLUMN "refresh_tokens"."revoked_at" IS 'refresh token 폐기 일시';
COMMENT ON COLUMN "refresh_tokens"."created_at" IS 'refresh token 생성 일시';
COMMENT ON TABLE "handoff_tokens" IS '런처에서 개별 앱으로 인증 상태를 전달하기 위한 일회성 토큰';
COMMENT ON COLUMN "handoff_tokens"."id" IS 'handoff token 레코드 고유 ID';
COMMENT ON COLUMN "handoff_tokens"."user_id" IS '사용자 ID';
COMMENT ON COLUMN "handoff_tokens"."token_hash" IS 'handoff token 해시값';
COMMENT ON COLUMN "handoff_tokens"."audience_app" IS '토큰을 사용할 대상 앱 식별자';
COMMENT ON COLUMN "handoff_tokens"."expires_at" IS 'handoff token 만료 일시';
COMMENT ON COLUMN "handoff_tokens"."used_at" IS 'handoff token 사용 일시';
COMMENT ON COLUMN "handoff_tokens"."created_at" IS 'handoff token 생성 일시';
COMMENT ON TABLE "login_notices" IS '런처 로그인 화면에 노출할 공지사항';
COMMENT ON COLUMN "login_notices"."id" IS '로그인 공지 고유 ID';
COMMENT ON COLUMN "login_notices"."title" IS '공지 제목';
COMMENT ON COLUMN "login_notices"."body" IS '공지 본문';
COMMENT ON COLUMN "login_notices"."link_url" IS '자세히보기 링크 URL';
COMMENT ON COLUMN "login_notices"."is_active" IS '공지 활성 여부';
COMMENT ON COLUMN "login_notices"."starts_at" IS '공지 노출 시작 일시';
COMMENT ON COLUMN "login_notices"."ends_at" IS '공지 노출 종료 일시';
COMMENT ON COLUMN "login_notices"."priority" IS '공지 우선순위. 값이 높을수록 우선 노출';
COMMENT ON COLUMN "login_notices"."created_at" IS '공지 생성 일시';
COMMENT ON COLUMN "login_notices"."updated_at" IS '공지 수정 일시';
@@ -0,0 +1,56 @@
INSERT INTO "organizations" ("id", "name", "type", "domain", "is_active", "created_at", "updated_at")
VALUES
('11111111-1111-4111-8111-111111111111', '퓨처드라이브', 'COMPANY', 'futuredrive.net', true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('22222222-2222-4222-8222-222222222222', '영남대학교', 'SCHOOL', 'yu.ac.kr', true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT ("name", "type") DO UPDATE
SET
"domain" = EXCLUDED."domain",
"is_active" = true,
"updated_at" = CURRENT_TIMESTAMP;
UPDATE "user_affiliations"
SET "is_primary" = false,
"updated_at" = CURRENT_TIMESTAMP
WHERE "ends_at" IS NULL;
UPDATE "user_affiliations"
SET "is_primary" = true,
"updated_at" = CURRENT_TIMESTAMP
WHERE "organization_id" = '11111111-1111-4111-8111-111111111111'
AND "ends_at" IS NULL;
INSERT INTO "user_affiliations" (
"id",
"user_id",
"organization_id",
"department",
"position",
"is_primary",
"starts_at",
"created_at",
"updated_at"
)
SELECT
lower(
substr(md5("users"."id" || ':futuredrive'), 1, 8) || '-' ||
substr(md5("users"."id" || ':futuredrive'), 9, 4) || '-4' ||
substr(md5("users"."id" || ':futuredrive'), 14, 3) || '-8' ||
substr(md5("users"."id" || ':futuredrive'), 18, 3) || '-' ||
substr(md5("users"."id" || ':futuredrive'), 21, 12)
),
"users"."id",
'11111111-1111-4111-8111-111111111111',
NULL,
NULL,
true,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM "users"
WHERE NOT EXISTS (
SELECT 1
FROM "user_affiliations"
WHERE "user_affiliations"."user_id" = "users"."id"
AND "user_affiliations"."organization_id" = '11111111-1111-4111-8111-111111111111'
AND "user_affiliations"."ends_at" IS NULL
);
@@ -0,0 +1,9 @@
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
UPDATE "organizations"
SET "id" = gen_random_uuid()::text,
"updated_at" = CURRENT_TIMESTAMP
WHERE "id" IN (
'11111111-1111-4111-8111-111111111111',
'22222222-2222-4222-8222-222222222222'
);
@@ -0,0 +1,57 @@
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
UPDATE "users"
SET "id" = gen_random_uuid()::text
WHERE "id" !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$';
UPDATE "organizations"
SET "id" = gen_random_uuid()::text
WHERE "id" !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$';
UPDATE "refresh_tokens"
SET "id" = gen_random_uuid()::text
WHERE "id" !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$';
UPDATE "handoff_tokens"
SET "id" = gen_random_uuid()::text
WHERE "id" !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$';
UPDATE "user_affiliations"
SET "id" = gen_random_uuid()::text
WHERE "id" !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$';
UPDATE "login_notices"
SET "id" = gen_random_uuid()::text
WHERE "id" !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$';
ALTER TABLE "refresh_tokens" DROP CONSTRAINT "refresh_tokens_user_id_fkey";
ALTER TABLE "handoff_tokens" DROP CONSTRAINT "handoff_tokens_user_id_fkey";
ALTER TABLE "user_affiliations" DROP CONSTRAINT "user_affiliations_user_id_fkey";
ALTER TABLE "user_affiliations" DROP CONSTRAINT "user_affiliations_organization_id_fkey";
ALTER TABLE "users"
ALTER COLUMN "id" TYPE uuid USING "id"::uuid;
ALTER TABLE "organizations"
ALTER COLUMN "id" TYPE uuid USING "id"::uuid;
ALTER TABLE "refresh_tokens"
ALTER COLUMN "id" TYPE uuid USING "id"::uuid,
ALTER COLUMN "user_id" TYPE uuid USING "user_id"::uuid;
ALTER TABLE "handoff_tokens"
ALTER COLUMN "id" TYPE uuid USING "id"::uuid,
ALTER COLUMN "user_id" TYPE uuid USING "user_id"::uuid;
ALTER TABLE "user_affiliations"
ALTER COLUMN "id" TYPE uuid USING "id"::uuid,
ALTER COLUMN "user_id" TYPE uuid USING "user_id"::uuid,
ALTER COLUMN "organization_id" TYPE uuid USING "organization_id"::uuid;
ALTER TABLE "login_notices"
ALTER COLUMN "id" TYPE uuid USING "id"::uuid;
ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "handoff_tokens" ADD CONSTRAINT "handoff_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "user_affiliations" ADD CONSTRAINT "user_affiliations_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "user_affiliations" ADD CONSTRAINT "user_affiliations_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,68 @@
CREATE TYPE "LicenseCode" AS ENUM ('FREE', 'EDUCATION', 'PROFESSIONAL');
CREATE TABLE "licenses" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"code" "LicenseCode" NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "licenses_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "user_licenses" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"user_id" UUID NOT NULL,
"license_id" UUID NOT NULL,
"starts_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" TIMESTAMP(3),
"assigned_by" UUID,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "user_licenses_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "licenses_code_key" ON "licenses"("code");
CREATE INDEX "licenses_is_active_idx" ON "licenses"("is_active");
CREATE INDEX "user_licenses_user_id_starts_at_idx" ON "user_licenses"("user_id", "starts_at");
CREATE INDEX "user_licenses_license_id_idx" ON "user_licenses"("license_id");
ALTER TABLE "user_licenses" ADD CONSTRAINT "user_licenses_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "user_licenses" ADD CONSTRAINT "user_licenses_license_id_fkey" FOREIGN KEY ("license_id") REFERENCES "licenses"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
INSERT INTO "licenses" ("code", "name", "description", "updated_at")
VALUES
('FREE', 'Free', '기본 무료 라이선스', CURRENT_TIMESTAMP),
('EDUCATION', 'Education', '교육기관용 라이선스', CURRENT_TIMESTAMP),
('PROFESSIONAL', 'Professional', '전문가용 라이선스', CURRENT_TIMESTAMP);
INSERT INTO "user_licenses" ("user_id", "license_id", "starts_at")
SELECT u."id", l."id", CURRENT_TIMESTAMP
FROM "users" u
CROSS JOIN "licenses" l
WHERE l."code" = 'PROFESSIONAL'
AND NOT EXISTS (
SELECT 1
FROM "user_licenses" ul
WHERE ul."user_id" = u."id"
);
COMMENT ON TYPE "LicenseCode" IS '라이선스 코드. 무료, 교육용, 전문가용 플랜을 구분한다.';
COMMENT ON TABLE "licenses" IS 'Autonomy Studio 라이선스 마스터. 플랜 코드와 표시명을 관리한다.';
COMMENT ON COLUMN "licenses"."id" IS '라이선스 UUID.';
COMMENT ON COLUMN "licenses"."code" IS '라이선스 코드.';
COMMENT ON COLUMN "licenses"."name" IS '화면에 표시할 라이선스명.';
COMMENT ON COLUMN "licenses"."description" IS '라이선스 설명.';
COMMENT ON COLUMN "licenses"."is_active" IS '라이선스 사용 가능 여부.';
COMMENT ON COLUMN "licenses"."created_at" IS '라이선스 생성 시각.';
COMMENT ON COLUMN "licenses"."updated_at" IS '라이선스 수정 시각.';
COMMENT ON TABLE "user_licenses" IS '사용자별 라이선스 부여 이력. 현재 유효 라이선스와 기간을 관리한다.';
COMMENT ON COLUMN "user_licenses"."id" IS '사용자 라이선스 UUID.';
COMMENT ON COLUMN "user_licenses"."user_id" IS '라이선스를 부여받은 사용자 UUID.';
COMMENT ON COLUMN "user_licenses"."license_id" IS '부여된 라이선스 UUID.';
COMMENT ON COLUMN "user_licenses"."starts_at" IS '라이선스 시작 시각.';
COMMENT ON COLUMN "user_licenses"."expires_at" IS '라이선스 만료 시각. NULL이면 무기한.';
COMMENT ON COLUMN "user_licenses"."assigned_by" IS '라이선스를 부여한 관리자 UUID.';
COMMENT ON COLUMN "user_licenses"."created_at" IS '라이선스 부여 이력 생성 시각.';
@@ -0,0 +1,8 @@
ALTER TABLE "users"
ADD COLUMN "profile_image_data" BYTEA,
ADD COLUMN "profile_image_mime" TEXT,
ADD COLUMN "profile_image_updated_at" TIMESTAMP(3);
COMMENT ON COLUMN "users"."profile_image_data" IS '사용자 프로필 썸네일 이미지 바이너리. 128px WebP 압축본만 저장한다.';
COMMENT ON COLUMN "users"."profile_image_mime" IS '프로필 썸네일 MIME 타입.';
COMMENT ON COLUMN "users"."profile_image_updated_at" IS '프로필 썸네일 마지막 수정 시각.';
@@ -0,0 +1,19 @@
DO $$
DECLARE
column_record record;
BEGIN
FOR column_record IN
SELECT table_schema, table_name, column_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND data_type = 'timestamp without time zone'
LOOP
EXECUTE format(
'ALTER TABLE %I.%I ALTER COLUMN %I TYPE timestamptz USING %I AT TIME ZONE ''UTC''',
column_record.table_schema,
column_record.table_name,
column_record.column_name,
column_record.column_name
);
END LOOP;
END $$;
@@ -0,0 +1,12 @@
ALTER TABLE "licenses"
ADD COLUMN "default_duration_days" INTEGER DEFAULT 365;
UPDATE "licenses"
SET "default_duration_days" = 365
WHERE "default_duration_days" IS NULL;
UPDATE "user_licenses"
SET "expires_at" = "starts_at" + INTERVAL '365 days'
WHERE "expires_at" IS NULL;
COMMENT ON COLUMN "licenses"."default_duration_days" IS '사용자에게 라이선스를 부여할 때 기본 적용할 기간(일). NULL이면 무기한.';
@@ -0,0 +1,40 @@
CREATE TABLE "user_app_roles" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"user_id" uuid NOT NULL,
"app_key" text NOT NULL,
"role_code" text NOT NULL,
"assigned_by" uuid,
"assigned_at" timestamptz(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"starts_at" timestamptz(3),
"ends_at" timestamptz(3),
"created_at" timestamptz(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "user_app_roles_pkey" PRIMARY KEY ("id"),
CONSTRAINT "user_app_roles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "user_app_roles_user_id_app_key_role_code_key" ON "user_app_roles"("user_id", "app_key", "role_code");
CREATE INDEX "user_app_roles_app_key_role_code_idx" ON "user_app_roles"("app_key", "role_code");
CREATE INDEX "user_app_roles_user_id_app_key_idx" ON "user_app_roles"("user_id", "app_key");
COMMENT ON TABLE "user_app_roles" IS '사용자별 앱 역할. 전역 계정 역할과 별도로 LMS 강사, 학생, ADS IDE 개발자 같은 앱 단위 역할을 관리한다.';
COMMENT ON COLUMN "user_app_roles"."id" IS '앱 역할 매핑 UUID';
COMMENT ON COLUMN "user_app_roles"."user_id" IS '역할을 부여받은 auth 사용자 ID';
COMMENT ON COLUMN "user_app_roles"."app_key" IS '역할이 적용되는 앱 식별자. 예: LMS, ADS IDE';
COMMENT ON COLUMN "user_app_roles"."role_code" IS '앱 내부 역할 코드. 예: INSTRUCTOR, STUDENT, DEVELOPER';
COMMENT ON COLUMN "user_app_roles"."assigned_by" IS '역할을 부여한 관리자 사용자 ID';
COMMENT ON COLUMN "user_app_roles"."assigned_at" IS '역할 부여 일시';
COMMENT ON COLUMN "user_app_roles"."starts_at" IS '역할 유효 시작 일시. NULL이면 즉시 유효';
COMMENT ON COLUMN "user_app_roles"."ends_at" IS '역할 유효 종료 일시. NULL이면 무기한';
COMMENT ON COLUMN "user_app_roles"."created_at" IS '생성 일시';
COMMENT ON COLUMN "user_app_roles"."updated_at" IS '수정 일시';
INSERT INTO "user_app_roles" ("user_id", "app_key", "role_code")
SELECT "id", 'LMS', 'INSTRUCTOR'
FROM "users"
WHERE "email" IN ('teacher@futuredrive.net', 'teacher@yu.ac.kr')
ON CONFLICT ("user_id", "app_key", "role_code") DO NOTHING;
UPDATE "users"
SET "role" = 'USER'
WHERE "email" IN ('teacher@futuredrive.net', 'teacher@yu.ac.kr');
@@ -0,0 +1,2 @@
ALTER TYPE "LicenseCode" RENAME VALUE 'EDUCATION' TO 'EDU';
ALTER TYPE "LicenseCode" RENAME VALUE 'PROFESSIONAL' TO 'PRO';
+1
View File
@@ -0,0 +1 @@
provider = "postgresql"
+176
View File
@@ -0,0 +1,176 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum UserRole {
ADMIN
USER
}
enum OrganizationType {
COMPANY
SCHOOL
GOVERNMENT
LAB
OTHER
}
enum LicenseCode {
FREE
EDU
PRO
}
model User {
id String @id @default(uuid()) @db.Uuid
email String @unique
passwordHash String @map("password_hash")
name String?
role UserRole @default(USER)
isActive Boolean @default(true) @map("is_active")
lastLoginAt DateTime? @map("last_login_at") @db.Timestamptz(3)
profileImageData Bytes? @map("profile_image_data")
profileImageMime String? @map("profile_image_mime")
profileImageUpdatedAt DateTime? @map("profile_image_updated_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
refreshTokens RefreshToken[]
handoffTokens HandoffToken[]
affiliations UserAffiliation[]
userLicenses UserLicense[]
appRoles UserAppRole[]
@@map("users")
}
model UserAppRole {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
appKey String @map("app_key")
roleCode String @map("role_code")
assignedById String? @map("assigned_by") @db.Uuid
assignedAt DateTime @default(now()) @map("assigned_at") @db.Timestamptz(3)
startsAt DateTime? @map("starts_at") @db.Timestamptz(3)
endsAt DateTime? @map("ends_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, appKey, roleCode])
@@index([appKey, roleCode])
@@index([userId, appKey])
@@map("user_app_roles")
}
model License {
id String @id @default(uuid()) @db.Uuid
code LicenseCode @unique
name String
description String?
defaultDurationDays Int? @default(365) @map("default_duration_days")
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)
userLicenses UserLicense[]
@@index([isActive])
@@map("licenses")
}
model UserLicense {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
licenseId String @map("license_id") @db.Uuid
startsAt DateTime @default(now()) @map("starts_at") @db.Timestamptz(3)
expiresAt DateTime? @map("expires_at") @db.Timestamptz(3)
assignedById String? @map("assigned_by") @db.Uuid
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
license License @relation(fields: [licenseId], references: [id], onDelete: Restrict)
@@index([userId, startsAt])
@@index([licenseId])
@@map("user_licenses")
}
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[]
@@unique([name, type])
@@index([isActive, type])
@@map("organizations")
}
model UserAffiliation {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
organizationId String @map("organization_id") @db.Uuid
department String?
position String?
isPrimary Boolean @default(false) @map("is_primary")
startsAt DateTime? @map("starts_at") @db.Timestamptz(3)
endsAt DateTime? @map("ends_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict)
@@index([userId, isPrimary])
@@index([organizationId])
@@map("user_affiliations")
}
model RefreshToken {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
tokenHash String @unique @map("token_hash")
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
revokedAt DateTime? @map("revoked_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("refresh_tokens")
}
model HandoffToken {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
tokenHash String @unique @map("token_hash")
audienceApp String @map("audience_app")
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
usedAt DateTime? @map("used_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("handoff_tokens")
}
model LoginNotice {
id String @id @default(uuid()) @db.Uuid
title String
body String
linkUrl String? @map("link_url")
isActive Boolean @default(true) @map("is_active")
startsAt DateTime? @map("starts_at") @db.Timestamptz(3)
endsAt DateTime? @map("ends_at") @db.Timestamptz(3)
priority Int @default(0)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
@@index([isActive, priority])
@@map("login_notices")
}
+137
View File
@@ -0,0 +1,137 @@
import { LicenseCode, PrismaClient, UserRole } from '@prisma/client'
import { config } from '../src/config'
import { hashPassword } from '../src/security/password'
const prisma = new PrismaClient()
async function main(): Promise<void> {
const professionalLicense = await prisma.license.upsert({
where: { code: LicenseCode.PRO },
create: {
code: LicenseCode.PRO,
name: 'Professional',
description: '전문가용 라이선스',
defaultDurationDays: 365
},
update: {
name: 'Professional',
description: '전문가용 라이선스',
defaultDurationDays: 365,
isActive: true
}
})
await prisma.license.upsert({
where: { code: LicenseCode.FREE },
create: {
code: LicenseCode.FREE,
name: 'Free',
description: '기본 무료 라이선스',
defaultDurationDays: 365
},
update: {
name: 'Free',
description: '기본 무료 라이선스',
defaultDurationDays: 365,
isActive: true
}
})
await prisma.license.upsert({
where: { code: LicenseCode.EDU },
create: {
code: LicenseCode.EDU,
name: 'Education',
description: '교육기관용 라이선스',
defaultDurationDays: 365
},
update: {
name: 'Education',
description: '교육기관용 라이선스',
defaultDurationDays: 365,
isActive: true
}
})
const admin = await prisma.user.upsert({
where: { email: config.seedAdminEmail.toLowerCase() },
create: {
email: config.seedAdminEmail.toLowerCase(),
name: 'ATNMY Studio Admin',
role: UserRole.ADMIN,
passwordHash: await hashPassword(config.seedAdminPassword)
},
update: {
name: 'ATNMY Studio Admin',
role: UserRole.ADMIN,
isActive: true
}
})
const existingAdminLicense = await prisma.userLicense.findFirst({
where: {
userId: admin.id,
expiresAt: null
}
})
if (!existingAdminLicense) {
await prisma.userLicense.create({
data: {
userId: admin.id,
licenseId: professionalLicense.id
}
})
}
await prisma.user.updateMany({
where: {
email: {
in: ['teacher@futuredrive.net', 'teacher@yu.ac.kr']
}
},
data: {
role: UserRole.USER
}
})
const instructors = await prisma.user.findMany({
where: {
email: {
in: ['teacher@futuredrive.net', 'teacher@yu.ac.kr']
}
},
select: { id: true }
})
for (const instructor of instructors) {
await prisma.userAppRole.upsert({
where: {
userId_appKey_roleCode: {
userId: instructor.id,
appKey: 'LMS',
roleCode: 'INSTRUCTOR'
}
},
create: {
userId: instructor.id,
appKey: 'LMS',
roleCode: 'INSTRUCTOR'
},
update: {
endsAt: null
}
})
}
console.log(`Seeded admin user: ${config.seedAdminEmail}`)
}
main()
.catch((error) => {
console.error(error)
process.exitCode = 1
})
.finally(async () => {
await prisma.$disconnect()
})