init studio auth backend
This commit is contained in:
@@ -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';
|
||||
@@ -0,0 +1 @@
|
||||
provider = "postgresql"
|
||||
@@ -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
@@ -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()
|
||||
})
|
||||
Reference in New Issue
Block a user