Sync LMS backend source
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
.git
|
||||
.env
|
||||
npm-debug.log*
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
README.md
|
||||
@@ -0,0 +1,10 @@
|
||||
NODE_ENV=development
|
||||
PORT=4400
|
||||
CORS_ORIGIN=http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176
|
||||
DATABASE_URL=postgresql://lms:future1234!%40@localhost:5432/lms?schema=public
|
||||
AUTH_SERVER_URL=http://localhost:4100
|
||||
GITEA_BASE_URL=http://localhost:3000
|
||||
GITEA_ADMIN_TOKEN=
|
||||
GITEA_STUDENT_OWNER=student
|
||||
PLATFORM_GATEWAY_URL=http://localhost:4500
|
||||
PLATFORM_GATEWAY_TOKEN=change-me
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
RUN npm prune --omit=dev
|
||||
|
||||
FROM node:22-alpine AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY --from=build /app/prisma ./prisma
|
||||
COPY package*.json ./
|
||||
EXPOSE 4400
|
||||
CMD ["node", "dist/main.js"]
|
||||
@@ -0,0 +1,40 @@
|
||||
# lms-be
|
||||
|
||||
Nest.js backend API for the LMS Electron frontend.
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env
|
||||
npm run prisma:generate
|
||||
npm run start:dev
|
||||
```
|
||||
|
||||
The API listens on `http://localhost:4400`.
|
||||
|
||||
- `GET /health`
|
||||
- `GET /health/db`
|
||||
- `GET /api`
|
||||
|
||||
## Database
|
||||
|
||||
Local PostgreSQL is included in Docker Compose.
|
||||
|
||||
```bash
|
||||
docker compose up -d postgres
|
||||
npm run prisma:migrate
|
||||
npm run start:dev
|
||||
```
|
||||
|
||||
For a separated database server, set `DATABASE_URL` to the remote PostgreSQL host:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgresql://USER:PASSWORD@DB_HOST:5432/DB_NAME?schema=public
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
@@ -0,0 +1,46 @@
|
||||
services:
|
||||
lms-be:
|
||||
build:
|
||||
context: .
|
||||
container_name: lms-be
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 4400
|
||||
CORS_ORIGIN: ${CORS_ORIGIN:-*}
|
||||
DATABASE_URL: ${DATABASE_URL:-postgresql://lms:future1234!%40@postgres:5432/lms?schema=public}
|
||||
AUTH_SERVER_URL: ${AUTH_SERVER_URL:-http://host.docker.internal:4100}
|
||||
ports:
|
||||
- "${PORT:-4400}:4400"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4400/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
container_name: lms-postgres
|
||||
command: postgres -c timezone=Asia/Seoul
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-lms}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-lms}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-future1234!@}
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-lms} -d ${POSTGRES_DB:-lms}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
@@ -0,0 +1,33 @@
|
||||
import eslint from '@eslint/js';
|
||||
import prettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['eslint.config.mjs', 'dist/**', 'node_modules/**', 'coverage/**'],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
prettierRecommended,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
},
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.spec.ts', 'test/**/*.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-unsafe-argument': 'off',
|
||||
'@typescript-eslint/no-unsafe-call': 'off',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'off',
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
Generated
+10686
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"name": "lms-be",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Backend API for LMS",
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "prisma generate && nest build",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:prod": "node dist/main.js",
|
||||
"lint": "eslint \"{src,test}/**/*.ts\" --fix",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:deploy": "prisma migrate deploy",
|
||||
"prisma:studio": "prisma studio",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.1.24",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.1.24",
|
||||
"@nestjs/platform-express": "^11.1.24",
|
||||
"@nestjs/platform-socket.io": "^11.1.24",
|
||||
"@nestjs/websockets": "^11.1.24",
|
||||
"@prisma/client": "^6.19.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"socket.io": "^4.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@nestjs/cli": "^11.0.21",
|
||||
"@nestjs/schematics": "^11.1.0",
|
||||
"@nestjs/testing": "^11.1.24",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"globals": "^17.6.0",
|
||||
"jest": "^30.4.2",
|
||||
"prettier": "^3.8.3",
|
||||
"prisma": "^6.19.3",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.2.2",
|
||||
"ts-jest": "^29.4.11",
|
||||
"ts-loader": "^9.6.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.60.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": ".",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"src/**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||
@@ -0,0 +1,22 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "lms_notifications" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"category" TEXT NOT NULL DEFAULT 'notice',
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "lms_notifications_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "lms_notifications_is_active_created_at_idx" ON "lms_notifications"("is_active", "created_at");
|
||||
|
||||
-- Seed
|
||||
INSERT INTO "lms_notifications" ("id", "title", "body", "category", "created_at", "updated_at")
|
||||
VALUES
|
||||
('lms-notification-course-review', '신규 과제 검토 요청', '자율주행 인지 실습 과정에 제출된 과제 3건의 검토가 필요합니다.', 'assignment', '2026-06-02T00:00:00.000Z', CURRENT_TIMESTAMP),
|
||||
('lms-notification-live-session', '라이브 세션 일정 안내', '오늘 15:00에 SLAM 기초 라이브 세션이 예정되어 있습니다.', 'schedule', '2026-06-01T00:00:00.000Z', CURRENT_TIMESTAMP),
|
||||
('lms-notification-system-maintenance', '시스템 점검 안내', '6월 5일 02:00부터 03:00까지 LMS 점검이 진행됩니다.', 'system', '2026-05-31T00:00:00.000Z', CURRENT_TIMESTAMP);
|
||||
@@ -0,0 +1,21 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "lms_notifications" ADD COLUMN "audience" TEXT NOT NULL DEFAULT 'all';
|
||||
|
||||
-- Existing admin messages are instructor-facing.
|
||||
UPDATE "lms_notifications"
|
||||
SET "audience" = 'instructor'
|
||||
WHERE "id" IN (
|
||||
'lms-notification-course-review',
|
||||
'lms-notification-live-session',
|
||||
'lms-notification-system-maintenance'
|
||||
);
|
||||
|
||||
-- Seed student messages.
|
||||
INSERT INTO "lms_notifications" ("id", "title", "body", "category", "audience", "created_at", "updated_at")
|
||||
VALUES
|
||||
('lms-student-notification-assignment-due', '과제 제출 마감 안내', '인지 실습 리포트 제출 마감이 오늘 18:00까지입니다.', 'assignment', 'student', '2026-06-02T00:00:00.000Z', CURRENT_TIMESTAMP),
|
||||
('lms-student-notification-new-lesson', '신규 강의 공개', '자율주행 센서 융합 기초 강의가 새로 공개되었습니다.', 'course', 'student', '2026-06-01T00:00:00.000Z', CURRENT_TIMESTAMP),
|
||||
('lms-student-notification-session', '실습 세션 안내', '내일 10:00에 ROS2 실습 세션이 예정되어 있습니다.', 'schedule', 'student', '2026-05-31T00:00:00.000Z', CURRENT_TIMESTAMP);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "lms_notifications_audience_is_active_created_at_idx" ON "lms_notifications"("audience", "is_active", "created_at");
|
||||
@@ -0,0 +1,16 @@
|
||||
COMMENT ON TABLE "users" IS 'LMS 내부 사용자 기본 정보';
|
||||
COMMENT ON COLUMN "users"."id" IS 'LMS 사용자 고유 ID';
|
||||
COMMENT ON COLUMN "users"."email" IS '사용자 이메일';
|
||||
COMMENT ON COLUMN "users"."name" IS '사용자 이름';
|
||||
COMMENT ON COLUMN "users"."createdAt" IS '사용자 생성 일시';
|
||||
COMMENT ON COLUMN "users"."updatedAt" IS '사용자 수정 일시';
|
||||
|
||||
COMMENT ON TABLE "lms_notifications" IS 'LMS 학생/강사/전체 대상 알림 메시지';
|
||||
COMMENT ON COLUMN "lms_notifications"."id" IS 'LMS 알림 고유 ID';
|
||||
COMMENT ON COLUMN "lms_notifications"."title" IS '알림 제목';
|
||||
COMMENT ON COLUMN "lms_notifications"."body" IS '알림 본문';
|
||||
COMMENT ON COLUMN "lms_notifications"."category" IS '알림 분류';
|
||||
COMMENT ON COLUMN "lms_notifications"."audience" IS '알림 대상. student, instructor, all 등을 사용';
|
||||
COMMENT ON COLUMN "lms_notifications"."is_active" IS '알림 활성 여부';
|
||||
COMMENT ON COLUMN "lms_notifications"."created_at" IS '알림 생성 일시';
|
||||
COMMENT ON COLUMN "lms_notifications"."updated_at" IS '알림 수정 일시';
|
||||
@@ -0,0 +1,15 @@
|
||||
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 "lms_notifications"
|
||||
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 "users"
|
||||
ALTER COLUMN "id" TYPE uuid USING "id"::uuid;
|
||||
|
||||
ALTER TABLE "lms_notifications"
|
||||
ALTER COLUMN "id" TYPE uuid USING "id"::uuid;
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN "auth_user_id" uuid;
|
||||
|
||||
CREATE UNIQUE INDEX "users_auth_user_id_key" ON "users"("auth_user_id");
|
||||
|
||||
COMMENT ON COLUMN "users"."auth_user_id" IS '중앙 auth DB users.id와 매핑되는 사용자 ID';
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS "lms_notifications";
|
||||
@@ -0,0 +1,177 @@
|
||||
CREATE TABLE "courses" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"slug" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"subtitle" TEXT,
|
||||
"description" TEXT,
|
||||
"organization_name" TEXT NOT NULL,
|
||||
"teacher_id" UUID NOT NULL,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "courses_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "course_weeks" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"course_id" UUID NOT NULL,
|
||||
"week" INTEGER NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"tags" TEXT[] NOT NULL,
|
||||
"progress" INTEGER NOT NULL DEFAULT 0,
|
||||
"state" TEXT NOT NULL,
|
||||
"duration" TEXT NOT NULL,
|
||||
"github" TEXT NOT NULL,
|
||||
"overview" TEXT NOT NULL,
|
||||
"deliverable" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "course_weeks_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "course_week_topics" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"week_id" UUID NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"detail" TEXT NOT NULL,
|
||||
"sort_order" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "course_week_topics_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "course_week_practices" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"week_id" UUID NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"sort_order" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "course_week_practices_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "courses_slug_key" ON "courses"("slug");
|
||||
CREATE INDEX "courses_is_active_idx" ON "courses"("is_active");
|
||||
CREATE INDEX "courses_teacher_id_idx" ON "courses"("teacher_id");
|
||||
CREATE UNIQUE INDEX "course_weeks_course_id_week_key" ON "course_weeks"("course_id", "week");
|
||||
CREATE INDEX "course_weeks_course_id_idx" ON "course_weeks"("course_id");
|
||||
CREATE UNIQUE INDEX "course_week_topics_week_id_sort_order_key" ON "course_week_topics"("week_id", "sort_order");
|
||||
CREATE INDEX "course_week_topics_week_id_idx" ON "course_week_topics"("week_id");
|
||||
CREATE UNIQUE INDEX "course_week_practices_week_id_sort_order_key" ON "course_week_practices"("week_id", "sort_order");
|
||||
CREATE INDEX "course_week_practices_week_id_idx" ON "course_week_practices"("week_id");
|
||||
|
||||
ALTER TABLE "courses" ADD CONSTRAINT "courses_teacher_id_fkey" FOREIGN KEY ("teacher_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "course_weeks" ADD CONSTRAINT "course_weeks_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "course_week_topics" ADD CONSTRAINT "course_week_topics_week_id_fkey" FOREIGN KEY ("week_id") REFERENCES "course_weeks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "course_week_practices" ADD CONSTRAINT "course_week_practices_week_id_fkey" FOREIGN KEY ("week_id") REFERENCES "course_weeks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
COMMENT ON TABLE "courses" IS 'LMS 강의 마스터. 소속과 강사 기준으로 전체 강의 정보를 관리한다.';
|
||||
COMMENT ON TABLE "course_weeks" IS '강의별 주차 커리큘럼 정보.';
|
||||
COMMENT ON TABLE "course_week_topics" IS '주차별 학습 주제와 상세 설명.';
|
||||
COMMENT ON TABLE "course_week_practices" IS '주차별 실습 내용.';
|
||||
COMMENT ON COLUMN "courses"."organization_name" IS '강의를 등록한 소속명. 현재 퓨처드라이브 기준.';
|
||||
COMMENT ON COLUMN "courses"."teacher_id" IS '강의를 등록한 강사 사용자 UUID.';
|
||||
|
||||
INSERT INTO "users" ("id", "email", "name", "createdAt", "updatedAt")
|
||||
VALUES (gen_random_uuid(), 'teacher@futuredrive.net', '퓨처드라이브 강사', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT ("email") DO UPDATE
|
||||
SET "name" = EXCLUDED."name",
|
||||
"updatedAt" = CURRENT_TIMESTAMP;
|
||||
|
||||
WITH teacher AS (
|
||||
SELECT "id" FROM "users" WHERE "email" = 'teacher@futuredrive.net'
|
||||
)
|
||||
INSERT INTO "courses" ("slug", "title", "subtitle", "description", "organization_name", "teacher_id", "updated_at")
|
||||
SELECT
|
||||
'futuredrive-autonomy-basic',
|
||||
'단계별 자율주행 알고리즘 학습 과정',
|
||||
'주차별 커리큘럼',
|
||||
'ROS2, 센서 처리, SLAM, 객체 인식, 경로 계획까지 자율주행 알고리즘 개발 흐름을 주차별로 학습합니다.',
|
||||
'퓨처드라이브',
|
||||
teacher."id",
|
||||
CURRENT_TIMESTAMP
|
||||
FROM teacher
|
||||
ON CONFLICT ("slug") DO UPDATE
|
||||
SET "title" = EXCLUDED."title",
|
||||
"subtitle" = EXCLUDED."subtitle",
|
||||
"description" = EXCLUDED."description",
|
||||
"organization_name" = EXCLUDED."organization_name",
|
||||
"teacher_id" = EXCLUDED."teacher_id",
|
||||
"is_active" = true,
|
||||
"updated_at" = CURRENT_TIMESTAMP;
|
||||
|
||||
WITH course AS (
|
||||
SELECT "id" FROM "courses" WHERE "slug" = 'futuredrive-autonomy-basic'
|
||||
)
|
||||
INSERT INTO "course_weeks" ("course_id", "week", "title", "tags", "progress", "state", "duration", "github", "overview", "deliverable", "updated_at")
|
||||
SELECT course."id", data."week", data."title", data."tags", data."progress", data."state", data."duration", data."github", data."overview", data."deliverable", CURRENT_TIMESTAMP
|
||||
FROM course
|
||||
CROSS JOIN (
|
||||
VALUES
|
||||
(1, 'ROS2 기초 & 개발 환경 설정', ARRAY['시뮬레이션','완료']::TEXT[], 100, 'done', '8-10시간', '완료', 'ROS2 패키지 구조와 노드 실행 흐름을 이해하고, 실습 환경에서 기본 통신 구조를 직접 구성합니다.', '실습 노드 코드, 실행 로그, README 정리본을 GitHub에 제출'),
|
||||
(2, '센서 데이터 수집 및 처리', ARRAY['MK-mini','완료']::TEXT[], 100, 'done', '8-10시간', '완료', '자율주행 시스템에서 사용하는 LiDAR와 카메라 데이터를 수집하고, ROS2 토픽 기반으로 전처리하는 흐름을 학습합니다.', '센서 토픽 분석표, 전처리 코드, 결과 캡처 이미지를 포함한 제출물'),
|
||||
(3, 'SLAM & 지도 생성', ARRAY['MK-mini','진행중']::TEXT[], 65, 'current', '8-10시간', '미제출', 'Cartographer 기반 SLAM을 실행해 주행 환경 지도를 생성하고, 저장된 맵을 이용해 Localization 흐름을 검증합니다.', '생성된 map 파일, SLAM 실행 로그, Localization 검증 결과를 GitHub에 제출'),
|
||||
(4, '객체 인식 & 딥러닝', ARRAY['시뮬레이션','진행예정']::TEXT[], 0, 'locked', '-', '진행예정', '카메라 기반 객체 인식 모델을 실행하고, 인식 결과를 주행 판단에 연결하는 과정을 학습합니다.', '모델 실행 로그와 인식 결과 리포트 제출'),
|
||||
(5, '경로 계획 알고리즘', ARRAY['f1tenth','진행예정']::TEXT[], 0, 'locked', '-', '진행예정', '생성된 지도와 차량 상태를 기반으로 경로를 계획하고, 제어 명령으로 연결하는 전체 흐름을 정리합니다.', '경로 계획 설정값, 주행 결과 영상, 개선 사항 정리본 제출')
|
||||
) AS data("week", "title", "tags", "progress", "state", "duration", "github", "overview", "deliverable")
|
||||
ON CONFLICT ("course_id", "week") DO UPDATE
|
||||
SET "title" = EXCLUDED."title",
|
||||
"tags" = EXCLUDED."tags",
|
||||
"progress" = EXCLUDED."progress",
|
||||
"state" = EXCLUDED."state",
|
||||
"duration" = EXCLUDED."duration",
|
||||
"github" = EXCLUDED."github",
|
||||
"overview" = EXCLUDED."overview",
|
||||
"deliverable" = EXCLUDED."deliverable",
|
||||
"updated_at" = CURRENT_TIMESTAMP;
|
||||
|
||||
WITH rows AS (
|
||||
SELECT w."id" AS week_id, topic.*
|
||||
FROM "course_weeks" w
|
||||
JOIN "courses" c ON c."id" = w."course_id"
|
||||
JOIN LATERAL (
|
||||
VALUES
|
||||
(1, 1, 'ROS2 노드 생성', '패키지 생성, 노드 실행, 로그 확인까지 ROS2 개발의 기본 흐름을 실습합니다.'),
|
||||
(1, 2, '토픽 통신', 'Publisher와 Subscriber를 구성하고 메시지 타입, 주기, 토픽 이름을 확인합니다.'),
|
||||
(1, 3, '파라미터 설정', '노드 파라미터를 외부에서 변경하고 launch 파일로 실행 구성을 정리합니다.'),
|
||||
(2, 1, 'LiDAR 포인트클라우드', 'LiDAR 토픽을 확인하고 포인트클라우드 메시지 구조와 좌표 범위를 분석합니다.'),
|
||||
(2, 2, '카메라 이미지 처리', '카메라 프레임을 수신해 기본 전처리와 시각화 결과를 확인합니다.'),
|
||||
(2, 3, '센서 퓨전 기초', 'LiDAR와 카메라 데이터를 같은 주행 상황 기준으로 비교하고 센서 결합 조건을 정리합니다.'),
|
||||
(3, 1, 'Cartographer SLAM', 'Cartographer 설정을 확인하고 센서 입력 조건에 맞춰 SLAM launch를 실행합니다.'),
|
||||
(3, 2, '맵 저장/로드', '생성된 지도를 저장한 뒤 재로딩 과정에서 맵 파일과 설정값을 검증합니다.'),
|
||||
(3, 3, 'Localization', '저장된 맵 위에서 차량 위치 추정이 안정적으로 동작하는지 확인합니다.'),
|
||||
(4, 1, '객체 인식 모델 실행', '객체 인식 모델의 입력 이미지와 출력 박스 구조를 확인합니다.'),
|
||||
(4, 2, 'ROS2 메시지 변환', '검출 결과를 ROS2 메시지로 변환하고 후속 노드에서 사용할 수 있게 정리합니다.'),
|
||||
(4, 3, '인식 결과 시각화', '시뮬레이션 장면에서 인식 결과를 시각화하고 실패 케이스를 기록합니다.'),
|
||||
(5, 1, '전역 경로 생성', '지도 기반 전역 경로를 생성하고 목적지까지의 기준 경로를 확인합니다.'),
|
||||
(5, 2, '지역 경로 갱신', '주변 장애물과 차량 상태를 반영해 지역 경로를 갱신합니다.'),
|
||||
(5, 3, '제어 명령 연결', '계획된 경로를 제어 명령으로 연결하고 주행 결과를 비교합니다.')
|
||||
) AS topic(week_no, sort_order, title, detail) ON topic.week_no = w."week"
|
||||
WHERE c."slug" = 'futuredrive-autonomy-basic'
|
||||
)
|
||||
INSERT INTO "course_week_topics" ("week_id", "sort_order", "title", "detail")
|
||||
SELECT week_id, sort_order, title, detail FROM rows
|
||||
ON CONFLICT ("week_id", "sort_order") DO UPDATE
|
||||
SET "title" = EXCLUDED."title",
|
||||
"detail" = EXCLUDED."detail";
|
||||
|
||||
WITH week3 AS (
|
||||
SELECT w."id" AS week_id
|
||||
FROM "course_weeks" w
|
||||
JOIN "courses" c ON c."id" = w."course_id"
|
||||
WHERE c."slug" = 'futuredrive-autonomy-basic' AND w."week" = 3
|
||||
)
|
||||
INSERT INTO "course_week_practices" ("week_id", "sort_order", "title", "description")
|
||||
SELECT week3.week_id, data.sort_order, data.title, data.description
|
||||
FROM week3
|
||||
CROSS JOIN (
|
||||
VALUES
|
||||
(1, 'SLAM 개요', '동시적 위치추정 및 지도작성의 기본 개념'),
|
||||
(2, 'Cartographer 설정', 'ROS2 Cartographer 패키지 설정 방법'),
|
||||
(3, '실습: 맵 생성', 'LiDAR 데이터를 활용한 실시간 맵핑'),
|
||||
(4, '위치 추정', '생성된 맵에서 로봇 위치 인식')
|
||||
) AS data(sort_order, title, description)
|
||||
ON CONFLICT ("week_id", "sort_order") DO UPDATE
|
||||
SET "title" = EXCLUDED."title",
|
||||
"description" = EXCLUDED."description";
|
||||
@@ -0,0 +1,119 @@
|
||||
CREATE TABLE "platforms" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"platform_key" TEXT NOT NULL,
|
||||
"display_name" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"organization_name" TEXT NOT NULL,
|
||||
"icon_key" TEXT NOT NULL,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "platforms_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "course_platforms" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"course_id" UUID NOT NULL,
|
||||
"platform_id" UUID NOT NULL,
|
||||
"status" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL,
|
||||
"state" TEXT NOT NULL,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "course_platforms_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "course_student_progress" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"course_id" UUID NOT NULL,
|
||||
"user_id" UUID NOT NULL,
|
||||
"completed_weeks" INTEGER NOT NULL DEFAULT 0,
|
||||
"total_weeks" INTEGER NOT NULL DEFAULT 0,
|
||||
"learning_hours" INTEGER NOT NULL DEFAULT 0,
|
||||
"commit_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"average_score" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "course_student_progress_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "platforms_platform_key_key" ON "platforms"("platform_key");
|
||||
CREATE INDEX "platforms_organization_name_idx" ON "platforms"("organization_name");
|
||||
CREATE INDEX "platforms_is_active_idx" ON "platforms"("is_active");
|
||||
CREATE UNIQUE INDEX "course_platforms_course_id_platform_id_key" ON "course_platforms"("course_id", "platform_id");
|
||||
CREATE INDEX "course_platforms_course_id_sort_order_idx" ON "course_platforms"("course_id", "sort_order");
|
||||
CREATE INDEX "course_platforms_platform_id_idx" ON "course_platforms"("platform_id");
|
||||
CREATE UNIQUE INDEX "course_student_progress_course_id_user_id_key" ON "course_student_progress"("course_id", "user_id");
|
||||
CREATE INDEX "course_student_progress_user_id_idx" ON "course_student_progress"("user_id");
|
||||
|
||||
ALTER TABLE "course_platforms" ADD CONSTRAINT "course_platforms_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "course_platforms" ADD CONSTRAINT "course_platforms_platform_id_fkey" FOREIGN KEY ("platform_id") REFERENCES "platforms"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "course_student_progress" ADD CONSTRAINT "course_student_progress_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "course_student_progress" ADD CONSTRAINT "course_student_progress_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
COMMENT ON TABLE "platforms" IS '최고관리자가 등록하는 실습 플랫폼 마스터. 학교/기관별 개별 장비를 구분한다.';
|
||||
COMMENT ON TABLE "course_platforms" IS '강사가 강의에 사용할 플랫폼을 선택해 배정한 목록.';
|
||||
COMMENT ON TABLE "course_student_progress" IS '학생별 강의 학습 현황 통계.';
|
||||
|
||||
INSERT INTO "users" ("id", "email", "name", "createdAt", "updatedAt")
|
||||
VALUES (gen_random_uuid(), 'student@futuredrive.net', '퓨처드라이브 학생', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT ("email") DO UPDATE
|
||||
SET "name" = EXCLUDED."name",
|
||||
"updatedAt" = CURRENT_TIMESTAMP;
|
||||
|
||||
INSERT INTO "platforms" ("platform_key", "display_name", "description", "organization_name", "icon_key", "updated_at")
|
||||
VALUES
|
||||
('futuredrive-local-pc', '로컬 PC', '내 PC에서 직접 개발 환경을 실행합니다.', '퓨처드라이브', 'laptop', CURRENT_TIMESTAMP),
|
||||
('futuredrive-simulator-01', '시뮬레이터', '가상 환경에서 안전하게 테스트합니다.', '퓨처드라이브', 'monitor', CURRENT_TIMESTAMP),
|
||||
('futuredrive-f1tenth-01', 'F1Tenth #01', '고속 주행 및 회피 알고리즘 실습 장비입니다.', '퓨처드라이브', 'car', CURRENT_TIMESTAMP),
|
||||
('futuredrive-mkmini-02', 'MK-mini #02', '기초 SLAM 및 자율주행 실습 장비입니다.', '퓨처드라이브', 'car', CURRENT_TIMESTAMP)
|
||||
ON CONFLICT ("platform_key") DO UPDATE
|
||||
SET "display_name" = EXCLUDED."display_name",
|
||||
"description" = EXCLUDED."description",
|
||||
"organization_name" = EXCLUDED."organization_name",
|
||||
"icon_key" = EXCLUDED."icon_key",
|
||||
"is_active" = true,
|
||||
"updated_at" = CURRENT_TIMESTAMP;
|
||||
|
||||
WITH course AS (
|
||||
SELECT "id" FROM "courses" WHERE "slug" = 'futuredrive-autonomy-basic'
|
||||
), data AS (
|
||||
SELECT *
|
||||
FROM (VALUES
|
||||
('futuredrive-local-pc', '클라이언트 실행 시 제어권이 부여됩니다', '연결', 'ready', 1),
|
||||
('futuredrive-simulator-01', '사용 가능', '제어권 요청', 'ready', 2),
|
||||
('futuredrive-f1tenth-01', '타 학생 사용 중', '대기 예약', 'reserved', 3),
|
||||
('futuredrive-mkmini-02', '접근 권한 없음', '강사에게 권한 요청', 'locked', 4)
|
||||
) AS v(platform_key, status, action, state, sort_order)
|
||||
)
|
||||
INSERT INTO "course_platforms" ("course_id", "platform_id", "status", "action", "state", "sort_order", "updated_at")
|
||||
SELECT course."id", p."id", data.status, data.action, data.state, data.sort_order, CURRENT_TIMESTAMP
|
||||
FROM course
|
||||
JOIN data ON true
|
||||
JOIN "platforms" p ON p."platform_key" = data.platform_key
|
||||
ON CONFLICT ("course_id", "platform_id") DO UPDATE
|
||||
SET "status" = EXCLUDED."status",
|
||||
"action" = EXCLUDED."action",
|
||||
"state" = EXCLUDED."state",
|
||||
"sort_order" = EXCLUDED."sort_order",
|
||||
"updated_at" = CURRENT_TIMESTAMP;
|
||||
|
||||
WITH course AS (
|
||||
SELECT "id" FROM "courses" WHERE "slug" = 'futuredrive-autonomy-basic'
|
||||
), student AS (
|
||||
SELECT "id" FROM "users" WHERE "email" = 'student@futuredrive.net'
|
||||
)
|
||||
INSERT INTO "course_student_progress" ("course_id", "user_id", "completed_weeks", "total_weeks", "learning_hours", "commit_count", "average_score", "updated_at")
|
||||
SELECT course."id", student."id", 2, 5, 42, 18, 85, CURRENT_TIMESTAMP
|
||||
FROM course, student
|
||||
ON CONFLICT ("course_id", "user_id") DO UPDATE
|
||||
SET "completed_weeks" = EXCLUDED."completed_weeks",
|
||||
"total_weeks" = EXCLUDED."total_weeks",
|
||||
"learning_hours" = EXCLUDED."learning_hours",
|
||||
"commit_count" = EXCLUDED."commit_count",
|
||||
"average_score" = EXCLUDED."average_score",
|
||||
"updated_at" = CURRENT_TIMESTAMP;
|
||||
@@ -0,0 +1,82 @@
|
||||
COMMENT ON TABLE "users" IS 'LMS 내부 사용자 기본 정보. 중앙 auth 사용자와 동기화해 앱별 데이터를 연결한다.';
|
||||
COMMENT ON COLUMN "users"."id" IS 'LMS 사용자 고유 UUID.';
|
||||
COMMENT ON COLUMN "users"."auth_user_id" IS '중앙 auth DB users.id와 매핑되는 사용자 UUID.';
|
||||
COMMENT ON COLUMN "users"."email" IS '사용자 로그인 이메일.';
|
||||
COMMENT ON COLUMN "users"."name" IS '사용자 표시 이름.';
|
||||
COMMENT ON COLUMN "users"."createdAt" IS '사용자 데이터 생성 일시.';
|
||||
COMMENT ON COLUMN "users"."updatedAt" IS '사용자 데이터 수정 일시.';
|
||||
|
||||
COMMENT ON TABLE "courses" IS 'LMS 강의 마스터. 소속과 강사 기준으로 전체 강의 정보를 관리한다.';
|
||||
COMMENT ON COLUMN "courses"."id" IS '강의 고유 UUID.';
|
||||
COMMENT ON COLUMN "courses"."slug" IS '강의 식별용 문자열 키.';
|
||||
COMMENT ON COLUMN "courses"."title" IS '강의 제목.';
|
||||
COMMENT ON COLUMN "courses"."subtitle" IS '강의 부제목.';
|
||||
COMMENT ON COLUMN "courses"."description" IS '강의 설명.';
|
||||
COMMENT ON COLUMN "courses"."organization_name" IS '강의를 운영하는 소속명.';
|
||||
COMMENT ON COLUMN "courses"."teacher_id" IS '강의를 등록하거나 운영하는 강사 사용자 UUID.';
|
||||
COMMENT ON COLUMN "courses"."is_active" IS '강의 활성 여부.';
|
||||
COMMENT ON COLUMN "courses"."created_at" IS '강의 생성 일시.';
|
||||
COMMENT ON COLUMN "courses"."updated_at" IS '강의 수정 일시.';
|
||||
|
||||
COMMENT ON TABLE "course_weeks" IS '강의별 주차 커리큘럼 정보.';
|
||||
COMMENT ON COLUMN "course_weeks"."id" IS '주차 커리큘럼 고유 UUID.';
|
||||
COMMENT ON COLUMN "course_weeks"."course_id" IS '소속 강의 UUID.';
|
||||
COMMENT ON COLUMN "course_weeks"."week" IS '강의 내 주차 번호.';
|
||||
COMMENT ON COLUMN "course_weeks"."title" IS '주차 제목.';
|
||||
COMMENT ON COLUMN "course_weeks"."tags" IS '주차 상태 및 분류 태그 목록.';
|
||||
COMMENT ON COLUMN "course_weeks"."progress" IS '주차 기준 진행률.';
|
||||
COMMENT ON COLUMN "course_weeks"."state" IS '주차 상태. done, current, locked 등을 사용한다.';
|
||||
COMMENT ON COLUMN "course_weeks"."duration" IS '예상 학습 시간 표시값.';
|
||||
COMMENT ON COLUMN "course_weeks"."github" IS 'GitHub 제출 상태 표시값.';
|
||||
COMMENT ON COLUMN "course_weeks"."overview" IS '주차 개요 설명.';
|
||||
COMMENT ON COLUMN "course_weeks"."deliverable" IS '주차 제출 기준.';
|
||||
COMMENT ON COLUMN "course_weeks"."created_at" IS '주차 커리큘럼 생성 일시.';
|
||||
COMMENT ON COLUMN "course_weeks"."updated_at" IS '주차 커리큘럼 수정 일시.';
|
||||
|
||||
COMMENT ON TABLE "course_week_topics" IS '주차별 학습 주제와 상세 설명.';
|
||||
COMMENT ON COLUMN "course_week_topics"."id" IS '주차 학습 주제 고유 UUID.';
|
||||
COMMENT ON COLUMN "course_week_topics"."week_id" IS '소속 주차 커리큘럼 UUID.';
|
||||
COMMENT ON COLUMN "course_week_topics"."title" IS '학습 주제 제목.';
|
||||
COMMENT ON COLUMN "course_week_topics"."detail" IS '학습 주제 상세 설명.';
|
||||
COMMENT ON COLUMN "course_week_topics"."sort_order" IS '주차 내 학습 주제 표시 순서.';
|
||||
|
||||
COMMENT ON TABLE "course_week_practices" IS '주차별 실습 내용.';
|
||||
COMMENT ON COLUMN "course_week_practices"."id" IS '주차 실습 내용 고유 UUID.';
|
||||
COMMENT ON COLUMN "course_week_practices"."week_id" IS '소속 주차 커리큘럼 UUID.';
|
||||
COMMENT ON COLUMN "course_week_practices"."title" IS '실습 항목 제목.';
|
||||
COMMENT ON COLUMN "course_week_practices"."description" IS '실습 항목 설명.';
|
||||
COMMENT ON COLUMN "course_week_practices"."sort_order" IS '주차 내 실습 항목 표시 순서.';
|
||||
|
||||
COMMENT ON TABLE "platforms" IS '최고관리자가 등록하는 실습 플랫폼 마스터. 학교/기관별 개별 장비를 구분한다.';
|
||||
COMMENT ON COLUMN "platforms"."id" IS '실습 플랫폼 고유 UUID.';
|
||||
COMMENT ON COLUMN "platforms"."platform_key" IS '플랫폼 식별용 문자열 키. 기관과 장비를 구분할 수 있어야 한다.';
|
||||
COMMENT ON COLUMN "platforms"."display_name" IS '화면에 표시할 플랫폼 이름.';
|
||||
COMMENT ON COLUMN "platforms"."description" IS '플랫폼 설명.';
|
||||
COMMENT ON COLUMN "platforms"."organization_name" IS '플랫폼을 소유하거나 운영하는 소속명.';
|
||||
COMMENT ON COLUMN "platforms"."icon_key" IS '프론트엔드에서 사용할 아이콘 키.';
|
||||
COMMENT ON COLUMN "platforms"."is_active" IS '플랫폼 활성 여부.';
|
||||
COMMENT ON COLUMN "platforms"."created_at" IS '플랫폼 생성 일시.';
|
||||
COMMENT ON COLUMN "platforms"."updated_at" IS '플랫폼 수정 일시.';
|
||||
|
||||
COMMENT ON TABLE "course_platforms" IS '강사가 강의에 사용할 플랫폼을 선택해 배정한 목록.';
|
||||
COMMENT ON COLUMN "course_platforms"."id" IS '강의-플랫폼 배정 고유 UUID.';
|
||||
COMMENT ON COLUMN "course_platforms"."course_id" IS '플랫폼을 배정한 강의 UUID.';
|
||||
COMMENT ON COLUMN "course_platforms"."platform_id" IS '강의에 배정된 플랫폼 UUID.';
|
||||
COMMENT ON COLUMN "course_platforms"."status" IS '플랫폼 사용 가능 상태 설명.';
|
||||
COMMENT ON COLUMN "course_platforms"."action" IS '플랫폼 버튼에 표시할 작업명.';
|
||||
COMMENT ON COLUMN "course_platforms"."state" IS '플랫폼 상태. ready, reserved, locked 등을 사용한다.';
|
||||
COMMENT ON COLUMN "course_platforms"."sort_order" IS '강의 내 플랫폼 표시 순서.';
|
||||
COMMENT ON COLUMN "course_platforms"."created_at" IS '강의-플랫폼 배정 생성 일시.';
|
||||
COMMENT ON COLUMN "course_platforms"."updated_at" IS '강의-플랫폼 배정 수정 일시.';
|
||||
|
||||
COMMENT ON TABLE "course_student_progress" IS '학생별 강의 학습 현황 통계.';
|
||||
COMMENT ON COLUMN "course_student_progress"."id" IS '학생 학습 현황 고유 UUID.';
|
||||
COMMENT ON COLUMN "course_student_progress"."course_id" IS '학습 현황이 속한 강의 UUID.';
|
||||
COMMENT ON COLUMN "course_student_progress"."user_id" IS '학습 현황 대상 학생 사용자 UUID.';
|
||||
COMMENT ON COLUMN "course_student_progress"."completed_weeks" IS '완료한 주차 수.';
|
||||
COMMENT ON COLUMN "course_student_progress"."total_weeks" IS '전체 주차 수.';
|
||||
COMMENT ON COLUMN "course_student_progress"."learning_hours" IS '누적 학습 시간.';
|
||||
COMMENT ON COLUMN "course_student_progress"."commit_count" IS '누적 커밋 수.';
|
||||
COMMENT ON COLUMN "course_student_progress"."average_score" IS '평균 점수.';
|
||||
COMMENT ON COLUMN "course_student_progress"."created_at" IS '학생 학습 현황 생성 일시.';
|
||||
COMMENT ON COLUMN "course_student_progress"."updated_at" IS '학생 학습 현황 수정 일시.';
|
||||
@@ -0,0 +1,43 @@
|
||||
UPDATE "users"
|
||||
SET "auth_user_id" = '54803583-0a04-44f4-9398-78478239c566',
|
||||
"name" = 'ATNMY Studio Admin',
|
||||
"updatedAt" = CURRENT_TIMESTAMP
|
||||
WHERE "email" = 'teacher@futuredrive.net';
|
||||
|
||||
INSERT INTO "users" ("id", "auth_user_id", "email", "name", "createdAt", "updatedAt")
|
||||
VALUES (
|
||||
gen_random_uuid(),
|
||||
'a8e7f37e-ef63-4912-a412-b4874b91ee67',
|
||||
'chyoo@futuredrive.net',
|
||||
'유창훈',
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
ON CONFLICT ("email") DO UPDATE
|
||||
SET "auth_user_id" = EXCLUDED."auth_user_id",
|
||||
"name" = EXCLUDED."name",
|
||||
"updatedAt" = CURRENT_TIMESTAMP;
|
||||
|
||||
WITH course AS (
|
||||
SELECT "id" FROM "courses" WHERE "slug" = 'futuredrive-autonomy-basic'
|
||||
), learner AS (
|
||||
SELECT "id" FROM "users" WHERE "email" = 'chyoo@futuredrive.net'
|
||||
)
|
||||
INSERT INTO "course_student_progress" ("course_id", "user_id", "completed_weeks", "total_weeks", "learning_hours", "commit_count", "average_score", "updated_at")
|
||||
SELECT course."id", learner."id", 2, 5, 42, 18, 85, CURRENT_TIMESTAMP
|
||||
FROM course, learner
|
||||
ON CONFLICT ("course_id", "user_id") DO UPDATE
|
||||
SET "completed_weeks" = EXCLUDED."completed_weeks",
|
||||
"total_weeks" = EXCLUDED."total_weeks",
|
||||
"learning_hours" = EXCLUDED."learning_hours",
|
||||
"commit_count" = EXCLUDED."commit_count",
|
||||
"average_score" = EXCLUDED."average_score",
|
||||
"updated_at" = CURRENT_TIMESTAMP;
|
||||
|
||||
DELETE FROM "course_student_progress"
|
||||
WHERE "user_id" IN (
|
||||
SELECT "id" FROM "users" WHERE "email" = 'student@futuredrive.net'
|
||||
);
|
||||
|
||||
DELETE FROM "users"
|
||||
WHERE "email" = 'student@futuredrive.net';
|
||||
@@ -0,0 +1,4 @@
|
||||
UPDATE "users"
|
||||
SET "auth_user_id" = NULL,
|
||||
"updatedAt" = CURRENT_TIMESTAMP
|
||||
WHERE "email" IN ('teacher@futuredrive.net', 'chyoo@futuredrive.net');
|
||||
@@ -0,0 +1,7 @@
|
||||
DELETE FROM "course_student_progress"
|
||||
WHERE "user_id" IN (
|
||||
SELECT "id" FROM "users" WHERE "email" = 'chyoo@futuredrive.net'
|
||||
);
|
||||
|
||||
DELETE FROM "users"
|
||||
WHERE "email" = 'chyoo@futuredrive.net';
|
||||
@@ -0,0 +1,72 @@
|
||||
CREATE TYPE "ChatThreadType" AS ENUM ('AI', 'INSTRUCTOR', 'MIXED');
|
||||
CREATE TYPE "ChatThreadStatus" AS ENUM ('OPEN', 'WAITING_INSTRUCTOR', 'RESOLVED', 'ARCHIVED');
|
||||
CREATE TYPE "ChatSenderType" AS ENUM ('STUDENT', 'AI', 'INSTRUCTOR', 'SYSTEM');
|
||||
CREATE TYPE "ChatContentFormat" AS ENUM ('TEXT', 'MARKDOWN');
|
||||
CREATE TYPE "AiChatRunStatus" AS ENUM ('SUCCEEDED', 'FAILED');
|
||||
|
||||
CREATE TABLE "chat_threads" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"course_id" UUID NOT NULL,
|
||||
"week_id" UUID,
|
||||
"student_id" UUID NOT NULL,
|
||||
"assigned_instructor_id" UUID,
|
||||
"type" "ChatThreadType" NOT NULL,
|
||||
"status" "ChatThreadStatus" NOT NULL DEFAULT 'OPEN',
|
||||
"title" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"last_message_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "chat_threads_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "chat_messages" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"thread_id" UUID NOT NULL,
|
||||
"sender_type" "ChatSenderType" NOT NULL,
|
||||
"sender_user_id" UUID,
|
||||
"content" TEXT NOT NULL,
|
||||
"content_format" "ChatContentFormat" NOT NULL DEFAULT 'TEXT',
|
||||
"metadata" JSONB,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"deleted_at" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "chat_messages_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "ai_chat_runs" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"message_id" UUID NOT NULL,
|
||||
"model" TEXT NOT NULL,
|
||||
"prompt_tokens" INTEGER,
|
||||
"completion_tokens" INTEGER,
|
||||
"total_tokens" INTEGER,
|
||||
"latency_ms" INTEGER,
|
||||
"status" "AiChatRunStatus" NOT NULL,
|
||||
"error" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ai_chat_runs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "chat_threads_course_id_type_last_message_at_idx" ON "chat_threads"("course_id", "type", "last_message_at");
|
||||
CREATE INDEX "chat_threads_student_id_type_last_message_at_idx" ON "chat_threads"("student_id", "type", "last_message_at");
|
||||
CREATE INDEX "chat_threads_week_id_idx" ON "chat_threads"("week_id");
|
||||
CREATE INDEX "chat_threads_assigned_instructor_id_idx" ON "chat_threads"("assigned_instructor_id");
|
||||
CREATE INDEX "chat_messages_thread_id_created_at_idx" ON "chat_messages"("thread_id", "created_at");
|
||||
CREATE INDEX "chat_messages_sender_user_id_idx" ON "chat_messages"("sender_user_id");
|
||||
CREATE UNIQUE INDEX "ai_chat_runs_message_id_key" ON "ai_chat_runs"("message_id");
|
||||
CREATE INDEX "ai_chat_runs_status_created_at_idx" ON "ai_chat_runs"("status", "created_at");
|
||||
|
||||
ALTER TABLE "chat_threads" ADD CONSTRAINT "chat_threads_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "chat_threads" ADD CONSTRAINT "chat_threads_week_id_fkey" FOREIGN KEY ("week_id") REFERENCES "course_weeks"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "chat_threads" ADD CONSTRAINT "chat_threads_student_id_fkey" FOREIGN KEY ("student_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "chat_threads" ADD CONSTRAINT "chat_threads_assigned_instructor_id_fkey" FOREIGN KEY ("assigned_instructor_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_thread_id_fkey" FOREIGN KEY ("thread_id") REFERENCES "chat_threads"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_sender_user_id_fkey" FOREIGN KEY ("sender_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "ai_chat_runs" ADD CONSTRAINT "ai_chat_runs_message_id_fkey" FOREIGN KEY ("message_id") REFERENCES "chat_messages"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
COMMENT ON TABLE "chat_threads" IS '학생 질의응답 대화방. AI, 강사, 혼합 대화의 상태와 연결 주차를 관리한다.';
|
||||
COMMENT ON TABLE "chat_messages" IS '질의응답 대화방의 개별 메시지 기록.';
|
||||
COMMENT ON TABLE "ai_chat_runs" IS 'AI 응답 생성 실행 기록. 모델, 토큰, 지연 시간, 오류를 추적한다.';
|
||||
COMMENT ON COLUMN "chat_messages"."metadata" 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,10 @@
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN "auth_role" TEXT,
|
||||
ADD COLUMN "primary_organization_id" UUID,
|
||||
ADD COLUMN "primary_organization_name" TEXT,
|
||||
ADD COLUMN "primary_organization_type" TEXT,
|
||||
ADD COLUMN "department" TEXT,
|
||||
ADD COLUMN "position" TEXT;
|
||||
|
||||
CREATE INDEX "users_auth_role_idx" ON "users"("auth_role");
|
||||
CREATE INDEX "users_primary_organization_id_idx" ON "users"("primary_organization_id");
|
||||
@@ -0,0 +1,73 @@
|
||||
CREATE TABLE "student_groups" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"organization_id" UUID,
|
||||
"organization_name" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"week" INTEGER NOT NULL DEFAULT 1,
|
||||
"status" TEXT NOT NULL DEFAULT '진행중',
|
||||
"platform_name" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "student_groups_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "student_group_members" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"group_id" UUID NOT NULL,
|
||||
"user_id" UUID NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "student_group_members_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "student_groups_organization_name_name_key" ON "student_groups"("organization_name", "name");
|
||||
CREATE INDEX "student_groups_organization_id_idx" ON "student_groups"("organization_id");
|
||||
CREATE INDEX "student_groups_organization_name_idx" ON "student_groups"("organization_name");
|
||||
CREATE UNIQUE INDEX "student_group_members_group_id_user_id_key" ON "student_group_members"("group_id", "user_id");
|
||||
CREATE INDEX "student_group_members_user_id_idx" ON "student_group_members"("user_id");
|
||||
|
||||
ALTER TABLE "student_group_members"
|
||||
ADD CONSTRAINT "student_group_members_group_id_fkey"
|
||||
FOREIGN KEY ("group_id") REFERENCES "student_groups"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "student_group_members"
|
||||
ADD CONSTRAINT "student_group_members_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
INSERT INTO "student_groups" ("organization_id", "organization_name", "name", "week", "status", "platform_name")
|
||||
SELECT org."organization_id", org."organization_name", seed."name", seed."week", '진행중', seed."platform_name"
|
||||
FROM (
|
||||
VALUES
|
||||
('그룹 A', 3, 'MK-mini #01'),
|
||||
('그룹 B', 2, '시뮬레이션'),
|
||||
('그룹 C', 4, 'f1tenth #01')
|
||||
) AS seed("name", "week", "platform_name")
|
||||
CROSS JOIN (
|
||||
SELECT DISTINCT "primary_organization_id" AS "organization_id", "primary_organization_name" AS "organization_name"
|
||||
FROM "users"
|
||||
WHERE "primary_organization_name" IS NOT NULL
|
||||
) org
|
||||
ON CONFLICT ("organization_name", "name") DO NOTHING;
|
||||
|
||||
INSERT INTO "student_group_members" ("group_id", "user_id")
|
||||
SELECT g."id", u."id"
|
||||
FROM "student_groups" g
|
||||
JOIN "users" u ON u."primary_organization_name" = g."organization_name"
|
||||
WHERE g."name" = '그룹 A'
|
||||
AND u."email" IN ('kms@yu.ac.kr', 'lsy@yu.ac.kr', 'cyj@yu.ac.kr')
|
||||
ON CONFLICT ("group_id", "user_id") DO NOTHING;
|
||||
|
||||
INSERT INTO "student_group_members" ("group_id", "user_id")
|
||||
SELECT g."id", u."id"
|
||||
FROM "student_groups" g
|
||||
JOIN "users" u ON u."primary_organization_name" = g."organization_name"
|
||||
WHERE g."name" = '그룹 B'
|
||||
AND u."email" IN ('jyr@yu.ac.kr', 'pjh@yu.ac.kr')
|
||||
ON CONFLICT ("group_id", "user_id") DO NOTHING;
|
||||
|
||||
INSERT INTO "student_group_members" ("group_id", "user_id")
|
||||
SELECT g."id", u."id"
|
||||
FROM "student_groups" g
|
||||
JOIN "users" u ON u."primary_organization_name" = g."organization_name"
|
||||
WHERE g."name" = '그룹 C'
|
||||
AND u."email" IN ('hdy@yu.ac.kr', 'ojh@yu.ac.kr', 'ysj@yu.ac.kr', 'kdh@yu.ac.kr')
|
||||
ON CONFLICT ("group_id", "user_id") DO NOTHING;
|
||||
@@ -0,0 +1,29 @@
|
||||
CREATE TABLE "student_enrollments" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"user_id" UUID NOT NULL,
|
||||
"organization_id" UUID,
|
||||
"organization_name" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
|
||||
"removed_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "student_enrollments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "student_enrollments_user_id_organization_name_key" ON "student_enrollments"("user_id", "organization_name");
|
||||
CREATE INDEX "student_enrollments_organization_id_status_idx" ON "student_enrollments"("organization_id", "status");
|
||||
CREATE INDEX "student_enrollments_organization_name_status_idx" ON "student_enrollments"("organization_name", "status");
|
||||
|
||||
ALTER TABLE "student_enrollments" ADD CONSTRAINT "student_enrollments_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
INSERT INTO "student_enrollments" ("user_id", "organization_id", "organization_name", "status")
|
||||
SELECT
|
||||
u."id",
|
||||
u."primary_organization_id",
|
||||
u."primary_organization_name",
|
||||
'ACTIVE'
|
||||
FROM "users" u
|
||||
WHERE u."primary_organization_name" IS NOT NULL
|
||||
AND (u."auth_role" IS NULL OR u."auth_role" IN ('USER', 'STUDENT'))
|
||||
ON CONFLICT ("user_id", "organization_name") DO NOTHING;
|
||||
@@ -0,0 +1,37 @@
|
||||
CREATE TABLE "instructor_platforms" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"organization_id" UUID,
|
||||
"organization_name" TEXT NOT NULL,
|
||||
"platform_id" UUID NOT NULL,
|
||||
"display_name" TEXT NOT NULL,
|
||||
"instance_no" INTEGER NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT '대기',
|
||||
"battery" INTEGER NOT NULL DEFAULT 100,
|
||||
"uptime_minutes" INTEGER NOT NULL DEFAULT 0,
|
||||
"speed_meters" DECIMAL(6, 2) NOT NULL DEFAULT 0,
|
||||
"error_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"emergency_stop" BOOLEAN NOT NULL DEFAULT false,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "instructor_platforms_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "instructor_platforms_organization_name_display_name_key"
|
||||
ON "instructor_platforms"("organization_name", "display_name");
|
||||
|
||||
CREATE UNIQUE INDEX "instructor_platforms_organization_name_platform_id_instance_no_key"
|
||||
ON "instructor_platforms"("organization_name", "platform_id", "instance_no");
|
||||
|
||||
CREATE INDEX "instructor_platforms_organization_id_idx"
|
||||
ON "instructor_platforms"("organization_id");
|
||||
|
||||
CREATE INDEX "instructor_platforms_organization_name_idx"
|
||||
ON "instructor_platforms"("organization_name");
|
||||
|
||||
CREATE INDEX "instructor_platforms_platform_id_idx"
|
||||
ON "instructor_platforms"("platform_id");
|
||||
|
||||
ALTER TABLE "instructor_platforms"
|
||||
ADD CONSTRAINT "instructor_platforms_platform_id_fkey"
|
||||
FOREIGN KEY ("platform_id") REFERENCES "platforms"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE "instructor_curriculum_settings" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"organization_id" UUID,
|
||||
"organization_name" TEXT NOT NULL,
|
||||
"course_id" UUID NOT NULL,
|
||||
"active_week" INTEGER NOT NULL DEFAULT 1,
|
||||
"active_learning_step" TEXT,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "instructor_curriculum_settings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "instructor_curriculum_settings_organization_name_key"
|
||||
ON "instructor_curriculum_settings"("organization_name");
|
||||
|
||||
CREATE INDEX "instructor_curriculum_settings_organization_id_idx"
|
||||
ON "instructor_curriculum_settings"("organization_id");
|
||||
|
||||
CREATE INDEX "instructor_curriculum_settings_course_id_idx"
|
||||
ON "instructor_curriculum_settings"("course_id");
|
||||
|
||||
ALTER TABLE "instructor_curriculum_settings"
|
||||
ADD CONSTRAINT "instructor_curriculum_settings_course_id_fkey"
|
||||
FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,37 @@
|
||||
UPDATE "courses"
|
||||
SET "organization_name" = '공통'
|
||||
WHERE "slug" = 'futuredrive-autonomy-basic'
|
||||
AND "organization_name" = '퓨처드라이브';
|
||||
|
||||
CREATE TABLE "instructor_curriculum_week_overrides" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"organization_id" UUID,
|
||||
"organization_name" TEXT NOT NULL,
|
||||
"course_id" UUID NOT NULL,
|
||||
"week" INTEGER NOT NULL,
|
||||
"title" TEXT,
|
||||
"overview" TEXT,
|
||||
"deliverable" TEXT,
|
||||
"topics" JSONB,
|
||||
"topic_details" JSONB,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "instructor_curriculum_week_overrides_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "instructor_curriculum_week_overrides_organization_name_course_id_week_key"
|
||||
ON "instructor_curriculum_week_overrides"("organization_name", "course_id", "week");
|
||||
|
||||
CREATE INDEX "instructor_curriculum_week_overrides_organization_id_idx"
|
||||
ON "instructor_curriculum_week_overrides"("organization_id");
|
||||
|
||||
CREATE INDEX "instructor_curriculum_week_overrides_organization_name_idx"
|
||||
ON "instructor_curriculum_week_overrides"("organization_name");
|
||||
|
||||
CREATE INDEX "instructor_curriculum_week_overrides_course_id_idx"
|
||||
ON "instructor_curriculum_week_overrides"("course_id");
|
||||
|
||||
ALTER TABLE "instructor_curriculum_week_overrides"
|
||||
ADD CONSTRAINT "instructor_curriculum_week_overrides_course_id_fkey"
|
||||
FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "instructor_curriculum_week_overrides"
|
||||
ADD COLUMN "duration" TEXT;
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
UPDATE "student_groups"
|
||||
SET "platform_name" = NULL
|
||||
WHERE "platform_name" IN ('로컬 PC', '시뮬레이션', '시뮬레이터', 'Simulator');
|
||||
|
||||
DELETE FROM "instructor_platforms"
|
||||
WHERE "platform_id" IN (
|
||||
SELECT "id"
|
||||
FROM "platforms"
|
||||
WHERE "platform_key" ILIKE '%local%'
|
||||
OR "platform_key" ILIKE '%simulator%'
|
||||
OR "display_name" ILIKE '%로컬%'
|
||||
OR "display_name" ILIKE '%시뮬%'
|
||||
OR "display_name" ILIKE '%simulator%'
|
||||
);
|
||||
|
||||
DELETE FROM "course_platforms"
|
||||
WHERE "platform_id" IN (
|
||||
SELECT "id"
|
||||
FROM "platforms"
|
||||
WHERE "platform_key" ILIKE '%local%'
|
||||
OR "platform_key" ILIKE '%simulator%'
|
||||
OR "display_name" ILIKE '%로컬%'
|
||||
OR "display_name" ILIKE '%시뮬%'
|
||||
OR "display_name" ILIKE '%simulator%'
|
||||
);
|
||||
|
||||
DELETE FROM "platforms"
|
||||
WHERE "platform_key" ILIKE '%local%'
|
||||
OR "platform_key" ILIKE '%simulator%'
|
||||
OR "display_name" ILIKE '%로컬%'
|
||||
OR "display_name" ILIKE '%시뮬%'
|
||||
OR "display_name" ILIKE '%simulator%';
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
INSERT INTO "platforms" ("platform_key", "display_name", "description", "organization_name", "icon_key", "is_active", "updated_at")
|
||||
VALUES
|
||||
('futuredrive-local-pc', '로컬 PC', '내 PC에서 직접 개발 환경을 실행합니다.', '퓨처드라이브', 'laptop', true, CURRENT_TIMESTAMP),
|
||||
('futuredrive-simulator-01', '시뮬레이터', '가상 환경에서 안전하게 테스트합니다.', '퓨처드라이브', 'monitor', true, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT ("platform_key") DO UPDATE
|
||||
SET "display_name" = EXCLUDED."display_name",
|
||||
"description" = EXCLUDED."description",
|
||||
"organization_name" = EXCLUDED."organization_name",
|
||||
"icon_key" = EXCLUDED."icon_key",
|
||||
"is_active" = true,
|
||||
"updated_at" = CURRENT_TIMESTAMP;
|
||||
|
||||
WITH course AS (
|
||||
SELECT "id" FROM "courses" WHERE "slug" = 'futuredrive-autonomy-basic'
|
||||
), data AS (
|
||||
SELECT *
|
||||
FROM (VALUES
|
||||
('futuredrive-local-pc', '클라이언트 실행 시 제어권이 부여됩니다', '연결', 'ready', 1),
|
||||
('futuredrive-simulator-01', '사용 가능', '제어권 요청', 'ready', 2)
|
||||
) AS v(platform_key, status, action, state, sort_order)
|
||||
)
|
||||
INSERT INTO "course_platforms" ("course_id", "platform_id", "status", "action", "state", "sort_order", "updated_at")
|
||||
SELECT course."id", p."id", data.status, data.action, data.state, data.sort_order, CURRENT_TIMESTAMP
|
||||
FROM course
|
||||
JOIN data ON true
|
||||
JOIN "platforms" p ON p."platform_key" = data.platform_key
|
||||
ON CONFLICT ("course_id", "platform_id") DO UPDATE
|
||||
SET "status" = EXCLUDED."status",
|
||||
"action" = EXCLUDED."action",
|
||||
"state" = EXCLUDED."state",
|
||||
"sort_order" = EXCLUDED."sort_order",
|
||||
"updated_at" = CURRENT_TIMESTAMP;
|
||||
@@ -0,0 +1,7 @@
|
||||
UPDATE platforms
|
||||
SET display_name = 'F1Tenth'
|
||||
WHERE platform_key = 'futuredrive-f1tenth-01';
|
||||
|
||||
UPDATE platforms
|
||||
SET display_name = 'MK-mini'
|
||||
WHERE platform_key = 'futuredrive-mkmini-02';
|
||||
@@ -0,0 +1,63 @@
|
||||
ALTER TABLE "instructor_platforms"
|
||||
ADD COLUMN "control_duration_minutes" INTEGER NOT NULL DEFAULT 30,
|
||||
ADD COLUMN "control_warning_minutes" INTEGER NOT NULL DEFAULT 5;
|
||||
|
||||
CREATE TABLE "platform_control_sessions" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"organization_id" UUID,
|
||||
"organization_name" TEXT NOT NULL,
|
||||
"platform_id" UUID,
|
||||
"instructor_platform_id" UUID,
|
||||
"holder_user_id" UUID NOT NULL,
|
||||
"group_id" UUID,
|
||||
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
|
||||
"started_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"ends_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
"warning_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
"warning_sent_at" TIMESTAMPTZ(3),
|
||||
"released_at" TIMESTAMPTZ(3),
|
||||
"end_reason" TEXT,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "platform_control_sessions_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "platform_control_sessions_platform_id_fkey" FOREIGN KEY ("platform_id") REFERENCES "platforms"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "platform_control_sessions_instructor_platform_id_fkey" FOREIGN KEY ("instructor_platform_id") REFERENCES "instructor_platforms"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "platform_control_sessions_holder_user_id_fkey" FOREIGN KEY ("holder_user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "platform_control_sessions_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "student_groups"("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE "platform_control_queue_entries" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"organization_id" UUID,
|
||||
"organization_name" TEXT NOT NULL,
|
||||
"platform_id" UUID,
|
||||
"instructor_platform_id" UUID,
|
||||
"user_id" UUID NOT NULL,
|
||||
"group_id" UUID,
|
||||
"status" TEXT NOT NULL DEFAULT 'WAITING',
|
||||
"requested_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"granted_at" TIMESTAMPTZ(3),
|
||||
"canceled_at" TIMESTAMPTZ(3),
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "platform_control_queue_entries_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "platform_control_queue_entries_platform_id_fkey" FOREIGN KEY ("platform_id") REFERENCES "platforms"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "platform_control_queue_entries_instructor_platform_id_fkey" FOREIGN KEY ("instructor_platform_id") REFERENCES "instructor_platforms"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "platform_control_queue_entries_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "platform_control_queue_entries_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "student_groups"("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX "platform_control_sessions_organization_id_idx" ON "platform_control_sessions"("organization_id");
|
||||
CREATE INDEX "platform_control_sessions_organization_name_idx" ON "platform_control_sessions"("organization_name");
|
||||
CREATE INDEX "platform_control_sessions_platform_id_status_idx" ON "platform_control_sessions"("platform_id", "status");
|
||||
CREATE INDEX "platform_control_sessions_instructor_platform_id_status_idx" ON "platform_control_sessions"("instructor_platform_id", "status");
|
||||
CREATE INDEX "platform_control_sessions_holder_user_id_status_idx" ON "platform_control_sessions"("holder_user_id", "status");
|
||||
CREATE INDEX "platform_control_sessions_ends_at_status_idx" ON "platform_control_sessions"("ends_at", "status");
|
||||
|
||||
CREATE INDEX "platform_control_queue_entries_organization_id_idx" ON "platform_control_queue_entries"("organization_id");
|
||||
CREATE INDEX "platform_control_queue_entries_organization_name_idx" ON "platform_control_queue_entries"("organization_name");
|
||||
CREATE INDEX "platform_control_queue_entries_platform_id_status_requested_at_idx" ON "platform_control_queue_entries"("platform_id", "status", "requested_at");
|
||||
CREATE INDEX "platform_control_queue_entries_instructor_platform_id_status_requested_at_idx" ON "platform_control_queue_entries"("instructor_platform_id", "status", "requested_at");
|
||||
CREATE INDEX "platform_control_queue_entries_user_id_status_idx" ON "platform_control_queue_entries"("user_id", "status");
|
||||
@@ -0,0 +1,44 @@
|
||||
ALTER TABLE "platforms"
|
||||
ADD COLUMN "vehicle_type" TEXT NOT NULL DEFAULT 'MK-mini',
|
||||
ADD COLUMN "wheelbase_meters" DECIMAL(6, 3) NOT NULL DEFAULT 0.325,
|
||||
ADD COLUMN "max_speed_meters_per_second" DECIMAL(6, 2) NOT NULL DEFAULT 7.0,
|
||||
ADD COLUMN "max_steering_angle_degrees" DECIMAL(6, 2) NOT NULL DEFAULT 30,
|
||||
ADD COLUMN "sensors" JSONB NOT NULL DEFAULT '[]',
|
||||
ADD COLUMN "drivers" JSONB NOT NULL DEFAULT '[]',
|
||||
ADD COLUMN "algorithms" JSONB NOT NULL DEFAULT '{}',
|
||||
ADD COLUMN "control_duration_minutes" INTEGER NOT NULL DEFAULT 30,
|
||||
ADD COLUMN "control_warning_minutes" INTEGER NOT NULL DEFAULT 5;
|
||||
|
||||
ALTER TABLE "instructor_platforms"
|
||||
ADD COLUMN "vehicle_type" TEXT NOT NULL DEFAULT 'MK-mini',
|
||||
ADD COLUMN "wheelbase_meters" DECIMAL(6, 3) NOT NULL DEFAULT 0.325,
|
||||
ADD COLUMN "max_speed_meters_per_second" DECIMAL(6, 2) NOT NULL DEFAULT 7.0,
|
||||
ADD COLUMN "max_steering_angle_degrees" DECIMAL(6, 2) NOT NULL DEFAULT 30,
|
||||
ADD COLUMN "sensors" JSONB NOT NULL DEFAULT '[]',
|
||||
ADD COLUMN "drivers" JSONB NOT NULL DEFAULT '[]',
|
||||
ADD COLUMN "algorithms" JSONB NOT NULL DEFAULT '{}';
|
||||
|
||||
UPDATE "platforms"
|
||||
SET
|
||||
"vehicle_type" = CASE
|
||||
WHEN "display_name" ILIKE '%F1Tenth%' THEN 'F1tenth'
|
||||
WHEN "display_name" ILIKE '%시뮬%' OR "display_name" ILIKE '%Simulator%' THEN 'Simulator'
|
||||
ELSE 'MK-mini'
|
||||
END,
|
||||
"sensors" = '[{"title":"LiDAR","description":"Robosense Helios 16채널"},{"title":"RGB-D 카메라","description":"Intel Realsense D435"},{"title":"IMU 센서","description":"9축 관성측정장치"}]'::jsonb,
|
||||
"drivers" = '[{"title":"LiDAR 드라이버","description":"robosense_driver v1.5.0"},{"title":"Camera 드라이버","description":"realsense2_camera v4.55.1"},{"title":"IMU 드라이버","description":"xsens_driver v2.2.0"}]'::jsonb,
|
||||
"algorithms" = '{"slam":"Cartographer","planning":"A* + DWA","control":"PID Control"}'::jsonb;
|
||||
|
||||
UPDATE "instructor_platforms" AS ip
|
||||
SET
|
||||
"vehicle_type" = p."vehicle_type",
|
||||
"wheelbase_meters" = p."wheelbase_meters",
|
||||
"max_speed_meters_per_second" = p."max_speed_meters_per_second",
|
||||
"max_steering_angle_degrees" = p."max_steering_angle_degrees",
|
||||
"sensors" = p."sensors",
|
||||
"drivers" = p."drivers",
|
||||
"algorithms" = p."algorithms",
|
||||
"control_duration_minutes" = p."control_duration_minutes",
|
||||
"control_warning_minutes" = p."control_warning_minutes"
|
||||
FROM "platforms" AS p
|
||||
WHERE p."id" = ip."platform_id";
|
||||
@@ -0,0 +1,11 @@
|
||||
UPDATE "platforms"
|
||||
SET "sensors" = (
|
||||
SELECT COALESCE(jsonb_agg(sensor || jsonb_build_object('enabled', COALESCE((sensor->>'enabled')::boolean, true))), '[]'::jsonb)
|
||||
FROM jsonb_array_elements("platforms"."sensors") AS sensor
|
||||
);
|
||||
|
||||
UPDATE "instructor_platforms"
|
||||
SET "sensors" = (
|
||||
SELECT COALESCE(jsonb_agg(sensor || jsonb_build_object('enabled', COALESCE((sensor->>'enabled')::boolean, true))), '[]'::jsonb)
|
||||
FROM jsonb_array_elements("instructor_platforms"."sensors") AS sensor
|
||||
);
|
||||
@@ -0,0 +1,62 @@
|
||||
CREATE TABLE "course_week_modules" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"week_id" UUID NOT NULL,
|
||||
"sort_order" INTEGER NOT NULL,
|
||||
"category" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"folder_name" TEXT NOT NULL,
|
||||
"template_repository_url" TEXT,
|
||||
"docker_image" TEXT,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "course_week_modules_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "course_week_modules_week_id_folder_name_key" ON "course_week_modules"("week_id", "folder_name");
|
||||
CREATE UNIQUE INDEX "course_week_modules_week_id_sort_order_key" ON "course_week_modules"("week_id", "sort_order");
|
||||
CREATE INDEX "course_week_modules_week_id_idx" ON "course_week_modules"("week_id");
|
||||
|
||||
ALTER TABLE "course_week_modules"
|
||||
ADD CONSTRAINT "course_week_modules_week_id_fkey"
|
||||
FOREIGN KEY ("week_id") REFERENCES "course_weeks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
COMMENT ON TABLE "course_week_modules" IS '주차별 개발 스튜디오 모듈 목록.';
|
||||
COMMENT ON COLUMN "course_week_modules"."folder_name" IS '관리자 템플릿 또는 로컬 캐시의 원본 모듈 폴더명.';
|
||||
COMMENT ON COLUMN "course_week_modules"."template_repository_url" IS '추후 Gitea 템플릿 저장소 연동 시 사용할 원본 repo URL.';
|
||||
COMMENT ON COLUMN "course_week_modules"."docker_image" IS '모듈 실행에 사용할 Docker 이미지.';
|
||||
|
||||
WITH target_week AS (
|
||||
SELECT w."id" AS week_id
|
||||
FROM "course_weeks" w
|
||||
JOIN "courses" c ON c."id" = w."course_id"
|
||||
WHERE c."slug" = 'futuredrive-autonomy-basic' AND w."week" = 3
|
||||
),
|
||||
seed(sort_order, category, name, folder_name) AS (
|
||||
VALUES
|
||||
(1, 'Gui', 'AW-Rviz', '[furive-gui]AW-Rviz_v1.0.0_ws'),
|
||||
(2, 'Map', 'AW-SampleMap', '[furive-map]AW-SampleMap_v1.0.0_ws'),
|
||||
(3, 'Simulator', 'AW-PlanningSim', '[furive-simulator]AW-PlanningSim_v1.0.0_ws'),
|
||||
(4, 'Map', 'DGIST-Campus', '[furive-map]DGIST-Campus_v1.0.0_ws'),
|
||||
(5, 'Planning', 'AW-Planning', '[furive-planning]AW-Planning_v1.0.0_ws'),
|
||||
(6, 'Bag', 'AW-SampleBag', '[furive-bag]AW-SampleBag_v1.0.0_ws'),
|
||||
(7, 'Perception', 'AW-Perception', '[furive-perception]AW-Perception_v1.0.0_ws'),
|
||||
(8, 'Sensing', 'test2', '[furive-sensing]test2_v1.0.0_ws'),
|
||||
(9, 'Localization', 'AW-Localization', '[furive-localization]AW-Localization_v1.0.0_ws'),
|
||||
(10, 'System', 'AW-System', '[furive-system]AW-System_v1.0.0_ws'),
|
||||
(11, 'Vehicle', 'AW-TestSampleVehicle', '[furive-vehicle]AW-TestSampleVehicle_v1.0.0_ws'),
|
||||
(12, 'Module', 'vehicle-f1', 'vehicle-f1_v1.0.0_ws'),
|
||||
(13, 'Vehicle', 'AW-SampleVehicle', '[furive-vehicle]AW-SampleVehicle_v1.0.0_ws'),
|
||||
(14, 'Control', 'AW-Control', '[furive-control]AW-Control_v1.0.0_ws'),
|
||||
(15, 'Map', 'AW-SampleMapRosBag', '[furive-map]AW-SampleMapRosBag_v1.0.0_ws'),
|
||||
(16, 'Sensing', 'AW-SampleSensor', '[furive-sensing]AW-SampleSensor_v1.0.0_ws')
|
||||
)
|
||||
INSERT INTO "course_week_modules" ("week_id", "sort_order", "category", "name", "folder_name")
|
||||
SELECT target_week.week_id, seed.sort_order, seed.category, seed.name, seed.folder_name
|
||||
FROM target_week
|
||||
CROSS JOIN seed
|
||||
ON CONFLICT ("week_id", "folder_name") DO UPDATE
|
||||
SET "sort_order" = EXCLUDED."sort_order",
|
||||
"category" = EXCLUDED."category",
|
||||
"name" = EXCLUDED."name",
|
||||
"updated_at" = CURRENT_TIMESTAMP;
|
||||
@@ -0,0 +1,18 @@
|
||||
DELETE FROM "course_week_modules";
|
||||
|
||||
WITH seed(sort_order, category, name, folder_name) AS (
|
||||
VALUES
|
||||
(1, 'Gui', 'AW-Rviz', '[furive-gui]AW-Rviz_v1.0.0_ws'),
|
||||
(2, 'Map', 'AW-SampleMap', '[furive-map]AW-SampleMap_v1.0.0_ws'),
|
||||
(3, 'Planning', 'AW-Planning', '[furive-planning]AW-Planning_v1.0.0_ws'),
|
||||
(4, 'Perception', 'AW-Perception', '[furive-perception]AW-Perception_v1.0.0_ws'),
|
||||
(5, 'Localization', 'AW-Localization', '[furive-localization]AW-Localization_v1.0.0_ws'),
|
||||
(6, 'System', 'AW-System', '[furive-system]AW-System_v1.0.0_ws'),
|
||||
(7, 'Vehicle', 'AW-SampleVehicle', '[furive-vehicle]AW-SampleVehicle_v1.0.0_ws'),
|
||||
(8, 'Control', 'AW-Control', '[furive-control]AW-Control_v1.0.0_ws'),
|
||||
(9, 'Sensing', 'AW-SampleSensor', '[furive-sensing]AW-SampleSensor_v1.0.0_ws')
|
||||
)
|
||||
INSERT INTO "course_week_modules" ("week_id", "sort_order", "category", "name", "folder_name")
|
||||
SELECT w."id", seed.sort_order, seed.category, seed.name, seed.folder_name
|
||||
FROM "course_weeks" w
|
||||
CROSS JOIN seed;
|
||||
@@ -0,0 +1,24 @@
|
||||
UPDATE "course_week_modules"
|
||||
SET "template_repository_url" = CASE "name"
|
||||
WHEN 'AW-Rviz' THEN 'http://localhost:3000/templates/aw-rviz.git'
|
||||
WHEN 'AW-SampleMap' THEN 'http://localhost:3000/templates/aw-samplemap.git'
|
||||
WHEN 'AW-Planning' THEN 'http://localhost:3000/templates/aw-planning.git'
|
||||
WHEN 'AW-Perception' THEN 'http://localhost:3000/templates/aw-perception.git'
|
||||
WHEN 'AW-Localization' THEN 'http://localhost:3000/templates/aw-localization.git'
|
||||
WHEN 'AW-System' THEN 'http://localhost:3000/templates/aw-system.git'
|
||||
WHEN 'AW-SampleVehicle' THEN 'http://localhost:3000/templates/aw-samplevehicle.git'
|
||||
WHEN 'AW-Control' THEN 'http://localhost:3000/templates/aw-control.git'
|
||||
WHEN 'AW-SampleSensor' THEN 'http://localhost:3000/templates/aw-samplesensor.git'
|
||||
ELSE "template_repository_url"
|
||||
END
|
||||
WHERE "name" IN (
|
||||
'AW-Rviz',
|
||||
'AW-SampleMap',
|
||||
'AW-Planning',
|
||||
'AW-Perception',
|
||||
'AW-Localization',
|
||||
'AW-System',
|
||||
'AW-SampleVehicle',
|
||||
'AW-Control',
|
||||
'AW-SampleSensor'
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
CREATE TABLE "studio_repositories" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"user_id" UUID NOT NULL,
|
||||
"week_id" UUID NOT NULL,
|
||||
"module_id" UUID NOT NULL,
|
||||
"repo_owner" TEXT NOT NULL,
|
||||
"repo_name" TEXT NOT NULL,
|
||||
"repository_url" TEXT NOT NULL,
|
||||
"template_repository_url" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "studio_repositories_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "studio_repositories_user_id_module_id_key" ON "studio_repositories"("user_id", "module_id");
|
||||
CREATE INDEX "studio_repositories_week_id_idx" ON "studio_repositories"("week_id");
|
||||
CREATE INDEX "studio_repositories_repo_owner_repo_name_idx" ON "studio_repositories"("repo_owner", "repo_name");
|
||||
|
||||
ALTER TABLE "studio_repositories"
|
||||
ADD CONSTRAINT "studio_repositories_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "studio_repositories"
|
||||
ADD CONSTRAINT "studio_repositories_module_id_fkey"
|
||||
FOREIGN KEY ("module_id") REFERENCES "course_week_modules"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
COMMENT ON TABLE "studio_repositories" IS '개발 스튜디오 사용자별 모듈 Git 저장소 매핑.';
|
||||
@@ -0,0 +1,31 @@
|
||||
WITH target_weeks AS (
|
||||
SELECT
|
||||
w."id" AS week_id,
|
||||
COALESCE(MAX(m."sort_order"), 0) + 1 AS next_sort_order
|
||||
FROM "course_weeks" w
|
||||
LEFT JOIN "course_week_modules" m ON m."week_id" = w."id"
|
||||
GROUP BY w."id"
|
||||
)
|
||||
INSERT INTO "course_week_modules" (
|
||||
"week_id",
|
||||
"sort_order",
|
||||
"category",
|
||||
"name",
|
||||
"folder_name",
|
||||
"template_repository_url"
|
||||
)
|
||||
SELECT
|
||||
target_weeks.week_id,
|
||||
target_weeks.next_sort_order,
|
||||
'Simulator',
|
||||
'AW-PlanningSim',
|
||||
'[furive-simulator]AW-PlanningSim_v1.0.0_ws',
|
||||
'http://localhost:3000/templates/aw-planningsim'
|
||||
FROM target_weeks
|
||||
ON CONFLICT ("week_id", "folder_name") DO UPDATE
|
||||
SET
|
||||
"sort_order" = EXCLUDED."sort_order",
|
||||
"category" = EXCLUDED."category",
|
||||
"name" = EXCLUDED."name",
|
||||
"template_repository_url" = EXCLUDED."template_repository_url",
|
||||
"updated_at" = CURRENT_TIMESTAMP;
|
||||
@@ -0,0 +1,9 @@
|
||||
DELETE FROM "course_week_modules" m
|
||||
USING "course_weeks" w
|
||||
WHERE m."week_id" = w."id"
|
||||
AND w."week" = 3
|
||||
AND m."name" IN (
|
||||
'AW-Perception',
|
||||
'AW-Localization',
|
||||
'AW-SampleSensor'
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE "platforms"
|
||||
ADD COLUMN "default_studio_settings" JSONB NOT NULL DEFAULT '{"workspaceRoot":"~/autonomy-studio/workspaces","giteaBaseUrl":"http://localhost:3000","giteaOwner":"students"}';
|
||||
|
||||
ALTER TABLE "instructor_platforms"
|
||||
ADD COLUMN "studio_settings_override" JSONB;
|
||||
|
||||
UPDATE "instructor_platforms" AS ip
|
||||
SET "studio_settings_override" = p."default_studio_settings"
|
||||
FROM "platforms" AS p
|
||||
WHERE ip."platform_id" = p."id"
|
||||
AND ip."studio_settings_override" IS NULL;
|
||||
@@ -0,0 +1,37 @@
|
||||
WITH priority(name, folder_name, sort_order) AS (
|
||||
VALUES
|
||||
('AW-SampleVehicle', '[furive-vehicle]AW-SampleVehicle_v1.0.0_ws', 1),
|
||||
('AW-SampleSensor', '[furive-sensing]AW-SampleSensor_v1.0.0_ws', 2),
|
||||
('AW-SampleMap', '[furive-map]AW-SampleMap_v1.0.0_ws', 3),
|
||||
('AW-Localization', '[furive-localization]AW-Localization_v1.0.0_ws', 4),
|
||||
('AW-Perception', '[furive-perception]AW-Perception_v1.0.0_ws', 5),
|
||||
('AW-Planning', '[furive-planning]AW-Planning_v1.0.0_ws', 6),
|
||||
('AW-Control', '[furive-control]AW-Control_v1.0.0_ws', 7),
|
||||
('AW-System', '[furive-system]AW-System_v1.0.0_ws', 8),
|
||||
('AW-Rviz', '[furive-gui]AW-Rviz_v1.0.0_ws', 9),
|
||||
('AW-PlanningSim', '[furive-simulator]AW-PlanningSim_v1.0.0_ws', 10)
|
||||
)
|
||||
UPDATE "course_week_modules" module
|
||||
SET "sort_order" = priority.sort_order + 1000,
|
||||
"updated_at" = CURRENT_TIMESTAMP
|
||||
FROM priority
|
||||
WHERE module."folder_name" = priority.folder_name;
|
||||
|
||||
WITH priority(name, folder_name, sort_order) AS (
|
||||
VALUES
|
||||
('AW-SampleVehicle', '[furive-vehicle]AW-SampleVehicle_v1.0.0_ws', 1),
|
||||
('AW-SampleSensor', '[furive-sensing]AW-SampleSensor_v1.0.0_ws', 2),
|
||||
('AW-SampleMap', '[furive-map]AW-SampleMap_v1.0.0_ws', 3),
|
||||
('AW-Localization', '[furive-localization]AW-Localization_v1.0.0_ws', 4),
|
||||
('AW-Perception', '[furive-perception]AW-Perception_v1.0.0_ws', 5),
|
||||
('AW-Planning', '[furive-planning]AW-Planning_v1.0.0_ws', 6),
|
||||
('AW-Control', '[furive-control]AW-Control_v1.0.0_ws', 7),
|
||||
('AW-System', '[furive-system]AW-System_v1.0.0_ws', 8),
|
||||
('AW-Rviz', '[furive-gui]AW-Rviz_v1.0.0_ws', 9),
|
||||
('AW-PlanningSim', '[furive-simulator]AW-PlanningSim_v1.0.0_ws', 10)
|
||||
)
|
||||
UPDATE "course_week_modules" module
|
||||
SET "sort_order" = priority.sort_order,
|
||||
"updated_at" = CURRENT_TIMESTAMP
|
||||
FROM priority
|
||||
WHERE module."folder_name" = priority.folder_name;
|
||||
@@ -0,0 +1,122 @@
|
||||
CREATE TABLE "course_environment_profiles" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"course_id" UUID NOT NULL,
|
||||
"week" INTEGER NOT NULL,
|
||||
"platform_key" TEXT NOT NULL DEFAULT 'futuredrive-local-pc',
|
||||
"profile_type" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"config" JSONB NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "course_environment_profiles_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "course_environment_profiles_course_id_week_platform_key_key"
|
||||
ON "course_environment_profiles"("course_id", "week", "platform_key");
|
||||
CREATE INDEX "course_environment_profiles_course_id_week_idx"
|
||||
ON "course_environment_profiles"("course_id", "week");
|
||||
CREATE INDEX "course_environment_profiles_profile_type_idx"
|
||||
ON "course_environment_profiles"("profile_type");
|
||||
|
||||
ALTER TABLE "course_environment_profiles"
|
||||
ADD CONSTRAINT "course_environment_profiles_course_id_fkey"
|
||||
FOREIGN KEY ("course_id")
|
||||
REFERENCES "courses"("id")
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE;
|
||||
|
||||
COMMENT ON TABLE "course_environment_profiles" IS '강좌/주차/플랫폼별 개발환경 실행 프로파일.';
|
||||
COMMENT ON COLUMN "course_environment_profiles"."config" IS 'Docker runtime, ROS launch, bridge, viewer 실행 설정 JSON.';
|
||||
|
||||
INSERT INTO "course_environment_profiles" (
|
||||
"course_id",
|
||||
"week",
|
||||
"platform_key",
|
||||
"profile_type",
|
||||
"name",
|
||||
"config",
|
||||
"updated_at"
|
||||
)
|
||||
SELECT
|
||||
course."id",
|
||||
3,
|
||||
'futuredrive-local-pc',
|
||||
'SLAM',
|
||||
'TurtleBot3 SLAM 로컬 시뮬레이션',
|
||||
'{
|
||||
"type": "SLAM",
|
||||
"viewer": {
|
||||
"type": "slam",
|
||||
"url": "/slam-viewer/index.html",
|
||||
"healthUrl": "http://localhost:8765/health"
|
||||
},
|
||||
"runtime": {
|
||||
"containers": [
|
||||
{
|
||||
"name": "nav2-slam",
|
||||
"image": "nav2-slam:humble",
|
||||
"network": "host",
|
||||
"privileged": true,
|
||||
"env": {
|
||||
"DISPLAY": ":1",
|
||||
"QT_X11_NO_MITSHM": "1",
|
||||
"LIBGL_ALWAYS_SOFTWARE": "1",
|
||||
"TURTLEBOT3_MODEL": "burger"
|
||||
},
|
||||
"volumes": [
|
||||
"/tmp/.X11-unix:/tmp/.X11-unix",
|
||||
"/dev/shm:/dev/shm",
|
||||
"~/.gazebo:/root/.gazebo",
|
||||
"~/.cache:/root/.cache"
|
||||
],
|
||||
"command": "tail -f /dev/null"
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"container": "nav2-slam",
|
||||
"mode": "execDetached",
|
||||
"delayMs": 0,
|
||||
"command": "ros2 launch gazebo_ros gzserver.launch.py world:=/opt/ros/humble/share/turtlebot3_gazebo/worlds/turtlebot3_world.world & ros2 launch turtlebot3_gazebo robot_state_publisher.launch.py use_sim_time:=true & sleep 6; ros2 launch turtlebot3_gazebo spawn_turtlebot3.launch.py x_pose:=-2.0 y_pose:=-0.5; wait",
|
||||
"logFile": "/tmp/turtlebot3_gazebo.log"
|
||||
},
|
||||
{
|
||||
"container": "nav2-slam",
|
||||
"mode": "execDetached",
|
||||
"delayMs": 6000,
|
||||
"command": "ros2 launch slam_toolbox online_async_launch.py use_sim_time:=true",
|
||||
"logFile": "/tmp/slam_toolbox.log"
|
||||
},
|
||||
{
|
||||
"container": "nav2-slam",
|
||||
"mode": "execDetached",
|
||||
"delayMs": 3000,
|
||||
"command": "ros2 launch nav2_bringup navigation_launch.py use_sim_time:=true autostart:=true",
|
||||
"logFile": "/tmp/nav2.log"
|
||||
}
|
||||
]
|
||||
},
|
||||
"bridge": {
|
||||
"name": "slam-ros-bridge",
|
||||
"image": "slam-ros-bridge",
|
||||
"buildContext": "resources/slam-ros-bridge",
|
||||
"port": 8765,
|
||||
"env": {
|
||||
"ODOM_TOPIC": "/odom",
|
||||
"SCAN_TOPIC": "/scan",
|
||||
"MAP_TOPIC": "/map",
|
||||
"CMD_VEL_TOPIC": "/cmd_vel",
|
||||
"NAVIGATE_TO_POSE_ACTION": "/navigate_to_pose"
|
||||
}
|
||||
}
|
||||
}'::jsonb,
|
||||
CURRENT_TIMESTAMP
|
||||
FROM "courses" AS course
|
||||
ON CONFLICT ("course_id", "week", "platform_key") DO UPDATE
|
||||
SET
|
||||
"profile_type" = EXCLUDED."profile_type",
|
||||
"name" = EXCLUDED."name",
|
||||
"config" = EXCLUDED."config",
|
||||
"enabled" = true,
|
||||
"updated_at" = CURRENT_TIMESTAMP;
|
||||
@@ -0,0 +1,175 @@
|
||||
INSERT INTO "course_weeks" (
|
||||
"course_id",
|
||||
"week",
|
||||
"title",
|
||||
"tags",
|
||||
"progress",
|
||||
"state",
|
||||
"duration",
|
||||
"github",
|
||||
"overview",
|
||||
"deliverable",
|
||||
"updated_at"
|
||||
)
|
||||
SELECT
|
||||
course."id",
|
||||
6,
|
||||
'Autoware 기반 자율주행',
|
||||
ARRAY['Autoware','진행예정']::TEXT[],
|
||||
0,
|
||||
'locked',
|
||||
'10-12시간',
|
||||
'미제출',
|
||||
'Autoware 모듈을 실행하고 시각화 화면에서 초기 위치와 목표 포즈를 설정해 차량이 경로를 따라 주행하는 흐름을 검증합니다.',
|
||||
'초기 위치/목표 포즈 설정값, 주행 로그, 경로 계획 및 제어 결과를 정리해 제출',
|
||||
CURRENT_TIMESTAMP
|
||||
FROM "courses" AS course
|
||||
ON CONFLICT ("course_id", "week") DO UPDATE
|
||||
SET
|
||||
"title" = EXCLUDED."title",
|
||||
"tags" = EXCLUDED."tags",
|
||||
"progress" = EXCLUDED."progress",
|
||||
"state" = EXCLUDED."state",
|
||||
"duration" = EXCLUDED."duration",
|
||||
"github" = EXCLUDED."github",
|
||||
"overview" = EXCLUDED."overview",
|
||||
"deliverable" = EXCLUDED."deliverable",
|
||||
"updated_at" = CURRENT_TIMESTAMP;
|
||||
|
||||
WITH target_weeks AS (
|
||||
SELECT w."id" AS week_id
|
||||
FROM "course_weeks" w
|
||||
WHERE w."week" = 6
|
||||
)
|
||||
INSERT INTO "course_week_topics" ("week_id", "sort_order", "title", "detail")
|
||||
SELECT target_weeks.week_id, topic.sort_order, topic.title, topic.detail
|
||||
FROM target_weeks
|
||||
CROSS JOIN (
|
||||
VALUES
|
||||
(1, 'Autoware 실행 구조', '차량, 센서, 지도, 위치추정, 인지, 계획, 제어 모듈의 실행 순서와 ROS2 토픽 흐름을 확인합니다.'),
|
||||
(2, '초기 위치 설정', '시각화 화면에서 초기 위치를 지정하고 Localization 상태와 차량 pose 갱신을 검증합니다.'),
|
||||
(3, '목표 포즈와 경로 계획', '목표 포즈를 지정해 mission planning과 behavior planning 결과 경로를 확인합니다.'),
|
||||
(4, '제어 명령과 주행 검증', '계획된 경로를 따라 차량이 움직이는지 제어 명령, 상태 토픽, 로그를 기준으로 점검합니다.')
|
||||
) AS topic(sort_order, title, detail)
|
||||
ON CONFLICT ("week_id", "sort_order") DO UPDATE
|
||||
SET
|
||||
"title" = EXCLUDED."title",
|
||||
"detail" = EXCLUDED."detail";
|
||||
|
||||
WITH target_weeks AS (
|
||||
SELECT w."id" AS week_id
|
||||
FROM "course_weeks" w
|
||||
WHERE w."week" = 6
|
||||
)
|
||||
INSERT INTO "course_week_practices" ("week_id", "sort_order", "title", "description")
|
||||
SELECT target_weeks.week_id, practice.sort_order, practice.title, practice.description
|
||||
FROM target_weeks
|
||||
CROSS JOIN (
|
||||
VALUES
|
||||
(1, 'Autoware 모듈 실행', '주차에 등록된 Autoware 모듈을 순서대로 실행하고 컨테이너 상태를 확인합니다.'),
|
||||
(2, '초기 위치 지정', '시각화에서 초기 위치를 지정하고 차량 위치가 지도 좌표계에 정상 반영되는지 확인합니다.'),
|
||||
(3, '목표 포즈 지정', '목표 지점을 설정하고 생성된 route와 주행 상태를 비교합니다.'),
|
||||
(4, '주행 결과 제출', '수정한 소스, 실행 로그, 주행 검증 결과를 제출합니다.')
|
||||
) AS practice(sort_order, title, description)
|
||||
ON CONFLICT ("week_id", "sort_order") DO UPDATE
|
||||
SET
|
||||
"title" = EXCLUDED."title",
|
||||
"description" = EXCLUDED."description";
|
||||
|
||||
WITH week_pairs AS (
|
||||
SELECT
|
||||
source_week."id" AS source_week_id,
|
||||
target_week."id" AS target_week_id
|
||||
FROM "course_weeks" source_week
|
||||
JOIN "course_weeks" target_week
|
||||
ON target_week."course_id" = source_week."course_id"
|
||||
AND target_week."week" = 6
|
||||
WHERE source_week."week" = 3
|
||||
)
|
||||
INSERT INTO "course_week_modules" (
|
||||
"week_id",
|
||||
"sort_order",
|
||||
"category",
|
||||
"name",
|
||||
"folder_name",
|
||||
"template_repository_url",
|
||||
"docker_image",
|
||||
"updated_at"
|
||||
)
|
||||
SELECT
|
||||
week_pairs.target_week_id,
|
||||
source_module."sort_order",
|
||||
source_module."category",
|
||||
source_module."name",
|
||||
source_module."folder_name",
|
||||
source_module."template_repository_url",
|
||||
source_module."docker_image",
|
||||
CURRENT_TIMESTAMP
|
||||
FROM week_pairs
|
||||
JOIN "course_week_modules" source_module ON source_module."week_id" = week_pairs.source_week_id
|
||||
ON CONFLICT ("week_id", "folder_name") DO UPDATE
|
||||
SET
|
||||
"sort_order" = EXCLUDED."sort_order",
|
||||
"category" = EXCLUDED."category",
|
||||
"name" = EXCLUDED."name",
|
||||
"template_repository_url" = EXCLUDED."template_repository_url",
|
||||
"docker_image" = EXCLUDED."docker_image",
|
||||
"updated_at" = CURRENT_TIMESTAMP;
|
||||
|
||||
INSERT INTO "course_environment_profiles" (
|
||||
"course_id",
|
||||
"week",
|
||||
"platform_key",
|
||||
"profile_type",
|
||||
"name",
|
||||
"config",
|
||||
"updated_at"
|
||||
)
|
||||
SELECT
|
||||
course."id",
|
||||
6,
|
||||
'futuredrive-local-pc',
|
||||
'AUTOWARE',
|
||||
'Autoware 로컬 자율주행',
|
||||
'{
|
||||
"type": "autoware",
|
||||
"viewer": {
|
||||
"type": "autoware",
|
||||
"url": "/autoware-viewer/index.html",
|
||||
"healthUrl": "http://localhost:8765/health"
|
||||
},
|
||||
"runtime": {
|
||||
"containers": [],
|
||||
"steps": []
|
||||
},
|
||||
"bridge": {
|
||||
"name": "autoware-ros-bridge",
|
||||
"image": "autoware-ros-bridge",
|
||||
"port": 8765,
|
||||
"env": {
|
||||
"ROBOT_STACK": "autoware",
|
||||
"MAP_POINT_STRIDE": "8",
|
||||
"MAP_POINT_LIMIT": "80000",
|
||||
"AUTOWARE_ROUTE_INPUT": "rviz_topic",
|
||||
"INITIAL_POSE_TOPIC": "/initialpose",
|
||||
"GOAL_POSE_TOPIC": "/planning/mission_planning/goal",
|
||||
"VEHICLE_POSE_TOPIC": "/localization/kinematic_state",
|
||||
"TRAJECTORY_TOPIC": "/planning/scenario_planning/trajectory",
|
||||
"ROUTE_TOPIC": "/planning/mission_planning/route"
|
||||
}
|
||||
}
|
||||
}'::jsonb,
|
||||
CURRENT_TIMESTAMP
|
||||
FROM "courses" AS course
|
||||
ON CONFLICT ("course_id", "week", "platform_key") DO UPDATE
|
||||
SET
|
||||
"profile_type" = EXCLUDED."profile_type",
|
||||
"name" = EXCLUDED."name",
|
||||
"config" = EXCLUDED."config",
|
||||
"enabled" = true,
|
||||
"updated_at" = CURRENT_TIMESTAMP;
|
||||
|
||||
UPDATE "course_student_progress"
|
||||
SET
|
||||
"total_weeks" = GREATEST("total_weeks", 6),
|
||||
"updated_at" = CURRENT_TIMESTAMP;
|
||||
@@ -0,0 +1,25 @@
|
||||
ALTER TABLE "instructor_curriculum_settings"
|
||||
ADD COLUMN "group_id" UUID;
|
||||
|
||||
ALTER TABLE "instructor_curriculum_settings"
|
||||
ADD CONSTRAINT "instructor_curriculum_settings_group_id_fkey"
|
||||
FOREIGN KEY ("group_id")
|
||||
REFERENCES "student_groups"("id")
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE;
|
||||
|
||||
DROP INDEX IF EXISTS "instructor_curriculum_settings_organization_name_key";
|
||||
|
||||
CREATE UNIQUE INDEX "instructor_curriculum_settings_organization_default_key"
|
||||
ON "instructor_curriculum_settings"("organization_name")
|
||||
WHERE "group_id" IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX "instructor_curriculum_settings_organization_group_key"
|
||||
ON "instructor_curriculum_settings"("organization_name", "group_id")
|
||||
WHERE "group_id" IS NOT NULL;
|
||||
|
||||
CREATE INDEX "instructor_curriculum_settings_organization_name_idx"
|
||||
ON "instructor_curriculum_settings"("organization_name");
|
||||
|
||||
CREATE INDEX "instructor_curriculum_settings_group_id_idx"
|
||||
ON "instructor_curriculum_settings"("group_id");
|
||||
@@ -0,0 +1,38 @@
|
||||
ALTER TABLE "student_enrollments"
|
||||
ADD COLUMN "course_id" UUID;
|
||||
|
||||
UPDATE "student_enrollments" enrollment
|
||||
SET "course_id" = course."id"
|
||||
FROM "courses" course
|
||||
WHERE course."slug" = 'futuredrive-autonomy-basic'
|
||||
AND course."is_active" = true
|
||||
AND enrollment."course_id" IS NULL;
|
||||
|
||||
ALTER TABLE "student_enrollments"
|
||||
ALTER COLUMN "course_id" SET NOT NULL;
|
||||
|
||||
ALTER TABLE "student_enrollments"
|
||||
ADD CONSTRAINT "student_enrollments_course_id_fkey"
|
||||
FOREIGN KEY ("course_id")
|
||||
REFERENCES "courses"("id")
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE;
|
||||
|
||||
DROP INDEX IF EXISTS "student_enrollments_user_id_organization_name_key";
|
||||
|
||||
CREATE UNIQUE INDEX "student_enrollments_user_id_course_id_key"
|
||||
ON "student_enrollments"("user_id", "course_id");
|
||||
|
||||
CREATE INDEX "student_enrollments_course_id_status_idx"
|
||||
ON "student_enrollments"("course_id", "status");
|
||||
|
||||
DROP INDEX IF EXISTS "instructor_curriculum_settings_organization_default_key";
|
||||
DROP INDEX IF EXISTS "instructor_curriculum_settings_organization_group_key";
|
||||
|
||||
CREATE UNIQUE INDEX "instructor_curriculum_settings_course_default_key"
|
||||
ON "instructor_curriculum_settings"("organization_name", "course_id")
|
||||
WHERE "group_id" IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX "instructor_curriculum_settings_course_group_key"
|
||||
ON "instructor_curriculum_settings"("organization_name", "course_id", "group_id")
|
||||
WHERE "group_id" IS NOT NULL;
|
||||
@@ -0,0 +1,33 @@
|
||||
WITH week3 AS (
|
||||
SELECT "id" AS week_id
|
||||
FROM "course_weeks"
|
||||
WHERE "week" = 3
|
||||
),
|
||||
desired(sort_order, title, detail) AS (
|
||||
VALUES
|
||||
(1, 'SLAM 개요', '동시적 위치추정 및 지도작성의 기본 개념'),
|
||||
(2, 'Cartographer 설정', 'ROS2 Cartographer 패키지 설정 방법'),
|
||||
(3, '실습: 맵 생성', 'LiDAR 데이터를 활용한 실시간 맵핑'),
|
||||
(4, '위치 추정', '생성된 맵에서 로봇 위치 인식')
|
||||
)
|
||||
INSERT INTO "course_week_topics" ("week_id", "sort_order", "title", "detail")
|
||||
SELECT week3.week_id, desired.sort_order, desired.title, desired.detail
|
||||
FROM week3
|
||||
CROSS JOIN desired
|
||||
ON CONFLICT ("week_id", "sort_order") DO UPDATE
|
||||
SET
|
||||
"title" = EXCLUDED."title",
|
||||
"detail" = EXCLUDED."detail";
|
||||
|
||||
DELETE FROM "course_week_topics" topic
|
||||
USING "course_weeks" week
|
||||
WHERE topic."week_id" = week."id"
|
||||
AND week."week" = 3
|
||||
AND topic."sort_order" > 4;
|
||||
|
||||
UPDATE "instructor_curriculum_week_overrides"
|
||||
SET
|
||||
"topics" = '["SLAM 개요","Cartographer 설정","실습: 맵 생성","위치 추정"]'::jsonb,
|
||||
"topic_details" = '["동시적 위치추정 및 지도작성의 기본 개념","ROS2 Cartographer 패키지 설정 방법","LiDAR 데이터를 활용한 실시간 맵핑","생성된 맵에서 로봇 위치 인식"]'::jsonb,
|
||||
"updated_at" = CURRENT_TIMESTAMP
|
||||
WHERE "week" = 3;
|
||||
@@ -0,0 +1,4 @@
|
||||
UPDATE "courses"
|
||||
SET "description" = NULL,
|
||||
"updated_at" = CURRENT_TIMESTAMP
|
||||
WHERE "description" IS NOT NULL;
|
||||
@@ -0,0 +1,216 @@
|
||||
UPDATE "courses"
|
||||
SET "organization_name" = '퓨처드라이브',
|
||||
"updated_at" = CURRENT_TIMESTAMP
|
||||
WHERE "slug" = 'futuredrive-autonomy-basic'
|
||||
AND "organization_name" = '공통';
|
||||
|
||||
CREATE TEMP TABLE "_course_split_source" AS
|
||||
SELECT "id" AS source_course_id
|
||||
FROM "courses"
|
||||
WHERE "slug" = 'futuredrive-autonomy-basic'
|
||||
LIMIT 1;
|
||||
|
||||
INSERT INTO "courses" ("slug", "title", "subtitle", "description", "organization_name", "teacher_id", "is_active", "updated_at")
|
||||
SELECT
|
||||
'yeungnam-autonomy-basic',
|
||||
source."title",
|
||||
source."subtitle",
|
||||
source."description",
|
||||
'영남대학교',
|
||||
teacher."id",
|
||||
true,
|
||||
CURRENT_TIMESTAMP
|
||||
FROM "_course_split_source" split
|
||||
JOIN "courses" source ON source."id" = split.source_course_id
|
||||
JOIN "users" teacher ON teacher."email" = 'teacher@yu.ac.kr'
|
||||
ON CONFLICT ("slug") DO UPDATE
|
||||
SET "title" = EXCLUDED."title",
|
||||
"subtitle" = EXCLUDED."subtitle",
|
||||
"description" = EXCLUDED."description",
|
||||
"organization_name" = EXCLUDED."organization_name",
|
||||
"teacher_id" = EXCLUDED."teacher_id",
|
||||
"is_active" = true,
|
||||
"updated_at" = CURRENT_TIMESTAMP;
|
||||
|
||||
CREATE TEMP TABLE "_course_split_target" AS
|
||||
SELECT "id" AS target_course_id
|
||||
FROM "courses"
|
||||
WHERE "slug" = 'yeungnam-autonomy-basic'
|
||||
LIMIT 1;
|
||||
|
||||
INSERT INTO "course_platforms" ("course_id", "platform_id", "status", "action", "state", "sort_order", "updated_at")
|
||||
SELECT target.target_course_id, platform."platform_id", platform."status", platform."action", platform."state", platform."sort_order", CURRENT_TIMESTAMP
|
||||
FROM "course_platforms" platform
|
||||
JOIN "_course_split_source" source ON source.source_course_id = platform."course_id"
|
||||
CROSS JOIN "_course_split_target" target
|
||||
ON CONFLICT ("course_id", "platform_id") DO UPDATE
|
||||
SET "status" = EXCLUDED."status",
|
||||
"action" = EXCLUDED."action",
|
||||
"state" = EXCLUDED."state",
|
||||
"sort_order" = EXCLUDED."sort_order",
|
||||
"updated_at" = CURRENT_TIMESTAMP;
|
||||
|
||||
INSERT INTO "course_environment_profiles" ("course_id", "week", "platform_key", "profile_type", "name", "config", "enabled", "updated_at")
|
||||
SELECT target.target_course_id, profile."week", profile."platform_key", profile."profile_type", profile."name", profile."config", profile."enabled", CURRENT_TIMESTAMP
|
||||
FROM "course_environment_profiles" profile
|
||||
JOIN "_course_split_source" source ON source.source_course_id = profile."course_id"
|
||||
CROSS JOIN "_course_split_target" target
|
||||
ON CONFLICT ("course_id", "week", "platform_key") DO UPDATE
|
||||
SET "profile_type" = EXCLUDED."profile_type",
|
||||
"name" = EXCLUDED."name",
|
||||
"config" = EXCLUDED."config",
|
||||
"enabled" = EXCLUDED."enabled",
|
||||
"updated_at" = CURRENT_TIMESTAMP;
|
||||
|
||||
INSERT INTO "course_weeks" ("course_id", "week", "title", "tags", "progress", "state", "duration", "github", "overview", "deliverable", "updated_at")
|
||||
SELECT target.target_course_id, week."week", week."title", week."tags", week."progress", week."state", week."duration", week."github", week."overview", week."deliverable", CURRENT_TIMESTAMP
|
||||
FROM "course_weeks" week
|
||||
JOIN "_course_split_source" source ON source.source_course_id = week."course_id"
|
||||
CROSS JOIN "_course_split_target" target
|
||||
ON CONFLICT ("course_id", "week") DO UPDATE
|
||||
SET "title" = EXCLUDED."title",
|
||||
"tags" = EXCLUDED."tags",
|
||||
"progress" = EXCLUDED."progress",
|
||||
"state" = EXCLUDED."state",
|
||||
"duration" = EXCLUDED."duration",
|
||||
"github" = EXCLUDED."github",
|
||||
"overview" = EXCLUDED."overview",
|
||||
"deliverable" = EXCLUDED."deliverable",
|
||||
"updated_at" = CURRENT_TIMESTAMP;
|
||||
|
||||
CREATE TEMP TABLE "_course_split_week_map" AS
|
||||
SELECT source_week."id" AS source_week_id, target_week."id" AS target_week_id, source_week."week"
|
||||
FROM "course_weeks" source_week
|
||||
JOIN "_course_split_source" source ON source.source_course_id = source_week."course_id"
|
||||
JOIN "_course_split_target" target ON true
|
||||
JOIN "course_weeks" target_week ON target_week."course_id" = target.target_course_id AND target_week."week" = source_week."week";
|
||||
|
||||
INSERT INTO "course_week_topics" ("week_id", "sort_order", "title", "detail")
|
||||
SELECT week_map.target_week_id, topic."sort_order", topic."title", topic."detail"
|
||||
FROM "course_week_topics" topic
|
||||
JOIN "_course_split_week_map" week_map ON week_map.source_week_id = topic."week_id"
|
||||
ON CONFLICT ("week_id", "sort_order") DO UPDATE
|
||||
SET "title" = EXCLUDED."title",
|
||||
"detail" = EXCLUDED."detail";
|
||||
|
||||
INSERT INTO "course_week_practices" ("week_id", "sort_order", "title", "description")
|
||||
SELECT week_map.target_week_id, practice."sort_order", practice."title", practice."description"
|
||||
FROM "course_week_practices" practice
|
||||
JOIN "_course_split_week_map" week_map ON week_map.source_week_id = practice."week_id"
|
||||
ON CONFLICT ("week_id", "sort_order") DO UPDATE
|
||||
SET "title" = EXCLUDED."title",
|
||||
"description" = EXCLUDED."description";
|
||||
|
||||
INSERT INTO "course_week_modules" ("week_id", "sort_order", "category", "name", "folder_name", "template_repository_url", "docker_image", "updated_at")
|
||||
SELECT week_map.target_week_id, module."sort_order", module."category", module."name", module."folder_name", module."template_repository_url", module."docker_image", CURRENT_TIMESTAMP
|
||||
FROM "course_week_modules" module
|
||||
JOIN "_course_split_week_map" week_map ON week_map.source_week_id = module."week_id"
|
||||
ON CONFLICT ("week_id", "folder_name") DO UPDATE
|
||||
SET "sort_order" = EXCLUDED."sort_order",
|
||||
"category" = EXCLUDED."category",
|
||||
"name" = EXCLUDED."name",
|
||||
"template_repository_url" = EXCLUDED."template_repository_url",
|
||||
"docker_image" = EXCLUDED."docker_image",
|
||||
"updated_at" = CURRENT_TIMESTAMP;
|
||||
|
||||
CREATE TEMP TABLE "_course_split_module_map" AS
|
||||
SELECT source_module."id" AS source_module_id, target_module."id" AS target_module_id, week_map.source_week_id, week_map.target_week_id
|
||||
FROM "course_week_modules" source_module
|
||||
JOIN "_course_split_week_map" week_map ON week_map.source_week_id = source_module."week_id"
|
||||
JOIN "course_week_modules" target_module ON target_module."week_id" = week_map.target_week_id AND target_module."folder_name" = source_module."folder_name";
|
||||
|
||||
INSERT INTO "student_enrollments" ("user_id", "course_id", "organization_id", "organization_name", "status", "removed_at", "updated_at")
|
||||
SELECT enrollment."user_id", target.target_course_id, enrollment."organization_id", enrollment."organization_name", enrollment."status", enrollment."removed_at", CURRENT_TIMESTAMP
|
||||
FROM "student_enrollments" enrollment
|
||||
JOIN "_course_split_source" source ON source.source_course_id = enrollment."course_id"
|
||||
CROSS JOIN "_course_split_target" target
|
||||
WHERE enrollment."organization_name" = '영남대학교'
|
||||
ON CONFLICT ("user_id", "course_id") DO UPDATE
|
||||
SET "organization_id" = EXCLUDED."organization_id",
|
||||
"organization_name" = EXCLUDED."organization_name",
|
||||
"status" = EXCLUDED."status",
|
||||
"removed_at" = EXCLUDED."removed_at",
|
||||
"updated_at" = CURRENT_TIMESTAMP;
|
||||
|
||||
DELETE FROM "student_enrollments" enrollment
|
||||
USING "_course_split_source" source
|
||||
WHERE enrollment."course_id" = source.source_course_id
|
||||
AND enrollment."organization_name" = '영남대학교';
|
||||
|
||||
INSERT INTO "course_student_progress" ("course_id", "user_id", "completed_weeks", "total_weeks", "learning_hours", "commit_count", "average_score", "updated_at")
|
||||
SELECT target.target_course_id, progress."user_id", progress."completed_weeks", progress."total_weeks", progress."learning_hours", progress."commit_count", progress."average_score", CURRENT_TIMESTAMP
|
||||
FROM "course_student_progress" progress
|
||||
JOIN "_course_split_source" source ON source.source_course_id = progress."course_id"
|
||||
JOIN "users" student ON student."id" = progress."user_id"
|
||||
CROSS JOIN "_course_split_target" target
|
||||
WHERE student."primary_organization_name" = '영남대학교'
|
||||
ON CONFLICT ("course_id", "user_id") DO UPDATE
|
||||
SET "completed_weeks" = EXCLUDED."completed_weeks",
|
||||
"total_weeks" = EXCLUDED."total_weeks",
|
||||
"learning_hours" = EXCLUDED."learning_hours",
|
||||
"commit_count" = EXCLUDED."commit_count",
|
||||
"average_score" = EXCLUDED."average_score",
|
||||
"updated_at" = CURRENT_TIMESTAMP;
|
||||
|
||||
DELETE FROM "course_student_progress" progress
|
||||
USING "_course_split_source" source, "users" student
|
||||
WHERE progress."course_id" = source.source_course_id
|
||||
AND student."id" = progress."user_id"
|
||||
AND student."primary_organization_name" = '영남대학교';
|
||||
|
||||
UPDATE "instructor_curriculum_settings" setting
|
||||
SET "course_id" = target.target_course_id,
|
||||
"updated_at" = CURRENT_TIMESTAMP
|
||||
FROM "_course_split_source" source, "_course_split_target" target
|
||||
WHERE setting."course_id" = source.source_course_id
|
||||
AND setting."organization_name" = '영남대학교';
|
||||
|
||||
INSERT INTO "instructor_curriculum_week_overrides" ("organization_id", "organization_name", "course_id", "week", "title", "duration", "overview", "deliverable", "topics", "topic_details", "updated_at")
|
||||
SELECT override."organization_id", override."organization_name", target.target_course_id, override."week", override."title", override."duration", override."overview", override."deliverable", override."topics", override."topic_details", CURRENT_TIMESTAMP
|
||||
FROM "instructor_curriculum_week_overrides" override
|
||||
JOIN "_course_split_source" source ON source.source_course_id = override."course_id"
|
||||
CROSS JOIN "_course_split_target" target
|
||||
WHERE override."organization_name" = '영남대학교'
|
||||
ON CONFLICT ("organization_name", "course_id", "week") DO UPDATE
|
||||
SET "title" = EXCLUDED."title",
|
||||
"duration" = EXCLUDED."duration",
|
||||
"overview" = EXCLUDED."overview",
|
||||
"deliverable" = EXCLUDED."deliverable",
|
||||
"topics" = EXCLUDED."topics",
|
||||
"topic_details" = EXCLUDED."topic_details",
|
||||
"updated_at" = CURRENT_TIMESTAMP;
|
||||
|
||||
DELETE FROM "instructor_curriculum_week_overrides" override
|
||||
USING "_course_split_source" source
|
||||
WHERE override."course_id" = source.source_course_id
|
||||
AND override."organization_name" = '영남대학교';
|
||||
|
||||
UPDATE "studio_repositories" repository
|
||||
SET "week_id" = module_map.target_week_id,
|
||||
"module_id" = module_map.target_module_id,
|
||||
"updated_at" = CURRENT_TIMESTAMP
|
||||
FROM "_course_split_module_map" module_map, "users" student
|
||||
WHERE repository."module_id" = module_map.source_module_id
|
||||
AND repository."user_id" = student."id"
|
||||
AND student."primary_organization_name" = '영남대학교';
|
||||
|
||||
UPDATE "chat_threads" thread
|
||||
SET "course_id" = target.target_course_id,
|
||||
"updated_at" = CURRENT_TIMESTAMP
|
||||
FROM "_course_split_source" source
|
||||
JOIN "_course_split_target" target ON true
|
||||
JOIN "users" student ON true
|
||||
WHERE thread."course_id" = source.source_course_id
|
||||
AND student."id" = thread."student_id"
|
||||
AND student."primary_organization_name" = '영남대학교';
|
||||
|
||||
UPDATE "chat_threads" thread
|
||||
SET "week_id" = week_map.target_week_id,
|
||||
"updated_at" = CURRENT_TIMESTAMP
|
||||
FROM "_course_split_week_map" week_map
|
||||
JOIN "_course_split_target" target ON true
|
||||
JOIN "users" student ON true
|
||||
WHERE thread."course_id" = target.target_course_id
|
||||
AND thread."week_id" = week_map.source_week_id
|
||||
AND student."id" = thread."student_id"
|
||||
AND student."primary_organization_name" = '영남대학교';
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "instructor_curriculum_week_overrides"
|
||||
ADD COLUMN "practice_contents" JSONB;
|
||||
@@ -0,0 +1,8 @@
|
||||
ALTER TABLE "course_weeks"
|
||||
ADD COLUMN IF NOT EXISTS "requires_source_submission" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
ALTER TABLE "instructor_curriculum_week_overrides"
|
||||
ADD COLUMN IF NOT EXISTS "requires_source_submission" BOOLEAN DEFAULT true;
|
||||
|
||||
COMMENT ON COLUMN "course_weeks"."requires_source_submission" IS '주차 제출 시 소스 커밋/푸시가 필요한지 여부.';
|
||||
COMMENT ON COLUMN "instructor_curriculum_week_overrides"."requires_source_submission" IS '강사별 주차 소스 제출 필요 여부 오버라이드.';
|
||||
@@ -0,0 +1,28 @@
|
||||
CREATE TABLE IF NOT EXISTS "course_student_week_progress" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"course_id" UUID NOT NULL,
|
||||
"user_id" UUID NOT NULL,
|
||||
"week" INTEGER NOT NULL,
|
||||
"submitted_at" TIMESTAMPTZ(3),
|
||||
"commit_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "course_student_week_progress_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "course_student_week_progress_course_user_week_key"
|
||||
ON "course_student_week_progress"("course_id", "user_id", "week");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "course_student_week_progress_user_idx"
|
||||
ON "course_student_week_progress"("user_id");
|
||||
|
||||
ALTER TABLE "course_student_week_progress"
|
||||
ADD CONSTRAINT "course_student_week_progress_course_id_fkey"
|
||||
FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "course_student_week_progress"
|
||||
ADD CONSTRAINT "course_student_week_progress_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
COMMENT ON TABLE "course_student_week_progress" IS '학생별 주차 학습 및 제출 요약.';
|
||||
COMMENT ON COLUMN "course_student_week_progress"."submitted_at" IS '해당 주차 소스 제출 완료 시각.';
|
||||
@@ -0,0 +1,18 @@
|
||||
WITH week6_topics AS (
|
||||
SELECT
|
||||
w."course_id",
|
||||
jsonb_agg(t."title" ORDER BY t."sort_order") AS topics,
|
||||
jsonb_agg(t."detail" ORDER BY t."sort_order") AS topic_details
|
||||
FROM "course_weeks" w
|
||||
JOIN "course_week_topics" t ON t."week_id" = w."id"
|
||||
WHERE w."week" = 6
|
||||
GROUP BY w."course_id"
|
||||
)
|
||||
UPDATE "instructor_curriculum_week_overrides" override
|
||||
SET
|
||||
"topics" = week6_topics.topics,
|
||||
"topic_details" = week6_topics.topic_details,
|
||||
"updated_at" = CURRENT_TIMESTAMP
|
||||
FROM week6_topics
|
||||
WHERE override."course_id" = week6_topics."course_id"
|
||||
AND override."week" = 6;
|
||||
@@ -0,0 +1,26 @@
|
||||
WITH week6_content AS (
|
||||
SELECT
|
||||
w."course_id",
|
||||
w."overview",
|
||||
w."deliverable",
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'title', p."title",
|
||||
'description', p."description"
|
||||
)
|
||||
ORDER BY p."sort_order"
|
||||
) FILTER (WHERE p."id" IS NOT NULL) AS practice_contents
|
||||
FROM "course_weeks" w
|
||||
LEFT JOIN "course_week_practices" p ON p."week_id" = w."id"
|
||||
WHERE w."week" = 6
|
||||
GROUP BY w."course_id", w."overview", w."deliverable"
|
||||
)
|
||||
UPDATE "instructor_curriculum_week_overrides" override
|
||||
SET
|
||||
"overview" = week6_content."overview",
|
||||
"deliverable" = week6_content."deliverable",
|
||||
"practice_contents" = COALESCE(week6_content.practice_contents, '[]'::jsonb),
|
||||
"updated_at" = CURRENT_TIMESTAMP
|
||||
FROM week6_content
|
||||
WHERE override."course_id" = week6_content."course_id"
|
||||
AND override."week" = 6;
|
||||
@@ -0,0 +1,21 @@
|
||||
CREATE TABLE "course_week_references" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"week_id" UUID NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"original_file_name" TEXT NOT NULL,
|
||||
"stored_file_name" TEXT NOT NULL,
|
||||
"content_type" TEXT,
|
||||
"file_size_bytes" INTEGER NOT NULL DEFAULT 0,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "course_week_references_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "course_week_references_week_id_idx" ON "course_week_references"("week_id");
|
||||
CREATE UNIQUE INDEX "course_week_references_week_id_stored_file_name_key" ON "course_week_references"("week_id", "stored_file_name");
|
||||
|
||||
ALTER TABLE "course_week_references"
|
||||
ADD CONSTRAINT "course_week_references_week_id_fkey"
|
||||
FOREIGN KEY ("week_id") REFERENCES "course_weeks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "instructor_curriculum_week_overrides"
|
||||
ADD COLUMN "disabled_module_ids" JSONB;
|
||||
@@ -0,0 +1,22 @@
|
||||
CREATE TABLE "submission_scores" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"course_id" UUID NOT NULL,
|
||||
"week_id" UUID NOT NULL,
|
||||
"module_id" UUID NOT NULL,
|
||||
"student_id" UUID NOT NULL,
|
||||
"score" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "submission_scores_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "submission_scores_student_id_module_id_key" ON "submission_scores"("student_id", "module_id");
|
||||
CREATE INDEX "submission_scores_course_id_idx" ON "submission_scores"("course_id");
|
||||
CREATE INDEX "submission_scores_week_id_idx" ON "submission_scores"("week_id");
|
||||
CREATE INDEX "submission_scores_module_id_idx" ON "submission_scores"("module_id");
|
||||
|
||||
ALTER TABLE "submission_scores" ADD CONSTRAINT "submission_scores_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "submission_scores" ADD CONSTRAINT "submission_scores_week_id_fkey" FOREIGN KEY ("week_id") REFERENCES "course_weeks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "submission_scores" ADD CONSTRAINT "submission_scores_module_id_fkey" FOREIGN KEY ("module_id") REFERENCES "course_week_modules"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "submission_scores" ADD CONSTRAINT "submission_scores_student_id_fkey" FOREIGN KEY ("student_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE "submission_total_scores" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"course_id" UUID NOT NULL,
|
||||
"week_id" UUID NOT NULL,
|
||||
"student_id" UUID NOT NULL,
|
||||
"score" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "submission_total_scores_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "submission_total_scores_student_id_week_id_key" ON "submission_total_scores"("student_id", "week_id");
|
||||
CREATE INDEX "submission_total_scores_course_id_idx" ON "submission_total_scores"("course_id");
|
||||
CREATE INDEX "submission_total_scores_week_id_idx" ON "submission_total_scores"("week_id");
|
||||
|
||||
ALTER TABLE "submission_total_scores" ADD CONSTRAINT "submission_total_scores_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "submission_total_scores" ADD CONSTRAINT "submission_total_scores_week_id_fkey" FOREIGN KEY ("week_id") REFERENCES "course_weeks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "submission_total_scores" ADD CONSTRAINT "submission_total_scores_student_id_fkey" FOREIGN KEY ("student_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,135 @@
|
||||
CREATE TABLE "visualization_platforms" (
|
||||
"platform_key" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"display_name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"default_config" JSONB NOT NULL,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "visualization_platforms_pkey" PRIMARY KEY ("platform_key")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "visualization_platforms_type_key" ON "visualization_platforms"("type");
|
||||
CREATE INDEX "visualization_platforms_enabled_sort_order_idx" ON "visualization_platforms"("enabled", "sort_order");
|
||||
|
||||
CREATE TABLE "course_week_visualization_platforms" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"course_id" UUID NOT NULL,
|
||||
"week" INTEGER NOT NULL,
|
||||
"platform_key" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "course_week_visualization_platforms_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "course_week_visualization_platforms_course_id_week_platform_key_key"
|
||||
ON "course_week_visualization_platforms"("course_id", "week", "platform_key");
|
||||
CREATE INDEX "course_week_visualization_platforms_course_id_week_idx"
|
||||
ON "course_week_visualization_platforms"("course_id", "week");
|
||||
CREATE INDEX "course_week_visualization_platforms_platform_key_idx"
|
||||
ON "course_week_visualization_platforms"("platform_key");
|
||||
|
||||
ALTER TABLE "course_week_visualization_platforms"
|
||||
ADD CONSTRAINT "course_week_visualization_platforms_course_id_fkey"
|
||||
FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "course_week_visualization_platforms"
|
||||
ADD CONSTRAINT "course_week_visualization_platforms_platform_key_fkey"
|
||||
FOREIGN KEY ("platform_key") REFERENCES "visualization_platforms"("platform_key") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
INSERT INTO "visualization_platforms" ("platform_key", "type", "display_name", "description", "default_config", "sort_order", "updated_at")
|
||||
VALUES
|
||||
(
|
||||
'AUTOWARE',
|
||||
'AUTOWARE',
|
||||
'Autoware',
|
||||
'Autoware 기반 자율주행 시각화와 제어 실습 환경',
|
||||
'{
|
||||
"type": "AUTOWARE",
|
||||
"runtime": { "containers": [], "steps": [] },
|
||||
"bridge": {
|
||||
"name": "autoware-ros-bridge",
|
||||
"image": "autoware-ros-bridge",
|
||||
"port": 8765,
|
||||
"env": {
|
||||
"ROBOT_STACK": "autoware",
|
||||
"MAP_POINT_STRIDE": "8",
|
||||
"MAP_POINT_LIMIT": "80000",
|
||||
"AUTOWARE_ROUTE_INPUT": "rviz_topic",
|
||||
"INITIAL_POSE_TOPIC": "/initialpose",
|
||||
"GOAL_POSE_TOPIC": "/planning/mission_planning/goal",
|
||||
"VEHICLE_POSE_TOPIC": "/localization/kinematic_state",
|
||||
"TRAJECTORY_TOPIC": "/planning/scenario_planning/trajectory",
|
||||
"ROUTE_TOPIC": "/planning/mission_planning/route"
|
||||
}
|
||||
},
|
||||
"viewer": { "stack": "autoware" }
|
||||
}'::jsonb,
|
||||
1,
|
||||
CURRENT_TIMESTAMP
|
||||
),
|
||||
(
|
||||
'NAV2',
|
||||
'NAV2',
|
||||
'Nav2',
|
||||
'Nav2 기반 주행 알고리즘 시각화 실습 환경',
|
||||
'{
|
||||
"type": "NAV2",
|
||||
"runtime": { "containers": [], "steps": [] },
|
||||
"bridge": {
|
||||
"name": "nav2-ros-bridge",
|
||||
"image": "nav2-ros-bridge",
|
||||
"port": 8765,
|
||||
"env": {
|
||||
"ROBOT_STACK": "nav2"
|
||||
}
|
||||
},
|
||||
"viewer": { "stack": "nav2" }
|
||||
}'::jsonb,
|
||||
2,
|
||||
CURRENT_TIMESTAMP
|
||||
),
|
||||
(
|
||||
'SLAM',
|
||||
'SLAM',
|
||||
'SLAM',
|
||||
'TurtleBot3 SLAM 지도 생성 시각화 실습 환경',
|
||||
'{
|
||||
"type": "SLAM",
|
||||
"runtime": {
|
||||
"containers": [
|
||||
{ "name": "nav2-slam", "image": "nav2-slam:humble", "network": "host" },
|
||||
{ "name": "slam-ros-bridge", "image": "slam-ros-bridge", "network": "host" }
|
||||
]
|
||||
},
|
||||
"bridge": { "url": "http://localhost:8765" },
|
||||
"viewer": { "stack": "slam" },
|
||||
"ros": {
|
||||
"turtlebotModel": "burger"
|
||||
}
|
||||
}'::jsonb,
|
||||
3,
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
ON CONFLICT ("platform_key") DO UPDATE
|
||||
SET "type" = EXCLUDED."type",
|
||||
"display_name" = EXCLUDED."display_name",
|
||||
"description" = EXCLUDED."description",
|
||||
"default_config" = EXCLUDED."default_config",
|
||||
"sort_order" = EXCLUDED."sort_order",
|
||||
"enabled" = true,
|
||||
"updated_at" = CURRENT_TIMESTAMP;
|
||||
|
||||
INSERT INTO "course_week_visualization_platforms" ("course_id", "week", "platform_key", "enabled", "updated_at")
|
||||
SELECT DISTINCT profile."course_id", profile."week", upper(profile."profile_type"), true, CURRENT_TIMESTAMP
|
||||
FROM "course_environment_profiles" profile
|
||||
JOIN "visualization_platforms" platform ON platform."platform_key" = upper(profile."profile_type")
|
||||
WHERE profile."enabled" = true
|
||||
ON CONFLICT ("course_id", "week", "platform_key") DO UPDATE
|
||||
SET "enabled" = true,
|
||||
"updated_at" = CURRENT_TIMESTAMP;
|
||||
@@ -0,0 +1,41 @@
|
||||
UPDATE "visualization_platforms"
|
||||
SET "default_config" = '{
|
||||
"type": "AUTOWARE",
|
||||
"runtime": { "containers": [], "steps": [] },
|
||||
"bridge": {
|
||||
"name": "autoware-ros-bridge",
|
||||
"image": "autoware-ros-bridge",
|
||||
"port": 8765,
|
||||
"env": {
|
||||
"ROBOT_STACK": "autoware",
|
||||
"MAP_POINT_STRIDE": "8",
|
||||
"MAP_POINT_LIMIT": "80000",
|
||||
"AUTOWARE_ROUTE_INPUT": "rviz_topic",
|
||||
"INITIAL_POSE_TOPIC": "/initialpose",
|
||||
"GOAL_POSE_TOPIC": "/planning/mission_planning/goal",
|
||||
"VEHICLE_POSE_TOPIC": "/localization/kinematic_state",
|
||||
"TRAJECTORY_TOPIC": "/planning/scenario_planning/trajectory",
|
||||
"ROUTE_TOPIC": "/planning/mission_planning/route"
|
||||
}
|
||||
},
|
||||
"viewer": { "stack": "autoware" }
|
||||
}'::jsonb,
|
||||
"updated_at" = CURRENT_TIMESTAMP
|
||||
WHERE "platform_key" = 'AUTOWARE';
|
||||
|
||||
UPDATE "visualization_platforms"
|
||||
SET "default_config" = '{
|
||||
"type": "NAV2",
|
||||
"runtime": { "containers": [], "steps": [] },
|
||||
"bridge": {
|
||||
"name": "nav2-ros-bridge",
|
||||
"image": "nav2-ros-bridge",
|
||||
"port": 8765,
|
||||
"env": {
|
||||
"ROBOT_STACK": "nav2"
|
||||
}
|
||||
},
|
||||
"viewer": { "stack": "nav2" }
|
||||
}'::jsonb,
|
||||
"updated_at" = CURRENT_TIMESTAMP
|
||||
WHERE "platform_key" = 'NAV2';
|
||||
@@ -0,0 +1,85 @@
|
||||
UPDATE "visualization_platforms"
|
||||
SET "default_config" = '{
|
||||
"type": "AUTOWARE",
|
||||
"target": "local",
|
||||
"runtime": { "containers": [], "steps": [] },
|
||||
"bridge": {
|
||||
"name": "autoware-ros-bridge",
|
||||
"image": "autoware-ros-bridge",
|
||||
"buildContext": "resources/autoware-ros-bridge",
|
||||
"port": 8765,
|
||||
"env": {
|
||||
"ROBOT_STACK": "autoware",
|
||||
"MAP_POINT_STRIDE": "8",
|
||||
"MAP_POINT_LIMIT": "80000",
|
||||
"AUTOWARE_ROUTE_INPUT": "rviz_topic",
|
||||
"INITIAL_POSE_TOPIC": "/initialpose",
|
||||
"GOAL_POSE_TOPIC": "/planning/mission_planning/goal",
|
||||
"VEHICLE_POSE_TOPIC": "/localization/kinematic_state",
|
||||
"TRAJECTORY_TOPIC": "/planning/scenario_planning/trajectory",
|
||||
"ROUTE_TOPIC": "/planning/mission_planning/route"
|
||||
}
|
||||
},
|
||||
"viewer": { "stack": "autoware" }
|
||||
}'::jsonb,
|
||||
"updated_at" = CURRENT_TIMESTAMP
|
||||
WHERE "platform_key" = 'AUTOWARE';
|
||||
|
||||
UPDATE "visualization_platforms"
|
||||
SET "default_config" = '{
|
||||
"type": "NAV2",
|
||||
"target": "local",
|
||||
"runtime": { "containers": [], "steps": [] },
|
||||
"bridge": {
|
||||
"name": "nav2-ros-bridge",
|
||||
"image": "nav2-ros-bridge",
|
||||
"buildContext": "resources/nav2-ros-bridge",
|
||||
"port": 8765,
|
||||
"env": {
|
||||
"ROBOT_STACK": "nav2"
|
||||
}
|
||||
},
|
||||
"viewer": { "stack": "nav2" }
|
||||
}'::jsonb,
|
||||
"updated_at" = CURRENT_TIMESTAMP
|
||||
WHERE "platform_key" = 'NAV2';
|
||||
|
||||
UPDATE "visualization_platforms"
|
||||
SET "default_config" = jsonb_set("default_config", '{target}', '"local"'::jsonb, true),
|
||||
"updated_at" = CURRENT_TIMESTAMP
|
||||
WHERE "platform_key" = 'SLAM';
|
||||
|
||||
UPDATE "course_environment_profiles"
|
||||
SET "config" = jsonb_set(
|
||||
jsonb_set(
|
||||
jsonb_set("config", '{target}', '"local"'::jsonb, true),
|
||||
'{bridge,buildContext}',
|
||||
'"resources/autoware-ros-bridge"'::jsonb,
|
||||
true
|
||||
),
|
||||
'{bridge,image}',
|
||||
'"autoware-ros-bridge"'::jsonb,
|
||||
true
|
||||
),
|
||||
"updated_at" = CURRENT_TIMESTAMP
|
||||
WHERE upper("profile_type") = 'AUTOWARE';
|
||||
|
||||
UPDATE "course_environment_profiles"
|
||||
SET "config" = jsonb_set(
|
||||
jsonb_set(
|
||||
jsonb_set("config", '{target}', '"local"'::jsonb, true),
|
||||
'{bridge,buildContext}',
|
||||
'"resources/nav2-ros-bridge"'::jsonb,
|
||||
true
|
||||
),
|
||||
'{bridge,image}',
|
||||
'"nav2-ros-bridge"'::jsonb,
|
||||
true
|
||||
),
|
||||
"updated_at" = CURRENT_TIMESTAMP
|
||||
WHERE upper("profile_type") = 'NAV2';
|
||||
|
||||
UPDATE "course_environment_profiles"
|
||||
SET "config" = jsonb_set("config", '{target}', '"local"'::jsonb, true),
|
||||
"updated_at" = CURRENT_TIMESTAMP
|
||||
WHERE upper("profile_type") = 'SLAM';
|
||||
@@ -0,0 +1,29 @@
|
||||
CREATE TABLE "course_week_platforms" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"course_id" UUID NOT NULL,
|
||||
"week" INTEGER NOT NULL,
|
||||
"platform_id" UUID NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "course_week_platforms_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "course_week_platforms_course_id_week_platform_id_key" ON "course_week_platforms"("course_id", "week", "platform_id");
|
||||
CREATE INDEX "course_week_platforms_course_id_week_idx" ON "course_week_platforms"("course_id", "week");
|
||||
CREATE INDEX "course_week_platforms_platform_id_idx" ON "course_week_platforms"("platform_id");
|
||||
|
||||
ALTER TABLE "course_week_platforms"
|
||||
ADD CONSTRAINT "course_week_platforms_course_id_fkey"
|
||||
FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "course_week_platforms"
|
||||
ADD CONSTRAINT "course_week_platforms_platform_id_fkey"
|
||||
FOREIGN KEY ("platform_id") REFERENCES "platforms"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
INSERT INTO "course_week_platforms" ("course_id", "week", "platform_id", "enabled")
|
||||
SELECT cw."course_id", cw."week", cp."platform_id", true
|
||||
FROM "course_weeks" cw
|
||||
JOIN "course_platforms" cp ON cp."course_id" = cw."course_id"
|
||||
ON CONFLICT ("course_id", "week", "platform_id") DO NOTHING;
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE "instructor_curriculum_week_overrides"
|
||||
ADD COLUMN IF NOT EXISTS "tag_label" TEXT;
|
||||
|
||||
DROP TABLE IF EXISTS "course_week_platforms";
|
||||
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE "curriculum_tag_options" (
|
||||
"option_key" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "curriculum_tag_options_pkey" PRIMARY KEY ("option_key")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "curriculum_tag_options_label_key" ON "curriculum_tag_options"("label");
|
||||
CREATE INDEX "curriculum_tag_options_enabled_sort_order_idx" ON "curriculum_tag_options"("enabled", "sort_order");
|
||||
|
||||
INSERT INTO "curriculum_tag_options" ("option_key", "label", "sort_order", "enabled")
|
||||
VALUES
|
||||
('none', '없음', 0, true),
|
||||
('simulation', '시뮬레이션', 10, true),
|
||||
('mk-mini', 'MK-mini', 20, true),
|
||||
('f1tenth', 'f1tenth', 30, true)
|
||||
ON CONFLICT ("option_key") DO UPDATE SET
|
||||
"label" = EXCLUDED."label",
|
||||
"sort_order" = EXCLUDED."sort_order",
|
||||
"enabled" = EXCLUDED."enabled",
|
||||
"updated_at" = CURRENT_TIMESTAMP;
|
||||
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE "platforms"
|
||||
ADD COLUMN IF NOT EXISTS "curriculum_tag_only" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
UPDATE "platforms"
|
||||
SET "curriculum_tag_only" = true,
|
||||
"updated_at" = CURRENT_TIMESTAMP
|
||||
WHERE lower("platform_key") LIKE '%simulator%'
|
||||
OR "display_name" ILIKE '%시뮬%'
|
||||
OR lower("display_name") LIKE '%simulator%';
|
||||
|
||||
DROP TABLE IF EXISTS "curriculum_tag_options";
|
||||
@@ -0,0 +1,24 @@
|
||||
ALTER TABLE "platforms"
|
||||
ALTER COLUMN "default_studio_settings"
|
||||
SET DEFAULT '{"workspaceRoot":"~/autonomy-studio/workspaces","giteaBaseUrl":"http://localhost:3000","giteaOwner":"student"}';
|
||||
|
||||
UPDATE "platforms"
|
||||
SET "default_studio_settings" = jsonb_set(
|
||||
COALESCE("default_studio_settings", '{}'::jsonb),
|
||||
'{giteaOwner}',
|
||||
'"student"'::jsonb,
|
||||
true
|
||||
),
|
||||
"updated_at" = CURRENT_TIMESTAMP
|
||||
WHERE COALESCE("default_studio_settings"->>'giteaOwner', '') IN ('', 'students');
|
||||
|
||||
UPDATE "instructor_platforms"
|
||||
SET "studio_settings_override" = jsonb_set(
|
||||
"studio_settings_override",
|
||||
'{giteaOwner}',
|
||||
'"student"'::jsonb,
|
||||
true
|
||||
),
|
||||
"updated_at" = CURRENT_TIMESTAMP
|
||||
WHERE "studio_settings_override" IS NOT NULL
|
||||
AND "studio_settings_override"->>'giteaOwner' = 'students';
|
||||
@@ -0,0 +1,4 @@
|
||||
UPDATE "studio_repositories"
|
||||
SET "repo_owner" = 'student',
|
||||
"repository_url" = replace("repository_url", '/students/', '/student/')
|
||||
WHERE "repo_owner" = 'students';
|
||||
@@ -0,0 +1,350 @@
|
||||
COMMENT ON TABLE "users" IS 'LMS 내부 사용자 프로필. 인증 서버 사용자와 LMS 수강/강의/채팅 데이터를 연결한다.';
|
||||
COMMENT ON COLUMN "users"."id" IS 'LMS 사용자 ID';
|
||||
COMMENT ON COLUMN "users"."auth_user_id" IS '인증 서버 사용자 ID';
|
||||
COMMENT ON COLUMN "users"."email" IS '사용자 이메일';
|
||||
COMMENT ON COLUMN "users"."name" IS '사용자 이름';
|
||||
COMMENT ON COLUMN "users"."auth_role" IS '인증 서버 기준 역할';
|
||||
COMMENT ON COLUMN "users"."primary_organization_id" IS '대표 소속 조직 ID';
|
||||
COMMENT ON COLUMN "users"."primary_organization_name" IS '대표 소속 조직명';
|
||||
COMMENT ON COLUMN "users"."primary_organization_type" IS '대표 소속 조직 유형';
|
||||
COMMENT ON COLUMN "users"."department" IS '부서 또는 학과';
|
||||
COMMENT ON COLUMN "users"."position" IS '직책 또는 직위';
|
||||
COMMENT ON COLUMN "users"."createdAt" IS '레코드 생성 시각';
|
||||
COMMENT ON COLUMN "users"."updatedAt" IS '레코드 수정 시각';
|
||||
|
||||
COMMENT ON TABLE "student_enrollments" IS '수강생과 강좌의 등록 관계. 기존 사용자는 새 계정 생성 없이 강좌에 연결된다.';
|
||||
COMMENT ON COLUMN "student_enrollments"."id" IS '수강 등록 ID';
|
||||
COMMENT ON COLUMN "student_enrollments"."user_id" IS '수강생 사용자 ID';
|
||||
COMMENT ON COLUMN "student_enrollments"."course_id" IS '등록 강좌 ID';
|
||||
COMMENT ON COLUMN "student_enrollments"."organization_id" IS '등록 기준 조직 ID';
|
||||
COMMENT ON COLUMN "student_enrollments"."organization_name" IS '등록 기준 조직명';
|
||||
COMMENT ON COLUMN "student_enrollments"."status" IS '수강 등록 상태';
|
||||
COMMENT ON COLUMN "student_enrollments"."removed_at" IS '수강 등록 해제 시각';
|
||||
COMMENT ON COLUMN "student_enrollments"."created_at" IS '레코드 생성 시각';
|
||||
COMMENT ON COLUMN "student_enrollments"."updated_at" IS '레코드 수정 시각';
|
||||
|
||||
COMMENT ON TABLE "student_groups" IS '강사가 관리하는 수강생 그룹. 강의 진행 상태와 플랫폼 배정의 기준이다.';
|
||||
COMMENT ON COLUMN "student_groups"."id" IS '수강생 그룹 ID';
|
||||
COMMENT ON COLUMN "student_groups"."organization_id" IS '그룹 소속 조직 ID';
|
||||
COMMENT ON COLUMN "student_groups"."organization_name" IS '그룹 소속 조직명';
|
||||
COMMENT ON COLUMN "student_groups"."name" IS '그룹명';
|
||||
COMMENT ON COLUMN "student_groups"."week" IS '그룹 기본 주차 값';
|
||||
COMMENT ON COLUMN "student_groups"."status" IS '그룹 운영 상태';
|
||||
COMMENT ON COLUMN "student_groups"."platform_name" IS '그룹에 배정된 플랫폼 표시명';
|
||||
COMMENT ON COLUMN "student_groups"."created_at" IS '레코드 생성 시각';
|
||||
COMMENT ON COLUMN "student_groups"."updated_at" IS '레코드 수정 시각';
|
||||
|
||||
COMMENT ON TABLE "student_group_members" IS '수강생 그룹과 사용자 간의 멤버십.';
|
||||
COMMENT ON COLUMN "student_group_members"."id" IS '그룹 멤버 ID';
|
||||
COMMENT ON COLUMN "student_group_members"."group_id" IS '수강생 그룹 ID';
|
||||
COMMENT ON COLUMN "student_group_members"."user_id" IS '그룹에 포함된 사용자 ID';
|
||||
COMMENT ON COLUMN "student_group_members"."created_at" IS '레코드 생성 시각';
|
||||
|
||||
COMMENT ON TABLE "courses" IS 'LMS 강좌 마스터. 주차별 커리큘럼과 수강생 등록의 상위 단위이다.';
|
||||
COMMENT ON COLUMN "courses"."id" IS '강좌 ID';
|
||||
COMMENT ON COLUMN "courses"."slug" IS '강좌 식별 슬러그';
|
||||
COMMENT ON COLUMN "courses"."title" IS '강좌명';
|
||||
COMMENT ON COLUMN "courses"."subtitle" IS '강좌 부제목';
|
||||
COMMENT ON COLUMN "courses"."description" IS '강좌 설명';
|
||||
COMMENT ON COLUMN "courses"."organization_name" IS '강좌 소속 조직명';
|
||||
COMMENT ON COLUMN "courses"."teacher_id" IS '담당 강사 사용자 ID';
|
||||
COMMENT ON COLUMN "courses"."is_active" IS '강좌 활성 여부';
|
||||
COMMENT ON COLUMN "courses"."created_at" IS '레코드 생성 시각';
|
||||
COMMENT ON COLUMN "courses"."updated_at" IS '레코드 수정 시각';
|
||||
|
||||
COMMENT ON TABLE "course_weeks" IS '강좌별 주차 커리큘럼 기본 데이터.';
|
||||
COMMENT ON COLUMN "course_weeks"."id" IS '주차 커리큘럼 ID';
|
||||
COMMENT ON COLUMN "course_weeks"."course_id" IS '소속 강좌 ID';
|
||||
COMMENT ON COLUMN "course_weeks"."week" IS '주차 번호';
|
||||
COMMENT ON COLUMN "course_weeks"."title" IS '주차 제목';
|
||||
COMMENT ON COLUMN "course_weeks"."tags" IS '주차 태그 목록';
|
||||
COMMENT ON COLUMN "course_weeks"."progress" IS '기본 진행률';
|
||||
COMMENT ON COLUMN "course_weeks"."state" IS '주차 상태';
|
||||
COMMENT ON COLUMN "course_weeks"."duration" IS '예상 학습 시간';
|
||||
COMMENT ON COLUMN "course_weeks"."github" IS '관련 GitHub 또는 저장소 정보';
|
||||
COMMENT ON COLUMN "course_weeks"."overview" IS '주차 개요';
|
||||
COMMENT ON COLUMN "course_weeks"."deliverable" IS '제출 기준 또는 산출물 설명';
|
||||
COMMENT ON COLUMN "course_weeks"."requires_source_submission" IS '소스 제출 필요 여부';
|
||||
COMMENT ON COLUMN "course_weeks"."created_at" IS '레코드 생성 시각';
|
||||
COMMENT ON COLUMN "course_weeks"."updated_at" IS '레코드 수정 시각';
|
||||
|
||||
COMMENT ON TABLE "course_week_topics" IS '주차별 학습단계 목록.';
|
||||
COMMENT ON COLUMN "course_week_topics"."id" IS '학습단계 ID';
|
||||
COMMENT ON COLUMN "course_week_topics"."week_id" IS '주차 커리큘럼 ID';
|
||||
COMMENT ON COLUMN "course_week_topics"."title" IS '학습단계 제목';
|
||||
COMMENT ON COLUMN "course_week_topics"."detail" IS '학습단계 상세 내용';
|
||||
COMMENT ON COLUMN "course_week_topics"."sort_order" IS '학습단계 표시 순서';
|
||||
|
||||
COMMENT ON TABLE "course_week_practices" IS '주차별 실습내용 목록.';
|
||||
COMMENT ON COLUMN "course_week_practices"."id" IS '실습내용 ID';
|
||||
COMMENT ON COLUMN "course_week_practices"."week_id" IS '주차 커리큘럼 ID';
|
||||
COMMENT ON COLUMN "course_week_practices"."title" IS '실습내용 제목';
|
||||
COMMENT ON COLUMN "course_week_practices"."description" IS '실습내용 설명';
|
||||
COMMENT ON COLUMN "course_week_practices"."sort_order" IS '실습내용 표시 순서';
|
||||
|
||||
COMMENT ON TABLE "course_week_references" IS '주차별 참고자료 파일 메타데이터.';
|
||||
COMMENT ON COLUMN "course_week_references"."id" IS '참고자료 ID';
|
||||
COMMENT ON COLUMN "course_week_references"."week_id" IS '주차 커리큘럼 ID';
|
||||
COMMENT ON COLUMN "course_week_references"."title" IS '참고자료 제목';
|
||||
COMMENT ON COLUMN "course_week_references"."original_file_name" IS '업로드 원본 파일명';
|
||||
COMMENT ON COLUMN "course_week_references"."stored_file_name" IS '서버 저장 파일명';
|
||||
COMMENT ON COLUMN "course_week_references"."content_type" IS '파일 MIME 타입';
|
||||
COMMENT ON COLUMN "course_week_references"."file_size_bytes" IS '파일 크기 바이트';
|
||||
COMMENT ON COLUMN "course_week_references"."sort_order" IS '참고자료 표시 순서';
|
||||
COMMENT ON COLUMN "course_week_references"."created_at" IS '레코드 생성 시각';
|
||||
COMMENT ON COLUMN "course_week_references"."updated_at" IS '레코드 수정 시각';
|
||||
|
||||
COMMENT ON TABLE "course_week_modules" IS '주차별 개발 스튜디오 모듈 목록.';
|
||||
COMMENT ON COLUMN "course_week_modules"."id" IS '주차 모듈 ID';
|
||||
COMMENT ON COLUMN "course_week_modules"."week_id" IS '주차 커리큘럼 ID';
|
||||
COMMENT ON COLUMN "course_week_modules"."sort_order" IS '모듈 표시 및 실행 순서';
|
||||
COMMENT ON COLUMN "course_week_modules"."category" IS '모듈 분류';
|
||||
COMMENT ON COLUMN "course_week_modules"."name" IS '모듈명';
|
||||
COMMENT ON COLUMN "course_week_modules"."folder_name" IS '워크스페이스 폴더명';
|
||||
COMMENT ON COLUMN "course_week_modules"."template_repository_url" IS '모듈 템플릿 저장소 URL';
|
||||
COMMENT ON COLUMN "course_week_modules"."docker_image" IS '모듈 실행 Docker 이미지';
|
||||
COMMENT ON COLUMN "course_week_modules"."created_at" IS '레코드 생성 시각';
|
||||
COMMENT ON COLUMN "course_week_modules"."updated_at" IS '레코드 수정 시각';
|
||||
|
||||
COMMENT ON TABLE "instructor_curriculum_settings" IS '강사/그룹별 현재 진행 중인 주차와 학습단계 설정.';
|
||||
COMMENT ON COLUMN "instructor_curriculum_settings"."id" IS '강의 진행 설정 ID';
|
||||
COMMENT ON COLUMN "instructor_curriculum_settings"."organization_id" IS '설정 소속 조직 ID';
|
||||
COMMENT ON COLUMN "instructor_curriculum_settings"."organization_name" IS '설정 소속 조직명';
|
||||
COMMENT ON COLUMN "instructor_curriculum_settings"."group_id" IS '진행 상태가 적용되는 수강생 그룹 ID';
|
||||
COMMENT ON COLUMN "instructor_curriculum_settings"."course_id" IS '강좌 ID';
|
||||
COMMENT ON COLUMN "instructor_curriculum_settings"."active_week" IS '현재 진행 주차';
|
||||
COMMENT ON COLUMN "instructor_curriculum_settings"."active_learning_step" IS '현재 진행 학습단계 식별값';
|
||||
COMMENT ON COLUMN "instructor_curriculum_settings"."created_at" IS '레코드 생성 시각';
|
||||
COMMENT ON COLUMN "instructor_curriculum_settings"."updated_at" IS '레코드 수정 시각';
|
||||
|
||||
COMMENT ON TABLE "instructor_curriculum_week_overrides" IS '강사 소속/강좌별 주차 커리큘럼 수정값.';
|
||||
COMMENT ON COLUMN "instructor_curriculum_week_overrides"."id" IS '주차 커리큘럼 수정값 ID';
|
||||
COMMENT ON COLUMN "instructor_curriculum_week_overrides"."organization_id" IS '수정 소속 조직 ID';
|
||||
COMMENT ON COLUMN "instructor_curriculum_week_overrides"."organization_name" IS '수정 소속 조직명';
|
||||
COMMENT ON COLUMN "instructor_curriculum_week_overrides"."course_id" IS '강좌 ID';
|
||||
COMMENT ON COLUMN "instructor_curriculum_week_overrides"."week" IS '주차 번호';
|
||||
COMMENT ON COLUMN "instructor_curriculum_week_overrides"."tag_label" IS '주차 플랫폼/태그 표시 라벨';
|
||||
COMMENT ON COLUMN "instructor_curriculum_week_overrides"."title" IS '수정된 주차 제목';
|
||||
COMMENT ON COLUMN "instructor_curriculum_week_overrides"."duration" IS '수정된 예상 학습 시간';
|
||||
COMMENT ON COLUMN "instructor_curriculum_week_overrides"."overview" IS '수정된 주차 개요';
|
||||
COMMENT ON COLUMN "instructor_curriculum_week_overrides"."deliverable" IS '수정된 제출 기준';
|
||||
COMMENT ON COLUMN "instructor_curriculum_week_overrides"."requires_source_submission" IS '소스 제출 필요 여부 수정값';
|
||||
COMMENT ON COLUMN "instructor_curriculum_week_overrides"."topics" IS '수정된 학습단계 JSON';
|
||||
COMMENT ON COLUMN "instructor_curriculum_week_overrides"."topic_details" IS '수정된 학습단계 상세 JSON';
|
||||
COMMENT ON COLUMN "instructor_curriculum_week_overrides"."practice_contents" IS '수정된 실습내용 JSON';
|
||||
COMMENT ON COLUMN "instructor_curriculum_week_overrides"."disabled_module_ids" IS '비활성화한 주차 모듈 ID 목록 JSON';
|
||||
COMMENT ON COLUMN "instructor_curriculum_week_overrides"."created_at" IS '레코드 생성 시각';
|
||||
COMMENT ON COLUMN "instructor_curriculum_week_overrides"."updated_at" IS '레코드 수정 시각';
|
||||
|
||||
COMMENT ON TABLE "platforms" IS '관리자가 정의하는 플랫폼 마스터. 강좌 태그와 강사 등록 플랫폼의 기준이다.';
|
||||
COMMENT ON COLUMN "platforms"."id" IS '플랫폼 마스터 ID';
|
||||
COMMENT ON COLUMN "platforms"."platform_key" IS '플랫폼 고유 키';
|
||||
COMMENT ON COLUMN "platforms"."display_name" IS '플랫폼 표시명';
|
||||
COMMENT ON COLUMN "platforms"."description" IS '플랫폼 설명';
|
||||
COMMENT ON COLUMN "platforms"."organization_name" IS '플랫폼 기본 소속 조직명';
|
||||
COMMENT ON COLUMN "platforms"."icon_key" IS '플랫폼 아이콘 키';
|
||||
COMMENT ON COLUMN "platforms"."is_active" IS '플랫폼 활성 여부';
|
||||
COMMENT ON COLUMN "platforms"."curriculum_tag_only" IS '커리큘럼 태그 전용 여부';
|
||||
COMMENT ON COLUMN "platforms"."vehicle_type" IS '차량 또는 장비 유형';
|
||||
COMMENT ON COLUMN "platforms"."wheelbase_meters" IS '휠베이스 미터 단위';
|
||||
COMMENT ON COLUMN "platforms"."max_speed_meters_per_second" IS '최대 속도 m/s';
|
||||
COMMENT ON COLUMN "platforms"."max_steering_angle_degrees" IS '최대 조향각 도 단위';
|
||||
COMMENT ON COLUMN "platforms"."sensors" IS '기본 센서 구성 JSON';
|
||||
COMMENT ON COLUMN "platforms"."drivers" IS '기본 드라이버 구성 JSON';
|
||||
COMMENT ON COLUMN "platforms"."algorithms" IS '기본 알고리즘 구성 JSON';
|
||||
COMMENT ON COLUMN "platforms"."default_studio_settings" IS '강사 플랫폼 등록 시 복사되는 기본 개발 스튜디오 설정 JSON';
|
||||
COMMENT ON COLUMN "platforms"."control_duration_minutes" IS '기본 제어권 유지 시간 분';
|
||||
COMMENT ON COLUMN "platforms"."control_warning_minutes" IS '기본 제어권 만료 경고 시간 분';
|
||||
COMMENT ON COLUMN "platforms"."created_at" IS '레코드 생성 시각';
|
||||
COMMENT ON COLUMN "platforms"."updated_at" IS '레코드 수정 시각';
|
||||
|
||||
COMMENT ON TABLE "instructor_platforms" IS '강사가 실제 수업에 등록한 플랫폼 인스턴스.';
|
||||
COMMENT ON COLUMN "instructor_platforms"."id" IS '강사 등록 플랫폼 ID';
|
||||
COMMENT ON COLUMN "instructor_platforms"."organization_id" IS '등록 소속 조직 ID';
|
||||
COMMENT ON COLUMN "instructor_platforms"."organization_name" IS '등록 소속 조직명';
|
||||
COMMENT ON COLUMN "instructor_platforms"."platform_id" IS '플랫폼 마스터 ID';
|
||||
COMMENT ON COLUMN "instructor_platforms"."display_name" IS '수업에서 사용할 플랫폼 표시명';
|
||||
COMMENT ON COLUMN "instructor_platforms"."instance_no" IS '동일 플랫폼 내 인스턴스 번호';
|
||||
COMMENT ON COLUMN "instructor_platforms"."status" IS '플랫폼 운영 상태';
|
||||
COMMENT ON COLUMN "instructor_platforms"."battery" IS '배터리 잔량 퍼센트';
|
||||
COMMENT ON COLUMN "instructor_platforms"."uptime_minutes" IS '가동 시간 분';
|
||||
COMMENT ON COLUMN "instructor_platforms"."speed_meters" IS '현재 속도 m/s';
|
||||
COMMENT ON COLUMN "instructor_platforms"."error_count" IS '오류 발생 수';
|
||||
COMMENT ON COLUMN "instructor_platforms"."emergency_stop" IS '긴급정지 여부';
|
||||
COMMENT ON COLUMN "instructor_platforms"."vehicle_type" IS '차량 또는 장비 유형';
|
||||
COMMENT ON COLUMN "instructor_platforms"."wheelbase_meters" IS '휠베이스 미터 단위';
|
||||
COMMENT ON COLUMN "instructor_platforms"."max_speed_meters_per_second" IS '최대 속도 m/s';
|
||||
COMMENT ON COLUMN "instructor_platforms"."max_steering_angle_degrees" IS '최대 조향각 도 단위';
|
||||
COMMENT ON COLUMN "instructor_platforms"."sensors" IS '강사 플랫폼 센서 설정 JSON';
|
||||
COMMENT ON COLUMN "instructor_platforms"."drivers" IS '강사 플랫폼 드라이버 정보 JSON';
|
||||
COMMENT ON COLUMN "instructor_platforms"."algorithms" IS '강사 플랫폼 알고리즘 설정 JSON';
|
||||
COMMENT ON COLUMN "instructor_platforms"."studio_settings_override" IS '플랫폼별 개발 스튜디오 설정 오버라이드 JSON';
|
||||
COMMENT ON COLUMN "instructor_platforms"."control_duration_minutes" IS '제어권 유지 시간 분';
|
||||
COMMENT ON COLUMN "instructor_platforms"."control_warning_minutes" IS '제어권 만료 경고 시간 분';
|
||||
COMMENT ON COLUMN "instructor_platforms"."created_at" IS '레코드 생성 시각';
|
||||
COMMENT ON COLUMN "instructor_platforms"."updated_at" IS '레코드 수정 시각';
|
||||
|
||||
COMMENT ON TABLE "course_platforms" IS '강좌에서 사용 가능한 플랫폼 목록.';
|
||||
COMMENT ON COLUMN "course_platforms"."id" IS '강좌 플랫폼 ID';
|
||||
COMMENT ON COLUMN "course_platforms"."course_id" IS '강좌 ID';
|
||||
COMMENT ON COLUMN "course_platforms"."platform_id" IS '플랫폼 마스터 ID';
|
||||
COMMENT ON COLUMN "course_platforms"."status" IS '강좌 내 플랫폼 표시 상태';
|
||||
COMMENT ON COLUMN "course_platforms"."action" IS '강좌 내 플랫폼 기본 액션 문구';
|
||||
COMMENT ON COLUMN "course_platforms"."state" IS '강좌 내 플랫폼 사용 상태 키';
|
||||
COMMENT ON COLUMN "course_platforms"."sort_order" IS '플랫폼 표시 순서';
|
||||
COMMENT ON COLUMN "course_platforms"."created_at" IS '레코드 생성 시각';
|
||||
COMMENT ON COLUMN "course_platforms"."updated_at" IS '레코드 수정 시각';
|
||||
|
||||
COMMENT ON TABLE "course_environment_profiles" IS '강좌/주차/플랫폼별 개발환경 실행 프로파일.';
|
||||
COMMENT ON COLUMN "course_environment_profiles"."id" IS '개발환경 프로파일 ID';
|
||||
COMMENT ON COLUMN "course_environment_profiles"."course_id" IS '강좌 ID';
|
||||
COMMENT ON COLUMN "course_environment_profiles"."week" IS '주차 번호';
|
||||
COMMENT ON COLUMN "course_environment_profiles"."platform_key" IS '대상 플랫폼 키';
|
||||
COMMENT ON COLUMN "course_environment_profiles"."profile_type" IS '환경 프로파일 유형';
|
||||
COMMENT ON COLUMN "course_environment_profiles"."name" IS '프로파일 이름';
|
||||
COMMENT ON COLUMN "course_environment_profiles"."config" IS '컨테이너, 브릿지, 뷰어 실행 설정 JSON';
|
||||
COMMENT ON COLUMN "course_environment_profiles"."enabled" IS '프로파일 사용 여부';
|
||||
COMMENT ON COLUMN "course_environment_profiles"."created_at" IS '레코드 생성 시각';
|
||||
COMMENT ON COLUMN "course_environment_profiles"."updated_at" IS '레코드 수정 시각';
|
||||
|
||||
COMMENT ON TABLE "visualization_platforms" IS '시각화 화면에서 선택 가능한 주행/시뮬레이션 스택 마스터.';
|
||||
COMMENT ON COLUMN "visualization_platforms"."platform_key" IS '시각화 플랫폼 키';
|
||||
COMMENT ON COLUMN "visualization_platforms"."type" IS '시각화 스택 유형. AUTOWARE, NAV2, SLAM 등';
|
||||
COMMENT ON COLUMN "visualization_platforms"."display_name" IS '시각화 플랫폼 표시명';
|
||||
COMMENT ON COLUMN "visualization_platforms"."description" IS '시각화 플랫폼 설명';
|
||||
COMMENT ON COLUMN "visualization_platforms"."default_config" IS '기본 시각화 실행 설정 JSON';
|
||||
COMMENT ON COLUMN "visualization_platforms"."sort_order" IS '시각화 플랫폼 표시 순서';
|
||||
COMMENT ON COLUMN "visualization_platforms"."enabled" IS '시각화 플랫폼 사용 여부';
|
||||
COMMENT ON COLUMN "visualization_platforms"."created_at" IS '레코드 생성 시각';
|
||||
COMMENT ON COLUMN "visualization_platforms"."updated_at" IS '레코드 수정 시각';
|
||||
|
||||
COMMENT ON TABLE "course_week_visualization_platforms" IS '강좌 주차별 허용 시각화 플랫폼 선택값.';
|
||||
COMMENT ON COLUMN "course_week_visualization_platforms"."id" IS '주차 시각화 플랫폼 선택 ID';
|
||||
COMMENT ON COLUMN "course_week_visualization_platforms"."course_id" IS '강좌 ID';
|
||||
COMMENT ON COLUMN "course_week_visualization_platforms"."week" IS '주차 번호';
|
||||
COMMENT ON COLUMN "course_week_visualization_platforms"."platform_key" IS '시각화 플랫폼 키';
|
||||
COMMENT ON COLUMN "course_week_visualization_platforms"."enabled" IS '주차에서 해당 시각화 플랫폼 사용 여부';
|
||||
COMMENT ON COLUMN "course_week_visualization_platforms"."created_at" IS '레코드 생성 시각';
|
||||
COMMENT ON COLUMN "course_week_visualization_platforms"."updated_at" IS '레코드 수정 시각';
|
||||
|
||||
COMMENT ON TABLE "platform_control_sessions" IS '플랫폼 제어권을 실제로 점유 중이거나 종료된 세션.';
|
||||
COMMENT ON COLUMN "platform_control_sessions"."id" IS '제어권 세션 ID';
|
||||
COMMENT ON COLUMN "platform_control_sessions"."organization_id" IS '세션 소속 조직 ID';
|
||||
COMMENT ON COLUMN "platform_control_sessions"."organization_name" IS '세션 소속 조직명';
|
||||
COMMENT ON COLUMN "platform_control_sessions"."platform_id" IS '플랫폼 마스터 ID';
|
||||
COMMENT ON COLUMN "platform_control_sessions"."instructor_platform_id" IS '강사 등록 플랫폼 ID';
|
||||
COMMENT ON COLUMN "platform_control_sessions"."holder_user_id" IS '제어권 보유 사용자 ID';
|
||||
COMMENT ON COLUMN "platform_control_sessions"."group_id" IS '제어권 대상 수강생 그룹 ID';
|
||||
COMMENT ON COLUMN "platform_control_sessions"."status" IS '제어권 세션 상태';
|
||||
COMMENT ON COLUMN "platform_control_sessions"."started_at" IS '제어권 시작 시각';
|
||||
COMMENT ON COLUMN "platform_control_sessions"."ends_at" IS '제어권 만료 예정 시각';
|
||||
COMMENT ON COLUMN "platform_control_sessions"."warning_at" IS '만료 경고 예정 시각';
|
||||
COMMENT ON COLUMN "platform_control_sessions"."warning_sent_at" IS '만료 경고 발송 시각';
|
||||
COMMENT ON COLUMN "platform_control_sessions"."released_at" IS '제어권 반납 또는 종료 시각';
|
||||
COMMENT ON COLUMN "platform_control_sessions"."end_reason" IS '제어권 종료 사유';
|
||||
COMMENT ON COLUMN "platform_control_sessions"."created_at" IS '레코드 생성 시각';
|
||||
COMMENT ON COLUMN "platform_control_sessions"."updated_at" IS '레코드 수정 시각';
|
||||
|
||||
COMMENT ON TABLE "platform_control_queue_entries" IS '플랫폼 제어권 대기 예약 목록.';
|
||||
COMMENT ON COLUMN "platform_control_queue_entries"."id" IS '제어권 대기 ID';
|
||||
COMMENT ON COLUMN "platform_control_queue_entries"."organization_id" IS '대기 소속 조직 ID';
|
||||
COMMENT ON COLUMN "platform_control_queue_entries"."organization_name" IS '대기 소속 조직명';
|
||||
COMMENT ON COLUMN "platform_control_queue_entries"."platform_id" IS '플랫폼 마스터 ID';
|
||||
COMMENT ON COLUMN "platform_control_queue_entries"."instructor_platform_id" IS '강사 등록 플랫폼 ID';
|
||||
COMMENT ON COLUMN "platform_control_queue_entries"."user_id" IS '대기 신청 사용자 ID';
|
||||
COMMENT ON COLUMN "platform_control_queue_entries"."group_id" IS '대기 대상 수강생 그룹 ID';
|
||||
COMMENT ON COLUMN "platform_control_queue_entries"."status" IS '대기 예약 상태';
|
||||
COMMENT ON COLUMN "platform_control_queue_entries"."requested_at" IS '대기 신청 시각';
|
||||
COMMENT ON COLUMN "platform_control_queue_entries"."granted_at" IS '제어권 배정 시각';
|
||||
COMMENT ON COLUMN "platform_control_queue_entries"."canceled_at" IS '대기 취소 시각';
|
||||
COMMENT ON COLUMN "platform_control_queue_entries"."created_at" IS '레코드 생성 시각';
|
||||
COMMENT ON COLUMN "platform_control_queue_entries"."updated_at" IS '레코드 수정 시각';
|
||||
|
||||
COMMENT ON TABLE "course_student_progress" IS '학생별 강좌 전체 학습 현황 집계.';
|
||||
COMMENT ON COLUMN "course_student_progress"."id" IS '강좌 학습 현황 ID';
|
||||
COMMENT ON COLUMN "course_student_progress"."course_id" IS '강좌 ID';
|
||||
COMMENT ON COLUMN "course_student_progress"."user_id" IS '학생 사용자 ID';
|
||||
COMMENT ON COLUMN "course_student_progress"."completed_weeks" IS '완료한 주차 수';
|
||||
COMMENT ON COLUMN "course_student_progress"."total_weeks" IS '전체 주차 수';
|
||||
COMMENT ON COLUMN "course_student_progress"."learning_hours" IS '누적 학습 시간 분';
|
||||
COMMENT ON COLUMN "course_student_progress"."commit_count" IS '소스 제출 커밋 수';
|
||||
COMMENT ON COLUMN "course_student_progress"."average_score" IS '평균 점수';
|
||||
COMMENT ON COLUMN "course_student_progress"."created_at" IS '레코드 생성 시각';
|
||||
COMMENT ON COLUMN "course_student_progress"."updated_at" IS '레코드 수정 시각';
|
||||
|
||||
COMMENT ON TABLE "course_student_week_progress" IS '학생별 주차 제출 및 진행 현황.';
|
||||
COMMENT ON COLUMN "course_student_week_progress"."id" IS '주차 학습 현황 ID';
|
||||
COMMENT ON COLUMN "course_student_week_progress"."course_id" IS '강좌 ID';
|
||||
COMMENT ON COLUMN "course_student_week_progress"."user_id" IS '학생 사용자 ID';
|
||||
COMMENT ON COLUMN "course_student_week_progress"."week" IS '주차 번호';
|
||||
COMMENT ON COLUMN "course_student_week_progress"."submitted_at" IS '학생 제출 완료 시각';
|
||||
COMMENT ON COLUMN "course_student_week_progress"."commit_count" IS '해당 주차 제출 커밋 수';
|
||||
COMMENT ON COLUMN "course_student_week_progress"."created_at" IS '레코드 생성 시각';
|
||||
COMMENT ON COLUMN "course_student_week_progress"."updated_at" IS '레코드 수정 시각';
|
||||
|
||||
COMMENT ON TABLE "studio_repositories" IS '학생별 주차 모듈 Gitea 저장소 매핑.';
|
||||
COMMENT ON COLUMN "studio_repositories"."id" IS '스튜디오 저장소 매핑 ID';
|
||||
COMMENT ON COLUMN "studio_repositories"."user_id" IS '학생 사용자 ID';
|
||||
COMMENT ON COLUMN "studio_repositories"."week_id" IS '주차 커리큘럼 ID';
|
||||
COMMENT ON COLUMN "studio_repositories"."module_id" IS '주차 모듈 ID';
|
||||
COMMENT ON COLUMN "studio_repositories"."repo_owner" IS 'Gitea 저장소 owner 또는 조직';
|
||||
COMMENT ON COLUMN "studio_repositories"."repo_name" IS 'Gitea 저장소명';
|
||||
COMMENT ON COLUMN "studio_repositories"."repository_url" IS '학생 작업 저장소 URL';
|
||||
COMMENT ON COLUMN "studio_repositories"."template_repository_url" IS '원본 템플릿 저장소 URL';
|
||||
COMMENT ON COLUMN "studio_repositories"."created_at" IS '레코드 생성 시각';
|
||||
COMMENT ON COLUMN "studio_repositories"."updated_at" IS '레코드 수정 시각';
|
||||
|
||||
COMMENT ON TABLE "submission_scores" IS '학생 제출물의 모듈별 점수.';
|
||||
COMMENT ON COLUMN "submission_scores"."id" IS '모듈별 제출 점수 ID';
|
||||
COMMENT ON COLUMN "submission_scores"."course_id" IS '강좌 ID';
|
||||
COMMENT ON COLUMN "submission_scores"."week_id" IS '주차 커리큘럼 ID';
|
||||
COMMENT ON COLUMN "submission_scores"."module_id" IS '주차 모듈 ID';
|
||||
COMMENT ON COLUMN "submission_scores"."student_id" IS '학생 사용자 ID';
|
||||
COMMENT ON COLUMN "submission_scores"."score" IS '모듈별 점수';
|
||||
COMMENT ON COLUMN "submission_scores"."created_at" IS '레코드 생성 시각';
|
||||
COMMENT ON COLUMN "submission_scores"."updated_at" IS '레코드 수정 시각';
|
||||
|
||||
COMMENT ON TABLE "submission_total_scores" IS '학생 제출물의 주차별 총점.';
|
||||
COMMENT ON COLUMN "submission_total_scores"."id" IS '주차 제출 총점 ID';
|
||||
COMMENT ON COLUMN "submission_total_scores"."course_id" IS '강좌 ID';
|
||||
COMMENT ON COLUMN "submission_total_scores"."week_id" IS '주차 커리큘럼 ID';
|
||||
COMMENT ON COLUMN "submission_total_scores"."student_id" IS '학생 사용자 ID';
|
||||
COMMENT ON COLUMN "submission_total_scores"."score" IS '주차 제출 총점';
|
||||
COMMENT ON COLUMN "submission_total_scores"."created_at" IS '레코드 생성 시각';
|
||||
COMMENT ON COLUMN "submission_total_scores"."updated_at" IS '레코드 수정 시각';
|
||||
|
||||
COMMENT ON TABLE "chat_threads" IS '개발 스튜디오 질의응답 채팅방.';
|
||||
COMMENT ON COLUMN "chat_threads"."id" IS '채팅방 ID';
|
||||
COMMENT ON COLUMN "chat_threads"."course_id" IS '채팅 대상 강좌 ID';
|
||||
COMMENT ON COLUMN "chat_threads"."week_id" IS '채팅 대상 주차 ID';
|
||||
COMMENT ON COLUMN "chat_threads"."student_id" IS '질문 학생 사용자 ID';
|
||||
COMMENT ON COLUMN "chat_threads"."assigned_instructor_id" IS '배정된 강사 사용자 ID';
|
||||
COMMENT ON COLUMN "chat_threads"."type" IS '채팅방 유형. AI, 강사, 혼합';
|
||||
COMMENT ON COLUMN "chat_threads"."status" IS '채팅방 처리 상태';
|
||||
COMMENT ON COLUMN "chat_threads"."title" IS '채팅방 제목';
|
||||
COMMENT ON COLUMN "chat_threads"."created_at" IS '레코드 생성 시각';
|
||||
COMMENT ON COLUMN "chat_threads"."updated_at" IS '레코드 수정 시각';
|
||||
COMMENT ON COLUMN "chat_threads"."last_message_at" IS '마지막 메시지 작성 시각';
|
||||
|
||||
COMMENT ON TABLE "chat_messages" IS '개발 스튜디오 질의응답 메시지.';
|
||||
COMMENT ON COLUMN "chat_messages"."id" IS '채팅 메시지 ID';
|
||||
COMMENT ON COLUMN "chat_messages"."thread_id" IS '소속 채팅방 ID';
|
||||
COMMENT ON COLUMN "chat_messages"."sender_type" IS '메시지 발신자 유형';
|
||||
COMMENT ON COLUMN "chat_messages"."sender_user_id" IS '발신 사용자 ID';
|
||||
COMMENT ON COLUMN "chat_messages"."content" IS '메시지 내용';
|
||||
COMMENT ON COLUMN "chat_messages"."content_format" IS '메시지 내용 형식';
|
||||
COMMENT ON COLUMN "chat_messages"."metadata" IS '메시지 부가 메타데이터 JSON';
|
||||
COMMENT ON COLUMN "chat_messages"."created_at" IS '레코드 생성 시각';
|
||||
COMMENT ON COLUMN "chat_messages"."deleted_at" IS '메시지 삭제 시각';
|
||||
|
||||
COMMENT ON TABLE "ai_chat_runs" IS 'AI 채팅 응답 생성 실행 로그.';
|
||||
COMMENT ON COLUMN "ai_chat_runs"."id" IS 'AI 실행 로그 ID';
|
||||
COMMENT ON COLUMN "ai_chat_runs"."message_id" IS 'AI 응답 메시지 ID';
|
||||
COMMENT ON COLUMN "ai_chat_runs"."model" IS '사용한 AI 모델명';
|
||||
COMMENT ON COLUMN "ai_chat_runs"."prompt_tokens" IS '프롬프트 토큰 수';
|
||||
COMMENT ON COLUMN "ai_chat_runs"."completion_tokens" IS '응답 토큰 수';
|
||||
COMMENT ON COLUMN "ai_chat_runs"."total_tokens" IS '전체 토큰 수';
|
||||
COMMENT ON COLUMN "ai_chat_runs"."latency_ms" IS 'AI 응답 지연 시간 밀리초';
|
||||
COMMENT ON COLUMN "ai_chat_runs"."status" IS 'AI 실행 상태';
|
||||
COMMENT ON COLUMN "ai_chat_runs"."error" IS 'AI 실행 오류 메시지';
|
||||
COMMENT ON COLUMN "ai_chat_runs"."created_at" IS '레코드 생성 시각';
|
||||
@@ -0,0 +1,25 @@
|
||||
ALTER TABLE "student_groups"
|
||||
ADD COLUMN "instructor_platform_id" UUID;
|
||||
|
||||
UPDATE "student_groups" AS g
|
||||
SET "instructor_platform_id" = p."id"
|
||||
FROM "instructor_platforms" AS p
|
||||
WHERE g."platform_name" IS NOT NULL
|
||||
AND g."platform_name" = p."display_name"
|
||||
AND (
|
||||
g."organization_id" = p."organization_id"
|
||||
OR g."organization_name" = p."organization_name"
|
||||
);
|
||||
|
||||
CREATE INDEX "student_groups_instructor_platform_id_idx"
|
||||
ON "student_groups"("instructor_platform_id");
|
||||
|
||||
ALTER TABLE "student_groups"
|
||||
ADD CONSTRAINT "student_groups_instructor_platform_id_fkey"
|
||||
FOREIGN KEY ("instructor_platform_id")
|
||||
REFERENCES "instructor_platforms"("id")
|
||||
ON DELETE SET NULL
|
||||
ON UPDATE CASCADE;
|
||||
|
||||
COMMENT ON COLUMN "student_groups"."platform_name" IS '기존 호환용 플랫폼 표시명. 실제 배정 참조는 instructor_platform_id를 사용한다.';
|
||||
COMMENT ON COLUMN "student_groups"."instructor_platform_id" IS '그룹에 배정된 강사 등록 플랫폼 ID';
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE "instructor_curriculum_week_overrides"
|
||||
ADD COLUMN "topic_video_urls" JSONB;
|
||||
|
||||
COMMENT ON COLUMN "instructor_curriculum_week_overrides"."topic_video_urls" IS '학습단계별 동영상 강의 URL 목록';
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE "course_week_references"
|
||||
ADD COLUMN IF NOT EXISTS "kind" TEXT NOT NULL DEFAULT 'REFERENCE';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "course_week_references_week_id_kind_idx"
|
||||
ON "course_week_references"("week_id", "kind");
|
||||
|
||||
COMMENT ON COLUMN "course_week_references"."kind" IS '주차 파일 자료 유형. REFERENCE는 참고자료, EXAMPLE_CODE는 예제 코드 파일입니다.';
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TYPE "ChatMessageStatus" AS ENUM ('ACTIVE', 'CANCELED');
|
||||
|
||||
ALTER TABLE "chat_messages"
|
||||
ADD COLUMN "status" "ChatMessageStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||
ADD COLUMN "canceled_at" TIMESTAMPTZ(3);
|
||||
|
||||
CREATE INDEX "chat_messages_thread_id_status_created_at_idx"
|
||||
ON "chat_messages"("thread_id", "status", "created_at");
|
||||
|
||||
COMMENT ON COLUMN "chat_messages"."status" IS '채팅 메시지 처리 상태. AI 응답 중단 시 CANCELED로 표시해 다음 AI 컨텍스트에서 제외한다.';
|
||||
COMMENT ON COLUMN "chat_messages"."canceled_at" IS 'AI 응답이 사용자에 의해 중단된 시각.';
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE "course_week_modules"
|
||||
ADD COLUMN IF NOT EXISTS "source_type" TEXT NOT NULL DEFAULT 'GIT',
|
||||
ADD COLUMN IF NOT EXISTS "prebuilt_artifacts" JSONB NOT NULL DEFAULT '[]';
|
||||
|
||||
COMMENT ON COLUMN "course_week_modules"."source_type" IS '모듈 소스 제공 방식입니다. 기본값 GIT은 template_repository_url을 사용합니다.';
|
||||
COMMENT ON COLUMN "course_week_modules"."prebuilt_artifacts" IS 'R2 등 외부 저장소에 둔 모듈별 사전 빌드 산출물 목록입니다. build/install tar 아카이브를 기록합니다.';
|
||||
@@ -0,0 +1,49 @@
|
||||
UPDATE "course_environment_profiles"
|
||||
SET "config" = jsonb_set(
|
||||
jsonb_set("config", '{bridge,image}', '"nav2-slam:humble"'::jsonb, true),
|
||||
'{bridge,buildContext}',
|
||||
'"resources/slam-ros-bridge"'::jsonb,
|
||||
true
|
||||
),
|
||||
"updated_at" = CURRENT_TIMESTAMP
|
||||
WHERE upper("profile_type") = 'SLAM';
|
||||
|
||||
UPDATE "course_environment_profiles"
|
||||
SET "config" = jsonb_set(
|
||||
"config",
|
||||
'{runtime,containers}',
|
||||
COALESCE((
|
||||
SELECT jsonb_agg(container)
|
||||
FROM jsonb_array_elements("config" #> '{runtime,containers}') AS container
|
||||
WHERE container->>'name' <> 'slam-ros-bridge'
|
||||
), '[]'::jsonb),
|
||||
true
|
||||
),
|
||||
"updated_at" = CURRENT_TIMESTAMP
|
||||
WHERE upper("profile_type") = 'SLAM'
|
||||
AND jsonb_typeof("config" #> '{runtime,containers}') = 'array';
|
||||
|
||||
UPDATE "visualization_platforms"
|
||||
SET "default_config" = jsonb_set(
|
||||
jsonb_set("default_config", '{bridge,image}', '"nav2-slam:humble"'::jsonb, true),
|
||||
'{bridge,buildContext}',
|
||||
'"resources/slam-ros-bridge"'::jsonb,
|
||||
true
|
||||
),
|
||||
"updated_at" = CURRENT_TIMESTAMP
|
||||
WHERE "platform_key" = 'SLAM';
|
||||
|
||||
UPDATE "visualization_platforms"
|
||||
SET "default_config" = jsonb_set(
|
||||
"default_config",
|
||||
'{runtime,containers}',
|
||||
COALESCE((
|
||||
SELECT jsonb_agg(container)
|
||||
FROM jsonb_array_elements("default_config" #> '{runtime,containers}') AS container
|
||||
WHERE container->>'name' <> 'slam-ros-bridge'
|
||||
), '[]'::jsonb),
|
||||
true
|
||||
),
|
||||
"updated_at" = CURRENT_TIMESTAMP
|
||||
WHERE "platform_key" = 'SLAM'
|
||||
AND jsonb_typeof("default_config" #> '{runtime,containers}') = 'array';
|
||||
@@ -0,0 +1,139 @@
|
||||
INSERT INTO "users" ("id", "email", "name", "auth_role", "primary_organization_name", "createdAt", "updatedAt")
|
||||
VALUES (gen_random_uuid(), 'system@autonomystudio.local', 'Autonomy Studio', 'SYSTEM', 'Autonomy Studio', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT ("email") DO UPDATE
|
||||
SET "name" = EXCLUDED."name",
|
||||
"auth_role" = EXCLUDED."auth_role",
|
||||
"primary_organization_name" = EXCLUDED."primary_organization_name",
|
||||
"updatedAt" = CURRENT_TIMESTAMP;
|
||||
|
||||
WITH teacher AS (
|
||||
SELECT "id" FROM "users" WHERE "email" = 'system@autonomystudio.local'
|
||||
)
|
||||
INSERT INTO "courses" ("slug", "title", "subtitle", "description", "organization_name", "teacher_id", "is_active", "updated_at")
|
||||
SELECT
|
||||
'autonomy-studio-lms-guide',
|
||||
'NAV2 알아보기',
|
||||
'처음 사용하는 수강생을 위한 짧은 체험 강좌',
|
||||
'강좌 배정 전 신규 사용자가 LMS 화면 흐름을 빠르게 익힐 수 있는 공통 체험 강좌입니다. NAV2 개념을 예시로 커리큘럼 확인, 학습단계 열람, 질의응답 사용법만 간단히 안내합니다.',
|
||||
'Autonomy Studio',
|
||||
teacher."id",
|
||||
true,
|
||||
CURRENT_TIMESTAMP
|
||||
FROM teacher
|
||||
ON CONFLICT ("slug") DO UPDATE
|
||||
SET "title" = EXCLUDED."title",
|
||||
"subtitle" = EXCLUDED."subtitle",
|
||||
"description" = EXCLUDED."description",
|
||||
"organization_name" = EXCLUDED."organization_name",
|
||||
"teacher_id" = EXCLUDED."teacher_id",
|
||||
"is_active" = true,
|
||||
"updated_at" = CURRENT_TIMESTAMP;
|
||||
|
||||
WITH course AS (
|
||||
SELECT "id" FROM "courses" WHERE "slug" = 'autonomy-studio-lms-guide'
|
||||
)
|
||||
INSERT INTO "course_weeks" (
|
||||
"course_id",
|
||||
"week",
|
||||
"title",
|
||||
"tags",
|
||||
"progress",
|
||||
"state",
|
||||
"duration",
|
||||
"github",
|
||||
"overview",
|
||||
"deliverable",
|
||||
"requires_source_submission",
|
||||
"updated_at"
|
||||
)
|
||||
SELECT
|
||||
course."id",
|
||||
1,
|
||||
'NAV2 알아보기',
|
||||
ARRAY['NAV2','진행중']::TEXT[],
|
||||
0,
|
||||
'current',
|
||||
'15분',
|
||||
'제출 없음',
|
||||
'NAV2가 어떤 역할을 하는지 간단히 살펴보고, LMS에서 강좌 내용과 학습단계, 질문 기능을 어떻게 사용하는지 확인합니다.',
|
||||
'이 강좌는 체험용이므로 별도 제출은 없습니다. 실제 수업 강좌가 배정되면 해당 강좌가 우선 표시됩니다.',
|
||||
false,
|
||||
CURRENT_TIMESTAMP
|
||||
FROM course
|
||||
ON CONFLICT ("course_id", "week") DO UPDATE
|
||||
SET "title" = EXCLUDED."title",
|
||||
"tags" = EXCLUDED."tags",
|
||||
"progress" = EXCLUDED."progress",
|
||||
"state" = EXCLUDED."state",
|
||||
"duration" = EXCLUDED."duration",
|
||||
"github" = EXCLUDED."github",
|
||||
"overview" = EXCLUDED."overview",
|
||||
"deliverable" = EXCLUDED."deliverable",
|
||||
"requires_source_submission" = false,
|
||||
"updated_at" = CURRENT_TIMESTAMP;
|
||||
|
||||
DELETE FROM "course_weeks"
|
||||
USING "courses"
|
||||
WHERE "course_weeks"."course_id" = "courses"."id"
|
||||
AND "courses"."slug" = 'autonomy-studio-lms-guide'
|
||||
AND "course_weeks"."week" <> 1;
|
||||
|
||||
DELETE FROM "course_week_practices"
|
||||
USING "course_weeks", "courses"
|
||||
WHERE "course_week_practices"."week_id" = "course_weeks"."id"
|
||||
AND "course_weeks"."course_id" = "courses"."id"
|
||||
AND "courses"."slug" = 'autonomy-studio-lms-guide';
|
||||
|
||||
DELETE FROM "course_week_topics"
|
||||
USING "course_weeks", "courses"
|
||||
WHERE "course_week_topics"."week_id" = "course_weeks"."id"
|
||||
AND "course_weeks"."course_id" = "courses"."id"
|
||||
AND "courses"."slug" = 'autonomy-studio-lms-guide';
|
||||
|
||||
WITH week1 AS (
|
||||
SELECT w."id" AS week_id
|
||||
FROM "course_weeks" w
|
||||
JOIN "courses" c ON c."id" = w."course_id"
|
||||
WHERE c."slug" = 'autonomy-studio-lms-guide' AND w."week" = 1
|
||||
)
|
||||
INSERT INTO "course_week_topics" ("week_id", "sort_order", "title", "detail")
|
||||
SELECT week1.week_id, data.sort_order, data.title, data.detail
|
||||
FROM week1
|
||||
CROSS JOIN (
|
||||
VALUES
|
||||
(1, 'NAV2란 무엇인가', 'NAV2는 ROS 2 기반 로봇 내비게이션 스택입니다. 지도, 위치추정, 경로계획, 제어 흐름이 어떻게 이어지는지 큰 그림을 확인합니다.'),
|
||||
(2, 'LMS에서 강좌 읽기', '주차 카드와 학습단계를 열어 강의 내용, 참고 자료, 진행 상태를 확인하는 방법을 익힙니다.'),
|
||||
(3, '질문 기능 사용하기', '학습 중 궁금한 내용을 AI 또는 강사에게 질문하고, 답변을 확인하는 기본 흐름을 살펴봅니다.')
|
||||
) AS data(sort_order, title, detail);
|
||||
|
||||
WITH week1 AS (
|
||||
SELECT w."id" AS week_id
|
||||
FROM "course_weeks" w
|
||||
JOIN "courses" c ON c."id" = w."course_id"
|
||||
WHERE c."slug" = 'autonomy-studio-lms-guide' AND w."week" = 1
|
||||
)
|
||||
INSERT INTO "course_week_practices" ("week_id", "sort_order", "title", "description")
|
||||
SELECT week1.week_id, data.sort_order, data.title, data.description
|
||||
FROM week1
|
||||
CROSS JOIN (
|
||||
VALUES
|
||||
(1, '화면 둘러보기', '커리큘럼 화면에서 주차 카드, 학습단계, 강의 내용을 차례로 확인합니다.'),
|
||||
(2, '질문 남겨보기', '질의응답 화면에서 NAV2 관련 질문을 입력하는 위치를 확인합니다.')
|
||||
) AS data(sort_order, title, description);
|
||||
|
||||
WITH guide AS (
|
||||
SELECT "id" FROM "courses" WHERE "slug" = 'autonomy-studio-lms-guide'
|
||||
),
|
||||
local_platform AS (
|
||||
SELECT "id" FROM "platforms" WHERE "platform_key" = 'futuredrive-local-pc'
|
||||
)
|
||||
INSERT INTO "course_platforms" ("course_id", "platform_id", "status", "action", "state", "sort_order", "updated_at")
|
||||
SELECT guide."id", local_platform."id", '사용 가능', '연결', 'ready', 1, CURRENT_TIMESTAMP
|
||||
FROM guide
|
||||
CROSS JOIN local_platform
|
||||
ON CONFLICT ("course_id", "platform_id") DO UPDATE
|
||||
SET "status" = EXCLUDED."status",
|
||||
"action" = EXCLUDED."action",
|
||||
"state" = EXCLUDED."state",
|
||||
"sort_order" = EXCLUDED."sort_order",
|
||||
"updated_at" = CURRENT_TIMESTAMP;
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE "instructor_curriculum_week_overrides"
|
||||
ADD COLUMN IF NOT EXISTS "learning_steps" JSONB;
|
||||
|
||||
COMMENT ON COLUMN "instructor_curriculum_week_overrides"."learning_steps" IS '학습단계별 제목, 설명, 동영상, 핵심개념을 묶은 JSON 목록.';
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -0,0 +1,666 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
authUserId String? @unique @map("auth_user_id") @db.Uuid
|
||||
email String @unique
|
||||
name String?
|
||||
authRole String? @map("auth_role")
|
||||
primaryOrganizationId String? @map("primary_organization_id") @db.Uuid
|
||||
primaryOrganizationName String? @map("primary_organization_name")
|
||||
primaryOrganizationType String? @map("primary_organization_type")
|
||||
department String?
|
||||
position String?
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||
courses Course[] @relation("CourseTeacher")
|
||||
progress CourseStudentProgress[]
|
||||
weekProgress CourseStudentWeekProgress[]
|
||||
groupMemberships StudentGroupMember[]
|
||||
enrollments StudentEnrollment[]
|
||||
studentChatThreads ChatThread[] @relation("ChatThreadStudent")
|
||||
assignedChatThreads ChatThread[] @relation("ChatThreadInstructor")
|
||||
chatMessages ChatMessage[]
|
||||
platformControlSessions PlatformControlSession[]
|
||||
platformControlQueueEntries PlatformControlQueueEntry[]
|
||||
studioRepositories StudioRepository[]
|
||||
submissionScores SubmissionScore[]
|
||||
submissionTotalScores SubmissionTotalScore[]
|
||||
|
||||
@@index([authRole])
|
||||
@@index([primaryOrganizationId])
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model StudentEnrollment {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
courseId String @map("course_id") @db.Uuid
|
||||
organizationId String? @map("organization_id") @db.Uuid
|
||||
organizationName String @map("organization_name")
|
||||
status String @default("ACTIVE")
|
||||
removedAt DateTime? @map("removed_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)
|
||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, courseId])
|
||||
@@index([courseId, status])
|
||||
@@index([organizationId, status])
|
||||
@@index([organizationName, status])
|
||||
@@map("student_enrollments")
|
||||
}
|
||||
|
||||
model StudentGroup {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organizationId String? @map("organization_id") @db.Uuid
|
||||
organizationName String @map("organization_name")
|
||||
name String
|
||||
week Int @default(1)
|
||||
status String @default("진행중")
|
||||
platformName String? @map("platform_name")
|
||||
instructorPlatformId String? @map("instructor_platform_id") @db.Uuid
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
members StudentGroupMember[]
|
||||
instructorPlatform InstructorPlatform? @relation(fields: [instructorPlatformId], references: [id], onDelete: SetNull)
|
||||
platformControlSessions PlatformControlSession[]
|
||||
platformControlQueueEntries PlatformControlQueueEntry[]
|
||||
curriculumSettings InstructorCurriculumSetting[]
|
||||
|
||||
@@unique([organizationName, name])
|
||||
@@index([organizationId])
|
||||
@@index([organizationName])
|
||||
@@index([instructorPlatformId])
|
||||
@@map("student_groups")
|
||||
}
|
||||
|
||||
model InstructorPlatform {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organizationId String? @map("organization_id") @db.Uuid
|
||||
organizationName String @map("organization_name")
|
||||
platformId String @map("platform_id") @db.Uuid
|
||||
displayName String @map("display_name")
|
||||
instanceNo Int @map("instance_no")
|
||||
status String @default("대기")
|
||||
battery Int @default(100)
|
||||
uptimeMinutes Int @default(0) @map("uptime_minutes")
|
||||
speedMeters Decimal @default(0) @map("speed_meters") @db.Decimal(6, 2)
|
||||
errorCount Int @default(0) @map("error_count")
|
||||
emergencyStop Boolean @default(false) @map("emergency_stop")
|
||||
vehicleType String @default("MK-mini") @map("vehicle_type")
|
||||
wheelbaseMeters Decimal @default(0.325) @map("wheelbase_meters") @db.Decimal(6, 3)
|
||||
maxSpeedMetersPerSecond Decimal @default(7.0) @map("max_speed_meters_per_second") @db.Decimal(6, 2)
|
||||
maxSteeringAngleDegrees Decimal @default(30) @map("max_steering_angle_degrees") @db.Decimal(6, 2)
|
||||
sensors Json @default("[]")
|
||||
drivers Json @default("[]")
|
||||
algorithms Json @default("{}")
|
||||
studioSettingsOverride Json? @map("studio_settings_override")
|
||||
controlDurationMinutes Int @default(30) @map("control_duration_minutes")
|
||||
controlWarningMinutes Int @default(5) @map("control_warning_minutes")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
platform Platform @relation(fields: [platformId], references: [id], onDelete: Restrict)
|
||||
assignedGroups StudentGroup[]
|
||||
controlSessions PlatformControlSession[]
|
||||
controlQueueEntries PlatformControlQueueEntry[]
|
||||
|
||||
@@unique([organizationName, displayName])
|
||||
@@unique([organizationName, platformId, instanceNo])
|
||||
@@index([organizationId])
|
||||
@@index([organizationName])
|
||||
@@index([platformId])
|
||||
@@map("instructor_platforms")
|
||||
}
|
||||
|
||||
model StudentGroupMember {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
groupId String @map("group_id") @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
group StudentGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([groupId, userId])
|
||||
@@index([userId])
|
||||
@@map("student_group_members")
|
||||
}
|
||||
|
||||
model Course {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
slug String @unique
|
||||
title String
|
||||
subtitle String?
|
||||
description String?
|
||||
organizationName String @map("organization_name")
|
||||
teacherId String @map("teacher_id") @db.Uuid
|
||||
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)
|
||||
teacher User @relation("CourseTeacher", fields: [teacherId], references: [id], onDelete: Restrict)
|
||||
weeks CourseWeek[]
|
||||
platforms CoursePlatform[]
|
||||
studentProgress CourseStudentProgress[]
|
||||
studentWeekProgress CourseStudentWeekProgress[]
|
||||
chatThreads ChatThread[]
|
||||
enrollments StudentEnrollment[]
|
||||
instructorCurriculumSettings InstructorCurriculumSetting[]
|
||||
instructorCurriculumWeekOverrides InstructorCurriculumWeekOverride[]
|
||||
environmentProfiles CourseEnvironmentProfile[]
|
||||
visualizationSelections CourseWeekVisualizationPlatform[]
|
||||
submissionScores SubmissionScore[]
|
||||
submissionTotalScores SubmissionTotalScore[]
|
||||
|
||||
@@index([isActive])
|
||||
@@index([teacherId])
|
||||
@@map("courses")
|
||||
}
|
||||
|
||||
model CourseEnvironmentProfile {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
courseId String @map("course_id") @db.Uuid
|
||||
week Int
|
||||
platformKey String @default("futuredrive-local-pc") @map("platform_key")
|
||||
profileType String @map("profile_type")
|
||||
name String
|
||||
config Json
|
||||
enabled Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([courseId, week, platformKey])
|
||||
@@index([courseId, week])
|
||||
@@index([profileType])
|
||||
@@map("course_environment_profiles")
|
||||
}
|
||||
|
||||
model VisualizationPlatform {
|
||||
key String @id @map("platform_key")
|
||||
type String @unique
|
||||
displayName String @map("display_name")
|
||||
description String?
|
||||
defaultConfig Json @map("default_config")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
enabled Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
courseWeekSelections CourseWeekVisualizationPlatform[]
|
||||
|
||||
@@index([enabled, sortOrder])
|
||||
@@map("visualization_platforms")
|
||||
}
|
||||
|
||||
model CourseWeekVisualizationPlatform {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
courseId String @map("course_id") @db.Uuid
|
||||
week Int
|
||||
platformKey String @map("platform_key")
|
||||
enabled Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||
platform VisualizationPlatform @relation(fields: [platformKey], references: [key], onDelete: Cascade)
|
||||
|
||||
@@unique([courseId, week, platformKey])
|
||||
@@index([courseId, week])
|
||||
@@index([platformKey])
|
||||
@@map("course_week_visualization_platforms")
|
||||
}
|
||||
|
||||
model InstructorCurriculumSetting {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organizationId String? @map("organization_id") @db.Uuid
|
||||
organizationName String @map("organization_name")
|
||||
groupId String? @map("group_id") @db.Uuid
|
||||
courseId String @map("course_id") @db.Uuid
|
||||
activeWeek Int @default(1) @map("active_week")
|
||||
activeLearningStep String? @map("active_learning_step")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
course Course @relation(fields: [courseId], references: [id], onDelete: Restrict)
|
||||
group StudentGroup? @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([organizationId])
|
||||
@@index([organizationName])
|
||||
@@index([groupId])
|
||||
@@index([courseId])
|
||||
@@map("instructor_curriculum_settings")
|
||||
}
|
||||
|
||||
model InstructorCurriculumWeekOverride {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organizationId String? @map("organization_id") @db.Uuid
|
||||
organizationName String @map("organization_name")
|
||||
courseId String @map("course_id") @db.Uuid
|
||||
week Int
|
||||
tagLabel String? @map("tag_label")
|
||||
title String?
|
||||
duration String?
|
||||
overview String?
|
||||
deliverable String?
|
||||
requiresSourceSubmission Boolean? @default(true) @map("requires_source_submission")
|
||||
learningSteps Json? @map("learning_steps")
|
||||
topics Json?
|
||||
topicDetails Json? @map("topic_details")
|
||||
topicVideoUrls Json? @map("topic_video_urls")
|
||||
practiceContents Json? @map("practice_contents")
|
||||
disabledModuleIds Json? @map("disabled_module_ids")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([organizationName, courseId, week])
|
||||
@@index([organizationId])
|
||||
@@index([organizationName])
|
||||
@@index([courseId])
|
||||
@@map("instructor_curriculum_week_overrides")
|
||||
}
|
||||
|
||||
model Platform {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
platformKey String @unique @map("platform_key")
|
||||
displayName String @map("display_name")
|
||||
description String
|
||||
organizationName String @map("organization_name")
|
||||
iconKey String @map("icon_key")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
curriculumTagOnly Boolean @default(false) @map("curriculum_tag_only")
|
||||
vehicleType String @default("MK-mini") @map("vehicle_type")
|
||||
wheelbaseMeters Decimal @default(0.325) @map("wheelbase_meters") @db.Decimal(6, 3)
|
||||
maxSpeedMetersPerSecond Decimal @default(7.0) @map("max_speed_meters_per_second") @db.Decimal(6, 2)
|
||||
maxSteeringAngleDegrees Decimal @default(30) @map("max_steering_angle_degrees") @db.Decimal(6, 2)
|
||||
sensors Json @default("[]")
|
||||
drivers Json @default("[]")
|
||||
algorithms Json @default("{}")
|
||||
defaultStudioSettings Json @default("{\"workspaceRoot\":\"~/autonomy-studio/workspaces\",\"giteaBaseUrl\":\"http://localhost:3000\",\"giteaOwner\":\"student\"}") @map("default_studio_settings")
|
||||
controlDurationMinutes Int @default(30) @map("control_duration_minutes")
|
||||
controlWarningMinutes Int @default(5) @map("control_warning_minutes")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
courses CoursePlatform[]
|
||||
instructorPlatforms InstructorPlatform[]
|
||||
controlSessions PlatformControlSession[]
|
||||
controlQueueEntries PlatformControlQueueEntry[]
|
||||
|
||||
@@index([organizationName])
|
||||
@@index([isActive])
|
||||
@@map("platforms")
|
||||
}
|
||||
|
||||
model CoursePlatform {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
courseId String @map("course_id") @db.Uuid
|
||||
platformId String @map("platform_id") @db.Uuid
|
||||
status String
|
||||
action String
|
||||
state String
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||
platform Platform @relation(fields: [platformId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@unique([courseId, platformId])
|
||||
@@index([courseId, sortOrder])
|
||||
@@index([platformId])
|
||||
@@map("course_platforms")
|
||||
}
|
||||
|
||||
model PlatformControlSession {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organizationId String? @map("organization_id") @db.Uuid
|
||||
organizationName String @map("organization_name")
|
||||
platformId String? @map("platform_id") @db.Uuid
|
||||
instructorPlatformId String? @map("instructor_platform_id") @db.Uuid
|
||||
holderUserId String @map("holder_user_id") @db.Uuid
|
||||
groupId String? @map("group_id") @db.Uuid
|
||||
status String @default("ACTIVE")
|
||||
startedAt DateTime @default(now()) @map("started_at") @db.Timestamptz(3)
|
||||
endsAt DateTime @map("ends_at") @db.Timestamptz(3)
|
||||
warningAt DateTime @map("warning_at") @db.Timestamptz(3)
|
||||
warningSentAt DateTime? @map("warning_sent_at") @db.Timestamptz(3)
|
||||
releasedAt DateTime? @map("released_at") @db.Timestamptz(3)
|
||||
endReason String? @map("end_reason")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
platform Platform? @relation(fields: [platformId], references: [id], onDelete: Restrict)
|
||||
instructorPlatform InstructorPlatform? @relation(fields: [instructorPlatformId], references: [id], onDelete: Restrict)
|
||||
holderUser User @relation(fields: [holderUserId], references: [id], onDelete: Cascade)
|
||||
group StudentGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([organizationId])
|
||||
@@index([organizationName])
|
||||
@@index([platformId, status])
|
||||
@@index([instructorPlatformId, status])
|
||||
@@index([holderUserId, status])
|
||||
@@index([endsAt, status])
|
||||
@@map("platform_control_sessions")
|
||||
}
|
||||
|
||||
model PlatformControlQueueEntry {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organizationId String? @map("organization_id") @db.Uuid
|
||||
organizationName String @map("organization_name")
|
||||
platformId String? @map("platform_id") @db.Uuid
|
||||
instructorPlatformId String? @map("instructor_platform_id") @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
groupId String? @map("group_id") @db.Uuid
|
||||
status String @default("WAITING")
|
||||
requestedAt DateTime @default(now()) @map("requested_at") @db.Timestamptz(3)
|
||||
grantedAt DateTime? @map("granted_at") @db.Timestamptz(3)
|
||||
canceledAt DateTime? @map("canceled_at") @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
platform Platform? @relation(fields: [platformId], references: [id], onDelete: Restrict)
|
||||
instructorPlatform InstructorPlatform? @relation(fields: [instructorPlatformId], references: [id], onDelete: Restrict)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
group StudentGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([organizationId])
|
||||
@@index([organizationName])
|
||||
@@index([platformId, status, requestedAt])
|
||||
@@index([instructorPlatformId, status, requestedAt])
|
||||
@@index([userId, status])
|
||||
@@map("platform_control_queue_entries")
|
||||
}
|
||||
|
||||
model CourseStudentProgress {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
courseId String @map("course_id") @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
completedWeeks Int @default(0) @map("completed_weeks")
|
||||
totalWeeks Int @default(0) @map("total_weeks")
|
||||
learningHours Int @default(0) @map("learning_hours")
|
||||
commitCount Int @default(0) @map("commit_count")
|
||||
averageScore Int @default(0) @map("average_score")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([courseId, userId])
|
||||
@@index([userId])
|
||||
@@map("course_student_progress")
|
||||
}
|
||||
|
||||
model CourseStudentWeekProgress {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
courseId String @map("course_id") @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
week Int
|
||||
submittedAt DateTime? @map("submitted_at") @db.Timestamptz(3)
|
||||
commitCount Int @default(0) @map("commit_count")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([courseId, userId, week])
|
||||
@@index([userId])
|
||||
@@map("course_student_week_progress")
|
||||
}
|
||||
|
||||
model CourseWeek {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
courseId String @map("course_id") @db.Uuid
|
||||
week Int
|
||||
title String
|
||||
tags String[]
|
||||
progress Int @default(0)
|
||||
state String
|
||||
duration String
|
||||
github String
|
||||
overview String
|
||||
deliverable String
|
||||
requiresSourceSubmission Boolean @default(true) @map("requires_source_submission")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||
topics CourseWeekTopic[]
|
||||
practices CourseWeekPractice[]
|
||||
references CourseWeekReference[]
|
||||
modules CourseWeekModule[]
|
||||
chatThreads ChatThread[]
|
||||
submissionScores SubmissionScore[]
|
||||
submissionTotalScores SubmissionTotalScore[]
|
||||
|
||||
@@unique([courseId, week])
|
||||
@@index([courseId])
|
||||
@@map("course_weeks")
|
||||
}
|
||||
|
||||
model CourseWeekModule {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
weekId String @map("week_id") @db.Uuid
|
||||
sortOrder Int @map("sort_order")
|
||||
category String
|
||||
name String
|
||||
folderName String @map("folder_name")
|
||||
templateRepositoryUrl String? @map("template_repository_url")
|
||||
sourceType String @default("GIT") @map("source_type")
|
||||
prebuiltArtifacts Json @default("[]") @map("prebuilt_artifacts")
|
||||
dockerImage String? @map("docker_image")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
week CourseWeek @relation(fields: [weekId], references: [id], onDelete: Cascade)
|
||||
studioRepositories StudioRepository[]
|
||||
submissionScores SubmissionScore[]
|
||||
|
||||
@@unique([weekId, folderName])
|
||||
@@unique([weekId, sortOrder])
|
||||
@@index([weekId])
|
||||
@@map("course_week_modules")
|
||||
}
|
||||
|
||||
model StudioRepository {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
weekId String @map("week_id") @db.Uuid
|
||||
moduleId String @map("module_id") @db.Uuid
|
||||
repoOwner String @map("repo_owner")
|
||||
repoName String @map("repo_name")
|
||||
repositoryUrl String @map("repository_url")
|
||||
templateRepositoryUrl String @map("template_repository_url")
|
||||
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)
|
||||
module CourseWeekModule @relation(fields: [moduleId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, moduleId])
|
||||
@@index([weekId])
|
||||
@@index([repoOwner, repoName])
|
||||
@@map("studio_repositories")
|
||||
}
|
||||
|
||||
model SubmissionScore {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
courseId String @map("course_id") @db.Uuid
|
||||
weekId String @map("week_id") @db.Uuid
|
||||
moduleId String @map("module_id") @db.Uuid
|
||||
studentId String @map("student_id") @db.Uuid
|
||||
score Int @default(0)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||
week CourseWeek @relation(fields: [weekId], references: [id], onDelete: Cascade)
|
||||
module CourseWeekModule @relation(fields: [moduleId], references: [id], onDelete: Cascade)
|
||||
student User @relation(fields: [studentId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([studentId, moduleId])
|
||||
@@index([courseId])
|
||||
@@index([weekId])
|
||||
@@index([moduleId])
|
||||
@@map("submission_scores")
|
||||
}
|
||||
|
||||
model SubmissionTotalScore {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
courseId String @map("course_id") @db.Uuid
|
||||
weekId String @map("week_id") @db.Uuid
|
||||
studentId String @map("student_id") @db.Uuid
|
||||
score Int @default(0)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||
week CourseWeek @relation(fields: [weekId], references: [id], onDelete: Cascade)
|
||||
student User @relation(fields: [studentId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([studentId, weekId])
|
||||
@@index([courseId])
|
||||
@@index([weekId])
|
||||
@@map("submission_total_scores")
|
||||
}
|
||||
|
||||
model CourseWeekTopic {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
weekId String @map("week_id") @db.Uuid
|
||||
title String
|
||||
detail String
|
||||
sortOrder Int @map("sort_order")
|
||||
week CourseWeek @relation(fields: [weekId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([weekId, sortOrder])
|
||||
@@index([weekId])
|
||||
@@map("course_week_topics")
|
||||
}
|
||||
|
||||
model CourseWeekPractice {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
weekId String @map("week_id") @db.Uuid
|
||||
title String
|
||||
description String
|
||||
sortOrder Int @map("sort_order")
|
||||
week CourseWeek @relation(fields: [weekId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([weekId, sortOrder])
|
||||
@@index([weekId])
|
||||
@@map("course_week_practices")
|
||||
}
|
||||
|
||||
model CourseWeekReference {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
weekId String @map("week_id") @db.Uuid
|
||||
kind String @default("REFERENCE")
|
||||
title String
|
||||
originalFileName String @map("original_file_name")
|
||||
storedFileName String @map("stored_file_name")
|
||||
contentType String? @map("content_type")
|
||||
fileSizeBytes Int @default(0) @map("file_size_bytes")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
week CourseWeek @relation(fields: [weekId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([weekId, storedFileName])
|
||||
@@index([weekId])
|
||||
@@index([weekId, kind])
|
||||
@@map("course_week_references")
|
||||
}
|
||||
|
||||
enum ChatThreadType {
|
||||
AI
|
||||
INSTRUCTOR
|
||||
MIXED
|
||||
}
|
||||
|
||||
enum ChatThreadStatus {
|
||||
OPEN
|
||||
WAITING_INSTRUCTOR
|
||||
RESOLVED
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
enum ChatSenderType {
|
||||
STUDENT
|
||||
AI
|
||||
INSTRUCTOR
|
||||
SYSTEM
|
||||
}
|
||||
|
||||
enum ChatContentFormat {
|
||||
TEXT
|
||||
MARKDOWN
|
||||
}
|
||||
|
||||
enum ChatMessageStatus {
|
||||
ACTIVE
|
||||
CANCELED
|
||||
}
|
||||
|
||||
enum AiChatRunStatus {
|
||||
SUCCEEDED
|
||||
FAILED
|
||||
}
|
||||
|
||||
model ChatThread {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
courseId String @map("course_id") @db.Uuid
|
||||
weekId String? @map("week_id") @db.Uuid
|
||||
studentId String @map("student_id") @db.Uuid
|
||||
assignedInstructorId String? @map("assigned_instructor_id") @db.Uuid
|
||||
type ChatThreadType
|
||||
status ChatThreadStatus @default(OPEN)
|
||||
title String?
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
lastMessageAt DateTime @default(now()) @map("last_message_at") @db.Timestamptz(3)
|
||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||
week CourseWeek? @relation(fields: [weekId], references: [id], onDelete: SetNull)
|
||||
student User @relation("ChatThreadStudent", fields: [studentId], references: [id], onDelete: Cascade)
|
||||
assignedInstructor User? @relation("ChatThreadInstructor", fields: [assignedInstructorId], references: [id], onDelete: SetNull)
|
||||
messages ChatMessage[]
|
||||
|
||||
@@index([courseId, type, lastMessageAt])
|
||||
@@index([studentId, type, lastMessageAt])
|
||||
@@index([weekId])
|
||||
@@index([assignedInstructorId])
|
||||
@@map("chat_threads")
|
||||
}
|
||||
|
||||
model ChatMessage {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
threadId String @map("thread_id") @db.Uuid
|
||||
senderType ChatSenderType @map("sender_type")
|
||||
senderUserId String? @map("sender_user_id") @db.Uuid
|
||||
content String
|
||||
contentFormat ChatContentFormat @default(TEXT) @map("content_format")
|
||||
status ChatMessageStatus @default(ACTIVE)
|
||||
metadata Json?
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
canceledAt DateTime? @map("canceled_at") @db.Timestamptz(3)
|
||||
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
|
||||
thread ChatThread @relation(fields: [threadId], references: [id], onDelete: Cascade)
|
||||
senderUser User? @relation(fields: [senderUserId], references: [id], onDelete: SetNull)
|
||||
aiRun AiChatRun?
|
||||
|
||||
@@index([threadId, createdAt])
|
||||
@@index([threadId, status, createdAt])
|
||||
@@index([senderUserId])
|
||||
@@map("chat_messages")
|
||||
}
|
||||
|
||||
model AiChatRun {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
messageId String @unique @map("message_id") @db.Uuid
|
||||
model String
|
||||
promptTokens Int? @map("prompt_tokens")
|
||||
completionTokens Int? @map("completion_tokens")
|
||||
totalTokens Int? @map("total_tokens")
|
||||
latencyMs Int? @map("latency_ms")
|
||||
status AiChatRunStatus
|
||||
error String?
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
message ChatMessage @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([status, createdAt])
|
||||
@@map("ai_chat_runs")
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
getRoot() {
|
||||
return this.appService.getRoot();
|
||||
}
|
||||
|
||||
@Get('health')
|
||||
getHealth() {
|
||||
return this.appService.getHealth();
|
||||
}
|
||||
|
||||
@Get('health/db')
|
||||
getDatabaseHealth() {
|
||||
return this.appService.getDatabaseHealth();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { ChatModule } from './chat/chat.module';
|
||||
import { CurriculumModule } from './curriculum/curriculum.module';
|
||||
import { InstructorModule } from './instructor/instructor.module';
|
||||
import { PlatformControlModule } from './platform-control/platform-control.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { StudioModule } from './studio/studio.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
}),
|
||||
AuthModule,
|
||||
ChatModule,
|
||||
CurriculumModule,
|
||||
InstructorModule,
|
||||
PlatformControlModule,
|
||||
PrismaModule,
|
||||
StudioModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { AppService } from './app.service';
|
||||
import { PrismaService } from './prisma/prisma.service';
|
||||
|
||||
describe('AppService', () => {
|
||||
let service: AppService;
|
||||
let prisma: Pick<PrismaService, '$queryRaw'>;
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = {
|
||||
$queryRaw: jest.fn().mockResolvedValue([{ '?column?': 1 }]),
|
||||
} as Pick<PrismaService, '$queryRaw'>;
|
||||
service = new AppService(prisma as PrismaService);
|
||||
});
|
||||
|
||||
it('returns the root service status', () => {
|
||||
expect(service.getRoot()).toEqual({
|
||||
service: 'lms-be',
|
||||
status: 'ok',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a health payload', () => {
|
||||
expect(service.getHealth()).toMatchObject({
|
||||
status: 'ok',
|
||||
});
|
||||
});
|
||||
|
||||
it('checks database health', async () => {
|
||||
await expect(service.getDatabaseHealth()).resolves.toMatchObject({
|
||||
status: 'ok',
|
||||
provider: 'postgresql',
|
||||
});
|
||||
expect(prisma.$queryRaw).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { PrismaService } from './prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
getRoot() {
|
||||
return {
|
||||
service: 'lms-be',
|
||||
status: 'ok',
|
||||
};
|
||||
}
|
||||
|
||||
getHealth() {
|
||||
return {
|
||||
status: 'ok',
|
||||
uptime: process.uptime(),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async getDatabaseHealth() {
|
||||
await this.prisma.$queryRaw`SELECT 1`;
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
provider: 'postgresql',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Body, Controller, Get, Post, Req } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
import { HandoffExchangeDto } from './dto/handoff-exchange.dto';
|
||||
|
||||
type RefreshBody = {
|
||||
refreshToken?: string;
|
||||
};
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('handoff/exchange')
|
||||
exchangeHandoff(@Body() handoffExchangeDto: HandoffExchangeDto) {
|
||||
return this.authService.exchangeHandoff(
|
||||
handoffExchangeDto.handoffToken,
|
||||
handoffExchangeDto.audienceApp,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
refresh(@Body() body: RefreshBody) {
|
||||
return this.authService.refresh(body.refreshToken ?? '');
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
me(@Req() request: Request) {
|
||||
return this.authService.me(this.getBearerToken(request));
|
||||
}
|
||||
|
||||
private getBearerToken(request: Request): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
BadGatewayException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export type AuthResponseUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: string;
|
||||
roles?: string[];
|
||||
primaryAffiliation?: {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
organizationName: string;
|
||||
organizationType: string;
|
||||
department: string | null;
|
||||
position: string | null;
|
||||
} | null;
|
||||
currentLicense?: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
startsAt: string;
|
||||
expiresAt: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type CreateManagedAuthUserInput = {
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
organizationId: string;
|
||||
department?: string | null;
|
||||
position?: string | null;
|
||||
licenseCode?: 'FREE' | 'EDU' | 'PRO';
|
||||
};
|
||||
|
||||
type AuthTokenResponse = {
|
||||
accessToken: string;
|
||||
accessTokenExpiresIn?: number;
|
||||
refreshToken?: string;
|
||||
refreshTokenExpiresIn?: number;
|
||||
user: AuthResponseUser;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly authServerUrl: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.authServerUrl = this.configService
|
||||
.get<string>('AUTH_SERVER_URL', 'http://localhost:4100')
|
||||
.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
async me(accessToken: string | undefined) {
|
||||
const authResponse = await this.request<{ user: AuthResponseUser }>(
|
||||
'/auth/me',
|
||||
{
|
||||
method: 'GET',
|
||||
accessToken,
|
||||
unauthorizedMessage: 'Invalid bearer token',
|
||||
},
|
||||
);
|
||||
|
||||
return authResponse;
|
||||
}
|
||||
|
||||
async exchangeHandoff(handoffToken: string, audienceApp?: string) {
|
||||
const authResponse = await this.request<AuthTokenResponse>(
|
||||
'/auth/handoff/exchange',
|
||||
{
|
||||
method: 'POST',
|
||||
body: { handoffToken, audienceApp },
|
||||
unauthorizedMessage: 'Invalid handoff token',
|
||||
},
|
||||
);
|
||||
|
||||
return this.frontendTokenResponse(authResponse);
|
||||
}
|
||||
|
||||
async refresh(refreshToken: string) {
|
||||
const authResponse = await this.request<AuthTokenResponse>('/auth/refresh', {
|
||||
method: 'POST',
|
||||
body: { refreshToken },
|
||||
unauthorizedMessage: 'Invalid refresh token',
|
||||
});
|
||||
|
||||
return this.frontendTokenResponse(authResponse);
|
||||
}
|
||||
|
||||
async createManagedUser(
|
||||
accessToken: string,
|
||||
input: CreateManagedAuthUserInput,
|
||||
) {
|
||||
return this.request<{ user: AuthResponseUser }>('/auth/admin/users', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
accessToken,
|
||||
unauthorizedMessage: 'Invalid bearer token',
|
||||
});
|
||||
}
|
||||
|
||||
private async frontendTokenResponse(authResponse: AuthTokenResponse) {
|
||||
return {
|
||||
accessToken: authResponse.accessToken,
|
||||
accessTokenExpiresIn: authResponse.accessTokenExpiresIn,
|
||||
refreshToken: authResponse.refreshToken,
|
||||
refreshTokenExpiresIn: authResponse.refreshTokenExpiresIn,
|
||||
tokenType: 'Bearer',
|
||||
user: authResponse.user,
|
||||
};
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
path: string,
|
||||
options: {
|
||||
method: 'GET' | 'POST';
|
||||
body?: unknown;
|
||||
accessToken?: string;
|
||||
unauthorizedMessage?: string;
|
||||
},
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (options.body) headers['content-type'] = 'application/json';
|
||||
if (options.accessToken) {
|
||||
headers.authorization = `Bearer ${options.accessToken}`;
|
||||
}
|
||||
|
||||
let authResponse: globalThis.Response;
|
||||
try {
|
||||
authResponse = await fetch(`${this.authServerUrl}${path}`, {
|
||||
method: options.method,
|
||||
headers,
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
} catch {
|
||||
throw new BadGatewayException('Auth server is unavailable');
|
||||
}
|
||||
|
||||
const payload: unknown = await authResponse.json().catch((): null => null);
|
||||
|
||||
if (authResponse.status === 401) {
|
||||
throw new UnauthorizedException(
|
||||
options.unauthorizedMessage ?? 'Unauthorized',
|
||||
);
|
||||
}
|
||||
|
||||
if (!authResponse.ok) {
|
||||
throw new BadGatewayException({
|
||||
message: 'Auth server request failed',
|
||||
statusCode: authResponse.status,
|
||||
error: payload,
|
||||
});
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class HandoffExchangeDto {
|
||||
@IsString()
|
||||
handoffToken: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
audienceApp?: string;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Query, Req } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
|
||||
import { ChatService } from './chat.service';
|
||||
import { CreateAiResponseDto } from './dto/create-ai-response.dto';
|
||||
import { CreateChatMessageDto } from './dto/create-chat-message.dto';
|
||||
import { CreateInstructorChatMessageDto } from './dto/create-instructor-chat-message.dto';
|
||||
import { ListChatThreadsDto } from './dto/list-chat-threads.dto';
|
||||
|
||||
@Controller('chat')
|
||||
export class ChatController {
|
||||
constructor(private readonly chatService: ChatService) {}
|
||||
|
||||
@Get('threads')
|
||||
listThreads(@Req() request: Request, @Query() query: ListChatThreadsDto) {
|
||||
return this.chatService.listThreads(this.getBearerToken(request), query);
|
||||
}
|
||||
|
||||
@Delete('threads')
|
||||
clearThreads(@Req() request: Request, @Query() query: ListChatThreadsDto) {
|
||||
return this.chatService.clearStudentThreads(this.getBearerToken(request), query);
|
||||
}
|
||||
|
||||
@Post('messages')
|
||||
createMessage(
|
||||
@Req() request: Request,
|
||||
@Body() createChatMessageDto: CreateChatMessageDto,
|
||||
) {
|
||||
return this.chatService.createStudentMessage(
|
||||
this.getBearerToken(request),
|
||||
createChatMessageDto,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('ai/respond')
|
||||
createAiResponse(
|
||||
@Req() request: Request,
|
||||
@Body() createAiResponseDto: CreateAiResponseDto,
|
||||
) {
|
||||
return this.chatService.createAiResponse(
|
||||
this.getBearerToken(request),
|
||||
createAiResponseDto,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('ai/messages/:messageId/cancel')
|
||||
cancelAiMessage(
|
||||
@Req() request: Request,
|
||||
@Param('messageId') messageId: string,
|
||||
@Body() body: { content?: string },
|
||||
) {
|
||||
return this.chatService.cancelAiMessage(
|
||||
this.getBearerToken(request),
|
||||
messageId,
|
||||
body.content,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('ai/cancel-active')
|
||||
cancelActiveAiResponse(
|
||||
@Req() request: Request,
|
||||
@Body() body: { week?: number },
|
||||
) {
|
||||
return this.chatService.cancelActiveAiResponse(
|
||||
this.getBearerToken(request),
|
||||
body.week,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('instructor/threads')
|
||||
listInstructorThreads(
|
||||
@Req() request: Request,
|
||||
@Query() query: ListChatThreadsDto,
|
||||
) {
|
||||
return this.chatService.listInstructorThreads(
|
||||
this.getBearerToken(request),
|
||||
query,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('instructor/threads/:threadId')
|
||||
getInstructorThread(
|
||||
@Req() request: Request,
|
||||
@Param('threadId') threadId: string,
|
||||
) {
|
||||
return this.chatService.getInstructorThread(
|
||||
this.getBearerToken(request),
|
||||
threadId,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('instructor/threads/:threadId/messages')
|
||||
createInstructorMessage(
|
||||
@Req() request: Request,
|
||||
@Param('threadId') threadId: string,
|
||||
@Body() createInstructorChatMessageDto: CreateInstructorChatMessageDto,
|
||||
) {
|
||||
return this.chatService.createInstructorMessage(
|
||||
this.getBearerToken(request),
|
||||
threadId,
|
||||
createInstructorChatMessageDto,
|
||||
);
|
||||
}
|
||||
|
||||
private getBearerToken(request: Request): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
ConnectedSocket,
|
||||
MessageBody,
|
||||
OnGatewayConnection,
|
||||
SubscribeMessage,
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
} from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
|
||||
import { AuthService, type AuthResponseUser } from '../auth/auth.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
type ChatSocketUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
roles?: string[];
|
||||
};
|
||||
|
||||
export type ChatNotificationPayload = {
|
||||
type: 'chat.message.created';
|
||||
threadId: string;
|
||||
messageId: string;
|
||||
senderType: 'STUDENT' | 'AI' | 'INSTRUCTOR' | 'SYSTEM';
|
||||
preview: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type UserNotificationPayload =
|
||||
| ChatNotificationPayload
|
||||
| {
|
||||
type: 'platform.control.warning';
|
||||
sessionId: string;
|
||||
platformId: string | null;
|
||||
instructorPlatformId: string | null;
|
||||
endsAt: string;
|
||||
warningAt: string;
|
||||
}
|
||||
| {
|
||||
type: 'platform.control.granted';
|
||||
sessionId: string;
|
||||
startedAt: string;
|
||||
endsAt: string;
|
||||
warningAt: string;
|
||||
}
|
||||
| {
|
||||
type: 'platform.control.terminated';
|
||||
sessionId: string;
|
||||
platformId: string | null;
|
||||
instructorPlatformId: string | null;
|
||||
reason: string;
|
||||
message: string;
|
||||
endedAt: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@WebSocketGateway({
|
||||
cors: {
|
||||
origin: true,
|
||||
credentials: true,
|
||||
},
|
||||
})
|
||||
export class ChatGateway implements OnGatewayConnection {
|
||||
@WebSocketServer()
|
||||
private server!: Server;
|
||||
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async handleConnection(client: Socket) {
|
||||
const accessToken = this.getAccessToken(client);
|
||||
if (!accessToken) {
|
||||
client.disconnect(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { user } = await this.authService.me(accessToken);
|
||||
const lmsUser = await this.prisma.user.upsert({
|
||||
where: { email: user.email },
|
||||
update: this.toUserSyncData(user),
|
||||
create: {
|
||||
email: user.email,
|
||||
...this.toUserSyncData(user),
|
||||
},
|
||||
});
|
||||
const socketUser: ChatSocketUser = {
|
||||
id: lmsUser.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
roles: user.roles,
|
||||
};
|
||||
|
||||
client.data.user = socketUser;
|
||||
await client.join(this.userRoom(lmsUser.id));
|
||||
const organizationName = user.primaryAffiliation?.organizationName?.trim();
|
||||
if (organizationName) {
|
||||
await client.join(this.organizationRoom(organizationName));
|
||||
}
|
||||
|
||||
if (this.isInstructor(socketUser)) {
|
||||
const activeCourses = await this.prisma.course.findMany({
|
||||
where: { isActive: true },
|
||||
select: { id: true },
|
||||
});
|
||||
await Promise.all(
|
||||
activeCourses.map((course) =>
|
||||
client.join(this.courseInstructorsRoom(course.id)),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
client.disconnect(true);
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage('chat.thread.join')
|
||||
async joinThread(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() payload: { threadId?: string },
|
||||
) {
|
||||
const threadId = payload.threadId;
|
||||
if (!threadId || !client.data.user) return { ok: false };
|
||||
|
||||
await client.join(this.threadRoom(threadId));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@SubscribeMessage('chat.thread.leave')
|
||||
async leaveThread(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() payload: { threadId?: string },
|
||||
) {
|
||||
const threadId = payload.threadId;
|
||||
if (!threadId) return { ok: false };
|
||||
|
||||
await client.leave(this.threadRoom(threadId));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
notifyUser(userId: string, payload: UserNotificationPayload) {
|
||||
this.server.to(this.userRoom(userId)).emit(payload.type, payload);
|
||||
}
|
||||
|
||||
notifyCourseInstructors(courseId: string, payload: ChatNotificationPayload) {
|
||||
this.server
|
||||
.to(this.courseInstructorsRoom(courseId))
|
||||
.emit(payload.type, payload);
|
||||
}
|
||||
|
||||
notifyThread(threadId: string, payload: ChatNotificationPayload) {
|
||||
this.server.to(this.threadRoom(threadId)).emit(payload.type, payload);
|
||||
}
|
||||
|
||||
notifyOrganization(organizationName: string, eventName: string, payload: unknown) {
|
||||
this.server.to(this.organizationRoom(organizationName)).emit(eventName, payload);
|
||||
}
|
||||
|
||||
async notifyGroupMembers(groupId: string, eventName: string, payload: unknown) {
|
||||
const members = await this.prisma.studentGroupMember.findMany({
|
||||
where: { groupId },
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
members.forEach((member) => {
|
||||
this.server.to(this.userRoom(member.userId)).emit(eventName, payload);
|
||||
});
|
||||
}
|
||||
|
||||
private getAccessToken(client: Socket) {
|
||||
const authToken = client.handshake.auth?.token;
|
||||
if (typeof authToken === 'string' && authToken) return authToken;
|
||||
|
||||
const authorization = client.handshake.headers.authorization;
|
||||
if (!authorization) return null;
|
||||
|
||||
const [type, token] = authorization.split(' ');
|
||||
return type === 'Bearer' ? token : null;
|
||||
}
|
||||
|
||||
private isInstructor(user: ChatSocketUser) {
|
||||
const roles = new Set(
|
||||
[user.role, ...(user.roles ?? [])].map((role) => role.toUpperCase()),
|
||||
);
|
||||
return ['INSTRUCTOR', 'LMS_ADMIN'].some((role) => roles.has(role));
|
||||
}
|
||||
|
||||
private toUserSyncData(user: AuthResponseUser) {
|
||||
return {
|
||||
authUserId: user.id,
|
||||
name: user.name,
|
||||
authRole: user.role,
|
||||
primaryOrganizationId: user.primaryAffiliation?.organizationId ?? null,
|
||||
primaryOrganizationName: user.primaryAffiliation?.organizationName ?? null,
|
||||
primaryOrganizationType: user.primaryAffiliation?.organizationType ?? null,
|
||||
department: user.primaryAffiliation?.department ?? null,
|
||||
position: user.primaryAffiliation?.position ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private userRoom(userId: string) {
|
||||
return `user:${userId}`;
|
||||
}
|
||||
|
||||
private courseInstructorsRoom(courseId: string) {
|
||||
return `course:${courseId}:instructors`;
|
||||
}
|
||||
|
||||
private threadRoom(threadId: string) {
|
||||
return `thread:${threadId}`;
|
||||
}
|
||||
|
||||
private organizationRoom(organizationName: string) {
|
||||
return `organization:${organizationName}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { ChatController } from './chat.controller';
|
||||
import { ChatGateway } from './chat.gateway';
|
||||
import { ChatService } from './chat.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, PrismaModule],
|
||||
controllers: [ChatController],
|
||||
providers: [ChatGateway, ChatService],
|
||||
exports: [ChatGateway],
|
||||
})
|
||||
export class ChatModule {}
|
||||
@@ -0,0 +1,989 @@
|
||||
import {
|
||||
BadGatewayException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
AiChatRunStatus,
|
||||
ChatMessageStatus,
|
||||
ChatSenderType,
|
||||
ChatThreadStatus,
|
||||
ChatThreadType,
|
||||
Prisma,
|
||||
} from '@prisma/client';
|
||||
|
||||
import { AuthService, type AuthResponseUser } from '../auth/auth.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { ChatGateway, type ChatNotificationPayload } from './chat.gateway';
|
||||
import { CreateAiResponseDto } from './dto/create-ai-response.dto';
|
||||
import { CreateChatMessageDto } from './dto/create-chat-message.dto';
|
||||
import { CreateInstructorChatMessageDto } from './dto/create-instructor-chat-message.dto';
|
||||
import { ListChatThreadsDto } from './dto/list-chat-threads.dto';
|
||||
|
||||
type OpenAiChatCompletionResponse = {
|
||||
choices?: Array<{
|
||||
message?: {
|
||||
content?: string;
|
||||
};
|
||||
}>;
|
||||
usage?: {
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
total_tokens?: number;
|
||||
};
|
||||
error?: {
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type FutureDriveLlmChatResponse = {
|
||||
answer?: string;
|
||||
sources?: Array<{
|
||||
title?: string;
|
||||
snippet?: string;
|
||||
score?: number;
|
||||
}>;
|
||||
topic?: string;
|
||||
follow_ups?: string[];
|
||||
request_id?: string;
|
||||
error?: {
|
||||
message?: string;
|
||||
};
|
||||
message?: string;
|
||||
};
|
||||
|
||||
const STUDENT_CHAT_VISIBLE_MESSAGE_LIMIT = 20;
|
||||
const AI_CHAT_CONTEXT_MESSAGE_LIMIT = 6;
|
||||
|
||||
@Injectable()
|
||||
export class ChatService {
|
||||
private readonly authServerUrl: string;
|
||||
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly chatGateway: ChatGateway,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {
|
||||
this.authServerUrl = this.configService
|
||||
.get<string>('AUTH_SERVER_URL', 'http://localhost:4100')
|
||||
.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
async listThreads(accessToken: string | undefined, query: ListChatThreadsDto) {
|
||||
const student = await this.getLmsUser(accessToken);
|
||||
const course = await this.getActiveCourse();
|
||||
const week = query.week
|
||||
? await this.getCourseWeek(course.id, query.week)
|
||||
: null;
|
||||
|
||||
const threads = await this.prisma.chatThread.findMany({
|
||||
where: {
|
||||
courseId: course.id,
|
||||
studentId: student.id,
|
||||
weekId: week?.id,
|
||||
type: query.mode ? this.toThreadType(query.mode) : undefined,
|
||||
status: { not: ChatThreadStatus.ARCHIVED },
|
||||
},
|
||||
orderBy: { lastMessageAt: 'desc' },
|
||||
include: {
|
||||
messages: {
|
||||
where: { deletedAt: null },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: STUDENT_CHAT_VISIBLE_MESSAGE_LIMIT,
|
||||
include: {
|
||||
aiRun: true,
|
||||
senderUser: {
|
||||
select: {
|
||||
authUserId: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
week: {
|
||||
select: { week: true, title: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
threads: threads.map((thread) => this.serializeThread(thread)),
|
||||
};
|
||||
}
|
||||
|
||||
async clearStudentThreads(accessToken: string | undefined, query: ListChatThreadsDto) {
|
||||
const student = await this.getLmsUser(accessToken);
|
||||
const course = await this.getActiveCourse();
|
||||
const week = query.week
|
||||
? await this.getCourseWeek(course.id, query.week)
|
||||
: null;
|
||||
|
||||
const result = await this.prisma.chatThread.updateMany({
|
||||
where: {
|
||||
courseId: course.id,
|
||||
studentId: student.id,
|
||||
weekId: week?.id,
|
||||
type: query.mode ? this.toThreadType(query.mode) : undefined,
|
||||
status: { not: ChatThreadStatus.ARCHIVED },
|
||||
},
|
||||
data: {
|
||||
status: ChatThreadStatus.ARCHIVED,
|
||||
},
|
||||
});
|
||||
|
||||
return { deletedCount: result.count };
|
||||
}
|
||||
|
||||
async createStudentMessage(
|
||||
accessToken: string | undefined,
|
||||
dto: CreateChatMessageDto,
|
||||
) {
|
||||
const student = await this.getLmsUser(accessToken);
|
||||
const course = await this.getActiveCourse();
|
||||
const week = dto.week ? await this.getCourseWeek(course.id, dto.week) : null;
|
||||
const thread = await this.getOrCreateThread({
|
||||
courseId: course.id,
|
||||
weekId: week?.id ?? null,
|
||||
studentId: student.id,
|
||||
type: this.toThreadType(dto.mode),
|
||||
firstMessage: dto.content,
|
||||
status:
|
||||
dto.mode === 'teacher'
|
||||
? ChatThreadStatus.WAITING_INSTRUCTOR
|
||||
: ChatThreadStatus.OPEN,
|
||||
});
|
||||
|
||||
const message = await this.prisma.$transaction(async (tx) => {
|
||||
const createdMessage = await tx.chatMessage.create({
|
||||
data: {
|
||||
threadId: thread.id,
|
||||
senderType: ChatSenderType.STUDENT,
|
||||
senderUserId: student.id,
|
||||
content: dto.content,
|
||||
},
|
||||
});
|
||||
await tx.chatThread.update({
|
||||
where: { id: thread.id },
|
||||
data: {
|
||||
status:
|
||||
dto.mode === 'teacher'
|
||||
? ChatThreadStatus.WAITING_INSTRUCTOR
|
||||
: thread.status,
|
||||
lastMessageAt: createdMessage.createdAt,
|
||||
},
|
||||
});
|
||||
return createdMessage;
|
||||
});
|
||||
const payload = this.toNotificationPayload(thread.id, message);
|
||||
this.chatGateway.notifyThread(thread.id, payload);
|
||||
this.chatGateway.notifyUser(student.id, payload);
|
||||
if (dto.mode === 'teacher') {
|
||||
this.chatGateway.notifyCourseInstructors(course.id, payload);
|
||||
}
|
||||
|
||||
return {
|
||||
threadId: thread.id,
|
||||
message: this.serializeMessage(message),
|
||||
};
|
||||
}
|
||||
|
||||
async createAiResponse(
|
||||
accessToken: string | undefined,
|
||||
dto: CreateAiResponseDto,
|
||||
) {
|
||||
const student = await this.getLmsUser(accessToken);
|
||||
const course = await this.getActiveCourse();
|
||||
const week = dto.week ? await this.getCourseWeek(course.id, dto.week) : null;
|
||||
const thread = await this.getOrCreateThread({
|
||||
courseId: course.id,
|
||||
weekId: week?.id ?? null,
|
||||
studentId: student.id,
|
||||
type: ChatThreadType.AI,
|
||||
firstMessage: dto.content,
|
||||
status: ChatThreadStatus.OPEN,
|
||||
});
|
||||
|
||||
const studentMessage = await this.prisma.$transaction(async (tx) => {
|
||||
const createdMessage = await tx.chatMessage.create({
|
||||
data: {
|
||||
threadId: thread.id,
|
||||
senderType: ChatSenderType.STUDENT,
|
||||
senderUserId: student.id,
|
||||
content: dto.content,
|
||||
},
|
||||
});
|
||||
await tx.chatThread.update({
|
||||
where: { id: thread.id },
|
||||
data: { lastMessageAt: createdMessage.createdAt },
|
||||
});
|
||||
return createdMessage;
|
||||
});
|
||||
const studentPayload = this.toNotificationPayload(thread.id, studentMessage);
|
||||
this.chatGateway.notifyThread(thread.id, studentPayload);
|
||||
this.chatGateway.notifyUser(student.id, studentPayload);
|
||||
|
||||
const recentMessages = await this.prisma.chatMessage.findMany({
|
||||
where: {
|
||||
threadId: thread.id,
|
||||
deletedAt: null,
|
||||
senderType: { in: [ChatSenderType.STUDENT, ChatSenderType.AI] },
|
||||
status: ChatMessageStatus.ACTIVE,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: AI_CHAT_CONTEXT_MESSAGE_LIMIT,
|
||||
});
|
||||
|
||||
const startedAt = Date.now();
|
||||
const aiProvider = this.configService.get<string>('AI_PROVIDER', 'openai').trim().toLowerCase();
|
||||
const model = aiProvider === 'futuredrive'
|
||||
? this.configService.get<string>('FUTUREDRIVE_LLM_MODEL', 'futuredrive-llm')
|
||||
: this.configService.get<string>('OPENAI_CHAT_MODEL', 'gpt-4.1-nano');
|
||||
|
||||
try {
|
||||
const aiResponse = await this.requestAiChatCompletion(
|
||||
model,
|
||||
recentMessages.reverse().map((message) => ({
|
||||
role:
|
||||
message.senderType === ChatSenderType.STUDENT
|
||||
? ('user' as const)
|
||||
: ('assistant' as const),
|
||||
content: message.content,
|
||||
})),
|
||||
);
|
||||
const currentStudentMessage = await this.prisma.chatMessage.findUnique({
|
||||
where: { id: studentMessage.id },
|
||||
select: { status: true },
|
||||
});
|
||||
if (currentStudentMessage?.status === ChatMessageStatus.CANCELED) {
|
||||
return {
|
||||
threadId: thread.id,
|
||||
message: this.serializeMessage({
|
||||
...studentMessage,
|
||||
status: ChatMessageStatus.CANCELED,
|
||||
canceledAt: new Date(),
|
||||
aiRun: null,
|
||||
senderUser: null,
|
||||
}),
|
||||
content: '',
|
||||
canceled: true,
|
||||
};
|
||||
}
|
||||
|
||||
const assistantMessage = await this.prisma.$transaction(async (tx) => {
|
||||
const message = await tx.chatMessage.create({
|
||||
data: {
|
||||
threadId: thread.id,
|
||||
senderType: ChatSenderType.AI,
|
||||
content: aiResponse.content,
|
||||
aiRun: {
|
||||
create: {
|
||||
model,
|
||||
promptTokens: aiResponse.usage.promptTokens,
|
||||
completionTokens: aiResponse.usage.completionTokens,
|
||||
totalTokens: aiResponse.usage.totalTokens,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
status: AiChatRunStatus.SUCCEEDED,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { aiRun: true },
|
||||
});
|
||||
await tx.chatThread.update({
|
||||
where: { id: thread.id },
|
||||
data: { lastMessageAt: message.createdAt },
|
||||
});
|
||||
return message;
|
||||
});
|
||||
const payload = this.toNotificationPayload(thread.id, assistantMessage);
|
||||
this.chatGateway.notifyThread(thread.id, payload);
|
||||
this.chatGateway.notifyUser(student.id, payload);
|
||||
|
||||
return {
|
||||
threadId: thread.id,
|
||||
message: this.serializeMessage(assistantMessage),
|
||||
content: assistantMessage.content,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'AI request failed';
|
||||
const failedMessage = await this.prisma.chatMessage.create({
|
||||
data: {
|
||||
threadId: thread.id,
|
||||
senderType: ChatSenderType.SYSTEM,
|
||||
content: message,
|
||||
aiRun: {
|
||||
create: {
|
||||
model,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
status: AiChatRunStatus.FAILED,
|
||||
error: message,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await this.prisma.chatThread.update({
|
||||
where: { id: thread.id },
|
||||
data: { lastMessageAt: failedMessage.createdAt },
|
||||
});
|
||||
const payload = this.toNotificationPayload(thread.id, failedMessage);
|
||||
this.chatGateway.notifyThread(thread.id, payload);
|
||||
this.chatGateway.notifyUser(student.id, payload);
|
||||
throw new BadGatewayException(message);
|
||||
}
|
||||
}
|
||||
|
||||
async cancelAiMessage(
|
||||
accessToken: string | undefined,
|
||||
messageId: string,
|
||||
content?: string,
|
||||
) {
|
||||
const student = await this.getLmsUser(accessToken);
|
||||
const message = await this.prisma.chatMessage.findFirst({
|
||||
where: {
|
||||
id: messageId,
|
||||
senderType: ChatSenderType.AI,
|
||||
thread: {
|
||||
studentId: student.id,
|
||||
type: ChatThreadType.AI,
|
||||
status: { not: ChatThreadStatus.ARCHIVED },
|
||||
},
|
||||
},
|
||||
include: {
|
||||
aiRun: true,
|
||||
senderUser: {
|
||||
select: {
|
||||
authUserId: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!message) {
|
||||
throw new NotFoundException('AI 메시지를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
const nextContent = typeof content === 'string' && content.trim()
|
||||
? content.trim()
|
||||
: message.content;
|
||||
const canceledMessage = await this.prisma.chatMessage.update({
|
||||
where: { id: message.id },
|
||||
data: {
|
||||
content: nextContent,
|
||||
status: ChatMessageStatus.CANCELED,
|
||||
canceledAt: new Date(),
|
||||
},
|
||||
include: {
|
||||
aiRun: true,
|
||||
senderUser: {
|
||||
select: {
|
||||
authUserId: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
message: this.serializeMessage(canceledMessage),
|
||||
};
|
||||
}
|
||||
|
||||
async cancelActiveAiResponse(
|
||||
accessToken: string | undefined,
|
||||
weekNumber?: number,
|
||||
) {
|
||||
const student = await this.getLmsUser(accessToken);
|
||||
const course = await this.getActiveCourse();
|
||||
const week = weekNumber ? await this.getCourseWeek(course.id, weekNumber) : null;
|
||||
const message = await this.prisma.chatMessage.findFirst({
|
||||
where: {
|
||||
senderType: ChatSenderType.STUDENT,
|
||||
status: ChatMessageStatus.ACTIVE,
|
||||
thread: {
|
||||
courseId: course.id,
|
||||
studentId: student.id,
|
||||
weekId: week?.id ?? null,
|
||||
type: ChatThreadType.AI,
|
||||
status: { not: ChatThreadStatus.ARCHIVED },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
aiRun: true,
|
||||
senderUser: {
|
||||
select: {
|
||||
authUserId: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!message) {
|
||||
return { message: null };
|
||||
}
|
||||
|
||||
const canceledMessage = await this.prisma.chatMessage.update({
|
||||
where: { id: message.id },
|
||||
data: {
|
||||
status: ChatMessageStatus.CANCELED,
|
||||
canceledAt: new Date(),
|
||||
},
|
||||
include: {
|
||||
aiRun: true,
|
||||
senderUser: {
|
||||
select: {
|
||||
authUserId: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
message: this.serializeMessage(canceledMessage),
|
||||
};
|
||||
}
|
||||
|
||||
async listInstructorThreads(
|
||||
accessToken: string | undefined,
|
||||
query: ListChatThreadsDto,
|
||||
) {
|
||||
const instructor = await this.getInstructorLmsUser(accessToken);
|
||||
const course = await this.getActiveCourse();
|
||||
const week = query.week
|
||||
? await this.getCourseWeek(course.id, query.week)
|
||||
: null;
|
||||
|
||||
const threads = await this.prisma.chatThread.findMany({
|
||||
where: {
|
||||
courseId: course.id,
|
||||
weekId: week?.id,
|
||||
type: ChatThreadType.INSTRUCTOR,
|
||||
status: { not: ChatThreadStatus.ARCHIVED },
|
||||
...this.instructorThreadVisibilityWhere(instructor),
|
||||
},
|
||||
orderBy: { lastMessageAt: 'desc' },
|
||||
include: {
|
||||
student: {
|
||||
select: {
|
||||
id: true,
|
||||
authUserId: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
assignedInstructor: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
where: { deletedAt: null },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: {
|
||||
aiRun: true,
|
||||
senderUser: {
|
||||
select: {
|
||||
authUserId: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
week: {
|
||||
select: { week: true, title: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
threads: threads.map((thread) => this.serializeInstructorThread(thread)),
|
||||
};
|
||||
}
|
||||
|
||||
async getInstructorThread(accessToken: string | undefined, threadId: string) {
|
||||
const instructor = await this.getInstructorLmsUser(accessToken);
|
||||
const thread = await this.getInstructorThreadById(threadId, instructor);
|
||||
|
||||
return {
|
||||
thread: this.serializeInstructorThread(thread),
|
||||
};
|
||||
}
|
||||
|
||||
async createInstructorMessage(
|
||||
accessToken: string | undefined,
|
||||
threadId: string,
|
||||
dto: CreateInstructorChatMessageDto,
|
||||
) {
|
||||
const instructor = await this.getInstructorLmsUser(accessToken);
|
||||
const thread = await this.getInstructorThreadById(threadId, instructor);
|
||||
|
||||
const message = await this.prisma.$transaction(async (tx) => {
|
||||
const createdMessage = await tx.chatMessage.create({
|
||||
data: {
|
||||
threadId,
|
||||
senderType: ChatSenderType.INSTRUCTOR,
|
||||
senderUserId: instructor.id,
|
||||
content: dto.content,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.chatThread.update({
|
||||
where: { id: threadId },
|
||||
data: {
|
||||
assignedInstructorId: instructor.id,
|
||||
status: ChatThreadStatus.OPEN,
|
||||
lastMessageAt: createdMessage.createdAt,
|
||||
},
|
||||
});
|
||||
|
||||
return createdMessage;
|
||||
});
|
||||
|
||||
const updatedThread = await this.getInstructorThreadById(threadId, instructor);
|
||||
const payload = this.toNotificationPayload(threadId, message);
|
||||
this.chatGateway.notifyThread(threadId, payload);
|
||||
this.chatGateway.notifyUser(thread.student.id, payload);
|
||||
this.chatGateway.notifyCourseInstructors(thread.courseId, payload);
|
||||
|
||||
return {
|
||||
thread: this.serializeInstructorThread(updatedThread),
|
||||
message: this.serializeMessage(message),
|
||||
};
|
||||
}
|
||||
|
||||
private async getLmsUser(accessToken: string | undefined) {
|
||||
if (!accessToken) throw new UnauthorizedException('Bearer token required');
|
||||
|
||||
const { user } = await this.authService.me(accessToken);
|
||||
|
||||
return this.prisma.user.upsert({
|
||||
where: { email: user.email },
|
||||
update: this.toUserSyncData(user),
|
||||
create: {
|
||||
email: user.email,
|
||||
...this.toUserSyncData(user),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async getInstructorLmsUser(accessToken: string | undefined) {
|
||||
if (!accessToken) throw new UnauthorizedException('Bearer token required');
|
||||
|
||||
const { user } = await this.authService.me(accessToken);
|
||||
const roles = new Set(
|
||||
[user.role, ...(user.roles ?? [])].map((role) => role.toUpperCase()),
|
||||
);
|
||||
|
||||
if (!['INSTRUCTOR', 'LMS_ADMIN'].some((role) => roles.has(role))) {
|
||||
throw new ForbiddenException('Instructor role required');
|
||||
}
|
||||
|
||||
return this.prisma.user.upsert({
|
||||
where: { email: user.email },
|
||||
update: this.toUserSyncData(user),
|
||||
create: {
|
||||
email: user.email,
|
||||
...this.toUserSyncData(user),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private toUserSyncData(user: AuthResponseUser) {
|
||||
return {
|
||||
authUserId: user.id,
|
||||
name: user.name,
|
||||
authRole: user.role,
|
||||
primaryOrganizationId: user.primaryAffiliation?.organizationId ?? null,
|
||||
primaryOrganizationName: user.primaryAffiliation?.organizationName ?? null,
|
||||
primaryOrganizationType: user.primaryAffiliation?.organizationType ?? null,
|
||||
department: user.primaryAffiliation?.department ?? null,
|
||||
position: user.primaryAffiliation?.position ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private instructorThreadVisibilityWhere(
|
||||
instructor: Prisma.UserGetPayload<Record<string, never>>,
|
||||
): Prisma.ChatThreadWhereInput {
|
||||
const studentOrganizationFilters = [
|
||||
instructor.primaryOrganizationId
|
||||
? {
|
||||
primaryOrganizationId: instructor.primaryOrganizationId,
|
||||
}
|
||||
: undefined,
|
||||
instructor.primaryOrganizationName
|
||||
? {
|
||||
progress: {
|
||||
some: {
|
||||
course: {
|
||||
organizationName: instructor.primaryOrganizationName,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
].filter(Boolean) as Prisma.UserWhereInput[];
|
||||
|
||||
return {
|
||||
OR: [
|
||||
{ assignedInstructorId: null },
|
||||
{ assignedInstructorId: instructor.id },
|
||||
],
|
||||
student: studentOrganizationFilters.length
|
||||
? {
|
||||
OR: studentOrganizationFilters,
|
||||
}
|
||||
: {
|
||||
id: '__NO_ORGANIZATION_SCOPE__',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async getActiveCourse() {
|
||||
const course = await this.prisma.course.findFirst({
|
||||
where: { slug: 'futuredrive-autonomy-basic', isActive: true },
|
||||
});
|
||||
if (!course) throw new NotFoundException('Curriculum course not found');
|
||||
return course;
|
||||
}
|
||||
|
||||
private async getCourseWeek(courseId: string, weekNumber: number) {
|
||||
const week = await this.prisma.courseWeek.findUnique({
|
||||
where: { courseId_week: { courseId, week: weekNumber } },
|
||||
});
|
||||
if (!week) throw new NotFoundException('Course week not found');
|
||||
return week;
|
||||
}
|
||||
|
||||
private async getOrCreateThread(input: {
|
||||
courseId: string;
|
||||
weekId: string | null;
|
||||
studentId: string;
|
||||
type: ChatThreadType;
|
||||
firstMessage: string;
|
||||
status: ChatThreadStatus;
|
||||
}) {
|
||||
const thread = await this.prisma.chatThread.findFirst({
|
||||
where: {
|
||||
courseId: input.courseId,
|
||||
weekId: input.weekId,
|
||||
studentId: input.studentId,
|
||||
type: input.type,
|
||||
status: { not: ChatThreadStatus.ARCHIVED },
|
||||
},
|
||||
orderBy: { lastMessageAt: 'desc' },
|
||||
});
|
||||
|
||||
if (thread) return thread;
|
||||
|
||||
return this.prisma.chatThread.create({
|
||||
data: {
|
||||
courseId: input.courseId,
|
||||
weekId: input.weekId,
|
||||
studentId: input.studentId,
|
||||
type: input.type,
|
||||
status: input.status,
|
||||
title: input.firstMessage.slice(0, 80),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async getInstructorThreadById(
|
||||
threadId: string,
|
||||
instructor: Prisma.UserGetPayload<Record<string, never>>,
|
||||
) {
|
||||
const thread = await this.prisma.chatThread.findFirst({
|
||||
where: {
|
||||
id: threadId,
|
||||
type: ChatThreadType.INSTRUCTOR,
|
||||
status: { not: ChatThreadStatus.ARCHIVED },
|
||||
...this.instructorThreadVisibilityWhere(instructor),
|
||||
},
|
||||
include: {
|
||||
student: {
|
||||
select: {
|
||||
id: true,
|
||||
authUserId: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
assignedInstructor: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
where: { deletedAt: null },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: {
|
||||
aiRun: true,
|
||||
senderUser: {
|
||||
select: {
|
||||
authUserId: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
week: {
|
||||
select: { week: true, title: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!thread) throw new NotFoundException('Instructor chat thread not found');
|
||||
|
||||
return thread;
|
||||
}
|
||||
|
||||
private async requestAiChatCompletion(
|
||||
model: string,
|
||||
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
|
||||
) {
|
||||
const provider = this.configService.get<string>('AI_PROVIDER', 'openai').trim().toLowerCase();
|
||||
if (provider === 'futuredrive') {
|
||||
return this.requestFutureDriveLlmChatCompletion(messages);
|
||||
}
|
||||
return this.requestOpenAiChatCompletion(model, messages);
|
||||
}
|
||||
|
||||
private async requestOpenAiChatCompletion(
|
||||
model: string,
|
||||
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
|
||||
) {
|
||||
const apiKey = this.configService.get<string>('OPENAI_API_KEY');
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
'OPENAI_API_KEY가 설정되어 있지 않습니다. lms-be .env에 OPENAI_API_KEY=... 형식으로 추가하세요.',
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
temperature: 0.3,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'당신은 자율주행 교육용 ROS2, SLAM 실습을 돕는 한국어 AI 챗봇입니다. 답변은 짧고 실습자가 바로 따라 할 수 있게 명확하게 작성하세요.',
|
||||
},
|
||||
...messages,
|
||||
],
|
||||
}),
|
||||
});
|
||||
const data = (await response.json()) as OpenAiChatCompletionResponse;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error?.message ?? `OpenAI request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const content = data.choices?.[0]?.message?.content?.trim();
|
||||
if (!content) throw new Error('OpenAI 응답이 비어 있습니다.');
|
||||
|
||||
return {
|
||||
content,
|
||||
usage: {
|
||||
promptTokens: data.usage?.prompt_tokens,
|
||||
completionTokens: data.usage?.completion_tokens,
|
||||
totalTokens: data.usage?.total_tokens,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async requestFutureDriveLlmChatCompletion(
|
||||
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
|
||||
) {
|
||||
const apiKey = this.configService.get<string>('FUTUREDRIVE_LLM_API_KEY');
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
'FUTUREDRIVE_LLM_API_KEY가 설정되어 있지 않습니다. lms-be .env에 FUTUREDRIVE_LLM_API_KEY=... 형식으로 추가하세요.',
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl = this.configService
|
||||
.get<string>('FUTUREDRIVE_LLM_BASE_URL', 'https://famine-tissue-cursor.ngrok-free.dev')
|
||||
.replace(/\/+$/, '');
|
||||
const lang = this.configService.get<string>('FUTUREDRIVE_LLM_LANG', 'ko');
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/chat/complete`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-API-Key': apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
'ngrok-skip-browser-warning': '1',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messages,
|
||||
lang,
|
||||
}),
|
||||
});
|
||||
const data = (await response.json().catch(() => ({}))) as FutureDriveLlmChatResponse;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error?.message ?? data.message ?? `FutureDrive LLM request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const content = data.answer?.trim();
|
||||
if (!content) throw new Error('FutureDrive LLM 응답이 비어 있습니다.');
|
||||
|
||||
return {
|
||||
content,
|
||||
usage: {
|
||||
promptTokens: undefined,
|
||||
completionTokens: undefined,
|
||||
totalTokens: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private toThreadType(mode: 'ai' | 'teacher') {
|
||||
return mode === 'ai' ? ChatThreadType.AI : ChatThreadType.INSTRUCTOR;
|
||||
}
|
||||
|
||||
private serializeThread(
|
||||
thread: Prisma.ChatThreadGetPayload<{
|
||||
include: {
|
||||
messages: { include: { aiRun: true; senderUser: { select: { authUserId: true; email: true; name: true } } } };
|
||||
week: { select: { week: true; title: true } };
|
||||
};
|
||||
}>,
|
||||
) {
|
||||
return {
|
||||
id: thread.id,
|
||||
type: thread.type,
|
||||
status: thread.status,
|
||||
title: thread.title,
|
||||
week: thread.week,
|
||||
lastMessageAt: thread.lastMessageAt,
|
||||
messages: [...thread.messages]
|
||||
.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime())
|
||||
.map((message) => this.serializeMessage(message)),
|
||||
};
|
||||
}
|
||||
|
||||
private serializeInstructorThread(
|
||||
thread: Prisma.ChatThreadGetPayload<{
|
||||
include: {
|
||||
student: { select: { id: true; authUserId: true; email: true; name: true } };
|
||||
assignedInstructor: { select: { id: true; email: true; name: true } };
|
||||
messages: { include: { aiRun: true; senderUser: { select: { authUserId: true; email: true; name: true } } } };
|
||||
week: { select: { week: true; title: true } };
|
||||
};
|
||||
}>,
|
||||
) {
|
||||
return {
|
||||
id: thread.id,
|
||||
type: thread.type,
|
||||
status: thread.status,
|
||||
title: thread.title,
|
||||
student: thread.student,
|
||||
studentName: thread.student.name ?? thread.student.email,
|
||||
studentAvatarUrl: this.getStudentAvatarUrl(thread.student),
|
||||
assignedInstructor: thread.assignedInstructor,
|
||||
week: thread.week,
|
||||
lastMessageAt: thread.lastMessageAt,
|
||||
messages: thread.messages.map((message) => this.serializeMessage(message)),
|
||||
};
|
||||
}
|
||||
|
||||
private serializeMessage(
|
||||
message: Prisma.ChatMessageGetPayload<{
|
||||
include: {
|
||||
aiRun: true;
|
||||
senderUser: { select: { authUserId: true; email: true; name: true } };
|
||||
};
|
||||
}> | {
|
||||
id: string;
|
||||
senderType: ChatSenderType;
|
||||
senderUserId: string | null;
|
||||
senderUser?: { email: string; name: string | null } | null;
|
||||
content: string;
|
||||
contentFormat: string;
|
||||
status?: ChatMessageStatus;
|
||||
metadata: Prisma.JsonValue | null;
|
||||
createdAt: Date;
|
||||
canceledAt?: Date | null;
|
||||
deletedAt: Date | null;
|
||||
},
|
||||
) {
|
||||
return {
|
||||
id: message.id,
|
||||
senderType: message.senderType,
|
||||
senderUserId: message.senderUserId,
|
||||
senderName: message.senderUser?.name ?? message.senderUser?.email ?? null,
|
||||
senderAvatarUrl: this.getUserAvatarUrl(message.senderUser ?? null),
|
||||
content: message.content,
|
||||
contentFormat: message.contentFormat,
|
||||
status: 'status' in message ? message.status : ChatMessageStatus.ACTIVE,
|
||||
metadata: message.metadata,
|
||||
createdAt: message.createdAt,
|
||||
canceledAt: 'canceledAt' in message ? message.canceledAt : null,
|
||||
};
|
||||
}
|
||||
|
||||
private toNotificationPayload(
|
||||
threadId: string,
|
||||
message: {
|
||||
id: string;
|
||||
senderType: ChatSenderType;
|
||||
content: string;
|
||||
createdAt: Date;
|
||||
},
|
||||
): ChatNotificationPayload {
|
||||
return {
|
||||
type: 'chat.message.created',
|
||||
threadId,
|
||||
messageId: message.id,
|
||||
senderType: message.senderType,
|
||||
preview: message.content.slice(0, 120),
|
||||
createdAt: message.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private getStudentAvatarUrl(student: {
|
||||
authUserId: string | null;
|
||||
email: string;
|
||||
}) {
|
||||
return this.getUserAvatarUrl(student);
|
||||
}
|
||||
|
||||
private getUserAvatarUrl(user: {
|
||||
authUserId?: string | null;
|
||||
email?: string | null;
|
||||
} | null) {
|
||||
if (!user) return null;
|
||||
|
||||
if (user.authUserId) {
|
||||
return `${this.authServerUrl}/auth/users/${user.authUserId}/avatar`;
|
||||
}
|
||||
|
||||
if (user.email) {
|
||||
return `${this.authServerUrl}/auth/users/avatar?email=${encodeURIComponent(user.email)}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
export class CreateAiResponseDto {
|
||||
@IsString()
|
||||
content: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
week?: number;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
export class CreateChatMessageDto {
|
||||
@IsIn(['ai', 'teacher'])
|
||||
mode: 'ai' | 'teacher';
|
||||
|
||||
@IsString()
|
||||
content: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
week?: number;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class CreateInstructorChatMessageDto {
|
||||
@IsString()
|
||||
content: string;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
|
||||
export class ListChatThreadsDto {
|
||||
@IsOptional()
|
||||
@IsIn(['ai', 'teacher'])
|
||||
mode?: 'ai' | 'teacher';
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => Number(value))
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
week?: number;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Controller, Get, Param, Query, Req, Res } from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
import { CurriculumService } from './curriculum.service';
|
||||
|
||||
@Controller('curriculum')
|
||||
export class CurriculumController {
|
||||
constructor(private readonly curriculumService: CurriculumService) {}
|
||||
|
||||
@Get()
|
||||
getActiveCurriculum(@Req() request: Request, @Query('courseId') courseId?: string) {
|
||||
return this.curriculumService.getActiveCurriculum(
|
||||
this.getBearerToken(request),
|
||||
courseId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('courses')
|
||||
listCourses(@Req() request: Request) {
|
||||
return this.curriculumService.listCourses(this.getBearerToken(request));
|
||||
}
|
||||
|
||||
@Get('references/:referenceId/download')
|
||||
async downloadReference(
|
||||
@Req() request: Request,
|
||||
@Param('referenceId') referenceId: string,
|
||||
@Res() response: Response,
|
||||
) {
|
||||
const download = await this.curriculumService.getReferenceDownload(
|
||||
this.getBearerToken(request),
|
||||
referenceId,
|
||||
);
|
||||
response.setHeader('content-type', download.contentType);
|
||||
return response.download(download.path, download.fileName);
|
||||
}
|
||||
|
||||
private getBearerToken(request: Request): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user