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