init studio launcher backend

This commit is contained in:
2026-07-01 18:01:05 +09:00
commit 702d093736
69 changed files with 14881 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
.env
.env.*
*.log
coverage/
+4
View File
@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}
+30
View File
@@ -0,0 +1,30 @@
FROM node:22-bookworm-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY prisma ./prisma
RUN npm run prisma:generate
COPY src ./src
COPY tsconfig*.json ./
COPY nest-cli.json ./
RUN npm run build
FROM node:22-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
EXPOSE 4300
CMD ["node", "dist/src/main.js"]
+98
View File
@@ -0,0 +1,98 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
+23
View File
@@ -0,0 +1,23 @@
services:
postgres:
image: postgres:17-alpine
container_name: autonomy-studio-launcher-postgres
command: postgres -c timezone=Asia/Seoul
environment:
POSTGRES_DB: ${POSTGRES_DB:-launcher}
POSTGRES_USER: ${POSTGRES_USER:-launcher}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-future1234!@}
ports:
- "${POSTGRES_PORT:-5435}:5432"
volumes:
- launcher_postgres_data:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-launcher} -d ${POSTGRES_DB:-launcher}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
volumes:
launcher_postgres_data:
+35
View File
@@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+10404
View File
File diff suppressed because it is too large Load Diff
+84
View File
@@ -0,0 +1,84 @@
{
"name": "atnmy-stdo-v2-be",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy",
"db:seed": "ts-node prisma/seed.ts",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/common": "^11.1.24",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.24",
"@nestjs/platform-express": "^11.1.24",
"@nestjs/serve-static": "^5.0.5",
"@prisma/client": "^6.19.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"prisma": "^6.19.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.21",
"@nestjs/schematics": "^11.1.0",
"@nestjs/testing": "^11.1.24",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.0.0",
"@types/supertest": "^7.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^17.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
}
}
@@ -0,0 +1,16 @@
CREATE TABLE "launcher_notices" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"body" TEXT NOT NULL,
"link_url" TEXT,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"starts_at" TIMESTAMP(3),
"ends_at" TIMESTAMP(3),
"priority" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "launcher_notices_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "launcher_notices_is_active_priority_idx" ON "launcher_notices"("is_active", "priority");
@@ -0,0 +1,4 @@
ALTER TABLE "launcher_notices" ADD COLUMN "app_key" TEXT NOT NULL DEFAULT 'login';
DROP INDEX IF EXISTS "launcher_notices_is_active_priority_idx";
CREATE INDEX "launcher_notices_app_key_is_active_priority_idx" ON "launcher_notices"("app_key", "is_active", "priority");
@@ -0,0 +1,17 @@
CREATE TABLE "launcher_app_versions" (
"id" TEXT NOT NULL,
"app_key" TEXT NOT NULL,
"display_name" TEXT NOT NULL,
"latest_version" TEXT NOT NULL,
"minimum_supported_version" TEXT,
"download_url" TEXT,
"release_notes" TEXT,
"is_enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "launcher_app_versions_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "launcher_app_versions_app_key_key" ON "launcher_app_versions"("app_key");
CREATE INDEX "launcher_app_versions_is_enabled_idx" ON "launcher_app_versions"("is_enabled");
@@ -0,0 +1,22 @@
CREATE TABLE "launcher_apps" (
"id" TEXT NOT NULL,
"app_key" TEXT NOT NULL,
"display_name" TEXT NOT NULL,
"description" TEXT NOT NULL,
"subtitle" TEXT,
"detail_url" TEXT,
"video_path" TEXT,
"icon_key" TEXT NOT NULL,
"launch_url" TEXT,
"dev_app_path" TEXT,
"exec_path" TEXT,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"is_enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "launcher_apps_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "launcher_apps_app_key_key" ON "launcher_apps"("app_key");
CREATE INDEX "launcher_apps_is_enabled_sort_order_idx" ON "launcher_apps"("is_enabled", "sort_order");
@@ -0,0 +1,41 @@
COMMENT ON TABLE "launcher_notices" IS '런처 로그인 화면 및 앱별 화면에 노출할 공지사항';
COMMENT ON COLUMN "launcher_notices"."id" IS '런처 공지 고유 ID';
COMMENT ON COLUMN "launcher_notices"."app_key" IS '공지 대상 앱 식별자. login은 로그인 화면 공지';
COMMENT ON COLUMN "launcher_notices"."title" IS '공지 제목';
COMMENT ON COLUMN "launcher_notices"."body" IS '공지 본문';
COMMENT ON COLUMN "launcher_notices"."link_url" IS '자세히보기 링크 URL';
COMMENT ON COLUMN "launcher_notices"."is_active" IS '공지 활성 여부';
COMMENT ON COLUMN "launcher_notices"."starts_at" IS '공지 노출 시작 일시';
COMMENT ON COLUMN "launcher_notices"."ends_at" IS '공지 노출 종료 일시';
COMMENT ON COLUMN "launcher_notices"."priority" IS '공지 우선순위. 값이 높을수록 우선 노출';
COMMENT ON COLUMN "launcher_notices"."created_at" IS '공지 생성 일시';
COMMENT ON COLUMN "launcher_notices"."updated_at" IS '공지 수정 일시';
COMMENT ON TABLE "launcher_app_versions" IS '런처 및 앱별 최신 버전, 다운로드 URL, 릴리즈 노트 관리';
COMMENT ON COLUMN "launcher_app_versions"."id" IS '앱 버전 레코드 고유 ID';
COMMENT ON COLUMN "launcher_app_versions"."app_key" IS '버전 대상 앱 식별자';
COMMENT ON COLUMN "launcher_app_versions"."display_name" IS '버전 대상 앱 표시명';
COMMENT ON COLUMN "launcher_app_versions"."latest_version" IS '파일서버에 배포된 최신 버전';
COMMENT ON COLUMN "launcher_app_versions"."minimum_supported_version" IS '강제 업데이트 판단용 최소 지원 버전';
COMMENT ON COLUMN "launcher_app_versions"."download_url" IS '설치 또는 업데이트 파일 다운로드 URL';
COMMENT ON COLUMN "launcher_app_versions"."release_notes" IS '업데이트 안내 문구 또는 릴리즈 노트';
COMMENT ON COLUMN "launcher_app_versions"."is_enabled" IS '버전 정보 사용 여부';
COMMENT ON COLUMN "launcher_app_versions"."created_at" IS '버전 정보 생성 일시';
COMMENT ON COLUMN "launcher_app_versions"."updated_at" IS '버전 정보 수정 일시';
COMMENT ON TABLE "launcher_apps" IS '런처에 표시할 앱 카드와 실행 정보를 관리하는 앱 마스터';
COMMENT ON COLUMN "launcher_apps"."id" IS '런처 앱 고유 ID';
COMMENT ON COLUMN "launcher_apps"."app_key" IS '런처 앱 식별자';
COMMENT ON COLUMN "launcher_apps"."display_name" IS '런처에 표시할 앱 이름';
COMMENT ON COLUMN "launcher_apps"."description" IS '앱 소개 본문';
COMMENT ON COLUMN "launcher_apps"."subtitle" IS '앱 소개 보조 문구';
COMMENT ON COLUMN "launcher_apps"."detail_url" IS '자세히보기 링크 URL';
COMMENT ON COLUMN "launcher_apps"."video_path" IS '앱 소개 영상 경로 또는 URL';
COMMENT ON COLUMN "launcher_apps"."icon_key" IS '프론트엔드 아이콘 매핑 키';
COMMENT ON COLUMN "launcher_apps"."launch_url" IS '웹 앱 실행 URL';
COMMENT ON COLUMN "launcher_apps"."dev_app_path" IS '개발 환경에서 실행할 로컬 앱 경로';
COMMENT ON COLUMN "launcher_apps"."exec_path" IS '설치 후 실행할 파일 경로 또는 파일명';
COMMENT ON COLUMN "launcher_apps"."sort_order" IS '런처 앱 표시 순서';
COMMENT ON COLUMN "launcher_apps"."is_enabled" IS '런처 앱 노출 여부';
COMMENT ON COLUMN "launcher_apps"."created_at" IS '런처 앱 생성 일시';
COMMENT ON COLUMN "launcher_apps"."updated_at" IS '런처 앱 수정 일시';
@@ -0,0 +1,22 @@
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
UPDATE "launcher_notices"
SET "id" = gen_random_uuid()::text
WHERE "id" !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$';
UPDATE "launcher_app_versions"
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 "launcher_apps"
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 "launcher_notices"
ALTER COLUMN "id" TYPE uuid USING "id"::uuid;
ALTER TABLE "launcher_app_versions"
ALTER COLUMN "id" TYPE uuid USING "id"::uuid;
ALTER TABLE "launcher_apps"
ALTER COLUMN "id" TYPE uuid USING "id"::uuid;
@@ -0,0 +1,65 @@
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE "notifications" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"app_key" TEXT NOT NULL DEFAULT 'all',
"audience" TEXT NOT NULL DEFAULT 'all',
"category" TEXT NOT NULL DEFAULT 'notice',
"title" TEXT NOT NULL,
"body" TEXT NOT NULL,
"link_url" TEXT,
"priority" INTEGER NOT NULL DEFAULT 0,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"starts_at" TIMESTAMP(3),
"ends_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "notifications_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "notification_reads" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"notification_id" uuid NOT NULL,
"auth_user_id" uuid NOT NULL,
"read_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "notification_reads_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "notifications_app_key_audience_is_active_priority_idx" ON "notifications"("app_key", "audience", "is_active", "priority");
CREATE INDEX "notifications_is_active_created_at_idx" ON "notifications"("is_active", "created_at");
CREATE UNIQUE INDEX "notification_reads_notification_id_auth_user_id_key" ON "notification_reads"("notification_id", "auth_user_id");
CREATE INDEX "notification_reads_auth_user_id_idx" ON "notification_reads"("auth_user_id");
ALTER TABLE "notification_reads" ADD CONSTRAINT "notification_reads_notification_id_fkey" FOREIGN KEY ("notification_id") REFERENCES "notifications"("id") ON DELETE CASCADE ON UPDATE CASCADE;
COMMENT ON TABLE "notifications" IS '런처와 모든 앱이 공통으로 사용하는 알림 메시지';
COMMENT ON COLUMN "notifications"."id" IS '알림 고유 ID';
COMMENT ON COLUMN "notifications"."app_key" IS '알림 대상 앱 식별자. all이면 전체 앱 대상';
COMMENT ON COLUMN "notifications"."audience" IS '알림 대상 사용자 그룹. all, user, student, instructor, admin 등을 사용';
COMMENT ON COLUMN "notifications"."category" IS '알림 분류';
COMMENT ON COLUMN "notifications"."title" IS '알림 제목';
COMMENT ON COLUMN "notifications"."body" IS '알림 본문';
COMMENT ON COLUMN "notifications"."link_url" IS '알림 클릭 시 이동할 링크 URL';
COMMENT ON COLUMN "notifications"."priority" IS '알림 우선순위. 값이 높을수록 우선 노출';
COMMENT ON COLUMN "notifications"."is_active" IS '알림 활성 여부';
COMMENT ON COLUMN "notifications"."starts_at" IS '알림 노출 시작 일시';
COMMENT ON COLUMN "notifications"."ends_at" IS '알림 노출 종료 일시';
COMMENT ON COLUMN "notifications"."created_at" IS '알림 생성 일시';
COMMENT ON COLUMN "notifications"."updated_at" IS '알림 수정 일시';
COMMENT ON TABLE "notification_reads" IS '사용자별 공통 알림 읽음 처리';
COMMENT ON COLUMN "notification_reads"."id" IS '알림 읽음 레코드 고유 ID';
COMMENT ON COLUMN "notification_reads"."notification_id" IS '알림 ID';
COMMENT ON COLUMN "notification_reads"."auth_user_id" IS '중앙 auth DB users.id';
COMMENT ON COLUMN "notification_reads"."read_at" IS '알림 읽음 일시';
INSERT INTO "notifications" ("app_key", "audience", "category", "title", "body", "priority", "created_at", "updated_at")
VALUES
('LMS', 'instructor', 'assignment', '신규 과제 검토 요청', '자율주행 인지 실습 과정에 제출된 과제 3건의 검토가 필요합니다.', 100, '2026-06-02T00:00:00.000Z', CURRENT_TIMESTAMP),
('LMS', 'instructor', 'schedule', '라이브 세션 일정 안내', '오늘 15:00에 SLAM 기초 라이브 세션이 예정되어 있습니다.', 90, '2026-06-01T00:00:00.000Z', CURRENT_TIMESTAMP),
('LMS', 'instructor', 'system', '시스템 점검 안내', '6월 5일 02:00부터 03:00까지 LMS 점검이 진행됩니다.', 80, '2026-05-31T00:00:00.000Z', CURRENT_TIMESTAMP),
('LMS', 'student', 'assignment', '과제 제출 마감 안내', '인지 실습 리포트 제출 마감이 오늘 18:00까지입니다.', 100, '2026-06-02T00:00:00.000Z', CURRENT_TIMESTAMP),
('LMS', 'student', 'course', '신규 강의 공개', '자율주행 센서 융합 기초 강의가 새로 공개되었습니다.', 90, '2026-06-01T00:00:00.000Z', CURRENT_TIMESTAMP),
('LMS', 'student', 'schedule', '실습 세션 안내', '내일 10:00에 ROS2 실습 세션이 예정되어 있습니다.', 80, '2026-05-31T00:00:00.000Z', CURRENT_TIMESTAMP);
@@ -0,0 +1,9 @@
UPDATE "launcher_apps"
SET "dev_app_path" = './apps/ads-ide/ads-ide-fe',
"updated_at" = CURRENT_TIMESTAMP
WHERE "app_key" = 'ADS IDE';
UPDATE "launcher_apps"
SET "dev_app_path" = './apps/lms/lms-fe',
"updated_at" = CURRENT_TIMESTAMP
WHERE "app_key" = 'LMS';
@@ -0,0 +1,31 @@
CREATE TABLE "launcher_user_apps" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"auth_user_id" UUID NOT NULL,
"app_id" UUID NOT NULL,
"can_view" BOOLEAN NOT NULL DEFAULT true,
"can_launch" BOOLEAN NOT NULL DEFAULT true,
"assigned_by" UUID,
"assigned_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "launcher_user_apps_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "launcher_user_apps_auth_user_id_app_id_key" ON "launcher_user_apps"("auth_user_id", "app_id");
CREATE INDEX "launcher_user_apps_app_id_idx" ON "launcher_user_apps"("app_id");
CREATE INDEX "launcher_user_apps_auth_user_id_can_view_can_launch_idx" ON "launcher_user_apps"("auth_user_id", "can_view", "can_launch");
ALTER TABLE "launcher_user_apps"
ADD CONSTRAINT "launcher_user_apps_app_id_fkey"
FOREIGN KEY ("app_id") REFERENCES "launcher_apps"("id") ON DELETE CASCADE ON UPDATE CASCADE;
COMMENT ON TABLE "launcher_user_apps" IS '사용자별 런처 앱 접근 권한. auth 사용자 UUID 기준으로 앱 표시 및 실행 가능 여부를 관리한다.';
COMMENT ON COLUMN "launcher_user_apps"."id" IS '사용자 앱 권한 고유 UUID';
COMMENT ON COLUMN "launcher_user_apps"."auth_user_id" IS 'auth DB users.id UUID';
COMMENT ON COLUMN "launcher_user_apps"."app_id" IS '권한이 부여된 런처 앱 UUID';
COMMENT ON COLUMN "launcher_user_apps"."can_view" IS '런처 앱 목록 및 상세 화면 표시 가능 여부';
COMMENT ON COLUMN "launcher_user_apps"."can_launch" IS '앱 설치, 업데이트, 실행 가능 여부';
COMMENT ON COLUMN "launcher_user_apps"."assigned_by" IS '권한을 부여한 관리자 auth 사용자 UUID';
COMMENT ON COLUMN "launcher_user_apps"."assigned_at" IS '권한 부여 일시';
COMMENT ON COLUMN "launcher_user_apps"."created_at" IS '권한 레코드 생성 일시';
COMMENT ON COLUMN "launcher_user_apps"."updated_at" IS '권한 레코드 수정 일시';
@@ -0,0 +1,40 @@
CREATE TABLE "common_codes" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"group_key" TEXT NOT NULL,
"code" TEXT NOT NULL,
"label" TEXT NOT NULL,
"description" TEXT,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "common_codes_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "common_codes_group_key_code_key" ON "common_codes"("group_key", "code");
CREATE INDEX "common_codes_group_key_is_active_sort_order_idx" ON "common_codes"("group_key", "is_active", "sort_order");
COMMENT ON TABLE "common_codes" IS '공통 코드 마스터. 알림 카테고리 등 화면 표시명과 코드값을 관리한다.';
COMMENT ON COLUMN "common_codes"."id" IS '공통 코드 고유 UUID';
COMMENT ON COLUMN "common_codes"."group_key" IS '공통 코드 그룹 키';
COMMENT ON COLUMN "common_codes"."code" IS '업무에서 사용하는 코드값';
COMMENT ON COLUMN "common_codes"."label" IS '화면에 표시할 코드명';
COMMENT ON COLUMN "common_codes"."description" IS '공통 코드 설명';
COMMENT ON COLUMN "common_codes"."sort_order" IS '같은 그룹 내 표시 순서';
COMMENT ON COLUMN "common_codes"."is_active" IS '공통 코드 사용 여부';
COMMENT ON COLUMN "common_codes"."created_at" IS '공통 코드 생성 일시';
COMMENT ON COLUMN "common_codes"."updated_at" IS '공통 코드 수정 일시';
INSERT INTO "common_codes" ("group_key", "code", "label", "description", "sort_order")
VALUES
('NOTIFICATION_CATEGORY', 'assignment', '과제', '과제 및 제출 관련 알림', 10),
('NOTIFICATION_CATEGORY', 'course', '강의', '강의 및 커리큘럼 관련 알림', 20),
('NOTIFICATION_CATEGORY', 'notice', '공지', '일반 공지 알림', 30),
('NOTIFICATION_CATEGORY', 'update', '업데이트', '업데이트 및 배포 알림', 40),
('NOTIFICATION_CATEGORY', 'system', '시스템', '시스템 운영 알림', 50)
ON CONFLICT ("group_key", "code") DO UPDATE
SET "label" = EXCLUDED."label",
"description" = EXCLUDED."description",
"sort_order" = EXCLUDED."sort_order",
"is_active" = true,
"updated_at" = CURRENT_TIMESTAMP;
@@ -0,0 +1,61 @@
CREATE TABLE "download_files" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"app_key" TEXT NOT NULL,
"context_key" TEXT NOT NULL DEFAULT 'general',
"title" TEXT NOT NULL,
"description" TEXT,
"file_name" TEXT NOT NULL,
"source_url" TEXT NOT NULL,
"content_type" TEXT,
"file_size_bytes" BIGINT,
"uploaded_by_app_key" TEXT,
"uploaded_by_auth_id" uuid,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "download_files_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "download_events" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"file_id" uuid NOT NULL,
"auth_user_id" uuid,
"app_key" TEXT NOT NULL,
"user_agent" TEXT,
"ip_address" TEXT,
"downloaded_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "download_events_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "download_files_app_key_context_key_is_active_sort_order_idx" ON "download_files"("app_key", "context_key", "is_active", "sort_order");
CREATE INDEX "download_files_uploaded_by_app_key_idx" ON "download_files"("uploaded_by_app_key");
CREATE INDEX "download_events_file_id_downloaded_at_idx" ON "download_events"("file_id", "downloaded_at");
CREATE INDEX "download_events_auth_user_id_downloaded_at_idx" ON "download_events"("auth_user_id", "downloaded_at");
CREATE INDEX "download_events_app_key_downloaded_at_idx" ON "download_events"("app_key", "downloaded_at");
ALTER TABLE "download_events"
ADD CONSTRAINT "download_events_file_id_fkey"
FOREIGN KEY ("file_id") REFERENCES "download_files"("id") ON DELETE CASCADE ON UPDATE CASCADE;
COMMENT ON TABLE "download_files" IS '스튜디오 앱들이 공통 다운로드 API로 제공하는 파일 메타데이터';
COMMENT ON COLUMN "download_files"."app_key" IS '파일이 노출될 앱 구분 키';
COMMENT ON COLUMN "download_files"."context_key" IS '앱 내부 화면 또는 영역 구분 키';
COMMENT ON COLUMN "download_files"."title" IS '사용자에게 표시할 파일 제목';
COMMENT ON COLUMN "download_files"."description" IS '파일 설명';
COMMENT ON COLUMN "download_files"."file_name" IS '다운로드 시 사용할 파일명';
COMMENT ON COLUMN "download_files"."source_url" IS '실제 원본 파일 URL';
COMMENT ON COLUMN "download_files"."content_type" IS '다운로드 응답에 사용할 MIME 타입';
COMMENT ON COLUMN "download_files"."file_size_bytes" IS '파일 크기 바이트';
COMMENT ON COLUMN "download_files"."uploaded_by_app_key" IS '파일을 등록한 앱 구분 키';
COMMENT ON COLUMN "download_files"."uploaded_by_auth_id" IS '파일을 등록한 중앙 auth 사용자 ID';
COMMENT ON COLUMN "download_files"."sort_order" IS '목록 표시 순서';
COMMENT ON COLUMN "download_files"."is_active" IS '다운로드 노출 여부';
COMMENT ON TABLE "download_events" IS '공통 다운로드 API를 통한 파일 다운로드 이력';
COMMENT ON COLUMN "download_events"."file_id" IS '다운로드한 파일 ID';
COMMENT ON COLUMN "download_events"."auth_user_id" IS '다운로드한 중앙 auth 사용자 ID';
COMMENT ON COLUMN "download_events"."app_key" IS '다운로드가 발생한 앱 구분 키';
COMMENT ON COLUMN "download_events"."user_agent" IS '요청 User-Agent';
COMMENT ON COLUMN "download_events"."ip_address" IS '요청 IP 주소';
COMMENT ON COLUMN "download_events"."downloaded_at" IS '다운로드 발생 시각';
@@ -0,0 +1,8 @@
INSERT INTO "common_codes" ("group_key", "code", "label", "description", "sort_order")
VALUES ('NOTIFICATION_CATEGORY', 'download', '다운로드', '파일 다운로드 관련 알림', 60)
ON CONFLICT ("group_key", "code") DO UPDATE SET
"label" = EXCLUDED."label",
"description" = EXCLUDED."description",
"sort_order" = EXCLUDED."sort_order",
"is_active" = true,
"updated_at" = CURRENT_TIMESTAMP;
@@ -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,29 @@
CREATE TABLE "provisioned_devices" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"account_email" TEXT NOT NULL,
"auth_user_id" UUID,
"hostname" TEXT NOT NULL,
"tailscale_hostname" TEXT NOT NULL,
"ssh_user" TEXT,
"usage_type" TEXT NOT NULL DEFAULT 'personal',
"os" TEXT,
"arch" TEXT,
"kernel" TEXT,
"distro" TEXT,
"device" JSONB,
"installed_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"last_provisioned_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "provisioned_devices_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "provisioned_devices_account_email_tailscale_hostname_key"
ON "provisioned_devices"("account_email", "tailscale_hostname");
CREATE INDEX "provisioned_devices_account_email_idx"
ON "provisioned_devices"("account_email");
CREATE INDEX "provisioned_devices_tailscale_hostname_idx"
ON "provisioned_devices"("tailscale_hostname");
@@ -0,0 +1,20 @@
CREATE TABLE "launcher_ssh_keys" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"account_email" TEXT NOT NULL,
"auth_user_id" UUID,
"public_key" TEXT NOT NULL,
"public_key_hash" TEXT NOT NULL,
"key_type" TEXT,
"label" TEXT,
"last_provisioned_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "launcher_ssh_keys_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "launcher_ssh_keys_account_email_public_key_hash_key"
ON "launcher_ssh_keys"("account_email", "public_key_hash");
CREATE INDEX "launcher_ssh_keys_account_email_idx"
ON "launcher_ssh_keys"("account_email");
@@ -0,0 +1,29 @@
COMMENT ON TABLE "provisioned_devices" IS '에이전트/런처 설치 시 계정과 장비를 프로비저닝한 이력을 저장한다.';
COMMENT ON COLUMN "provisioned_devices"."id" IS '프로비저닝 장비 이력 ID';
COMMENT ON COLUMN "provisioned_devices"."account_email" IS '설치 또는 로그인 프로비저닝을 요청한 계정 이메일';
COMMENT ON COLUMN "provisioned_devices"."auth_user_id" IS '인증 서버의 사용자 ID';
COMMENT ON COLUMN "provisioned_devices"."hostname" IS '설치 장비에서 감지한 원본 호스트명';
COMMENT ON COLUMN "provisioned_devices"."tailscale_hostname" IS 'Headscale/Tailscale에 등록하는 노드 호스트명';
COMMENT ON COLUMN "provisioned_devices"."ssh_user" IS '원격 SSH 접속에 사용할 장비 OS 사용자명';
COMMENT ON COLUMN "provisioned_devices"."usage_type" IS '프로비저닝 용도. personal, class, launcher-client 등';
COMMENT ON COLUMN "provisioned_devices"."os" IS '장비 운영체제 계열';
COMMENT ON COLUMN "provisioned_devices"."arch" IS '장비 CPU 아키텍처';
COMMENT ON COLUMN "provisioned_devices"."kernel" IS '장비 커널 버전';
COMMENT ON COLUMN "provisioned_devices"."distro" IS '장비 배포판/OS 상세 정보';
COMMENT ON COLUMN "provisioned_devices"."device" IS '설치 요청에서 전달된 장비 정보 원본 JSON';
COMMENT ON COLUMN "provisioned_devices"."installed_at" IS '최초 설치 이력 생성 시각';
COMMENT ON COLUMN "provisioned_devices"."last_provisioned_at" IS '마지막 프로비저닝 요청 시각';
COMMENT ON COLUMN "provisioned_devices"."created_at" IS '레코드 생성 시각';
COMMENT ON COLUMN "provisioned_devices"."updated_at" IS '레코드 수정 시각';
COMMENT ON TABLE "launcher_ssh_keys" IS '런처가 생성한 사용자/PC별 SSH public key를 저장한다. private key는 로컬 PC에만 존재한다.';
COMMENT ON COLUMN "launcher_ssh_keys"."id" IS '런처 SSH public key ID';
COMMENT ON COLUMN "launcher_ssh_keys"."account_email" IS 'public key를 등록한 사용자 이메일';
COMMENT ON COLUMN "launcher_ssh_keys"."auth_user_id" IS '인증 서버의 사용자 ID';
COMMENT ON COLUMN "launcher_ssh_keys"."public_key" IS '장비 authorized_keys에 배포할 SSH public key';
COMMENT ON COLUMN "launcher_ssh_keys"."public_key_hash" IS '중복 등록 방지를 위한 public key SHA-256 해시';
COMMENT ON COLUMN "launcher_ssh_keys"."key_type" IS 'SSH key 타입. 예: ssh-ed25519';
COMMENT ON COLUMN "launcher_ssh_keys"."label" IS '키 출처 또는 용도 라벨';
COMMENT ON COLUMN "launcher_ssh_keys"."last_provisioned_at" IS '마지막 키 등록/갱신 시각';
COMMENT ON COLUMN "launcher_ssh_keys"."created_at" IS '레코드 생성 시각';
COMMENT ON COLUMN "launcher_ssh_keys"."updated_at" IS '레코드 수정 시각';
@@ -0,0 +1,4 @@
ALTER TABLE "provisioned_devices"
ADD COLUMN "workspace_root" TEXT;
COMMENT ON COLUMN "provisioned_devices"."workspace_root" IS '에이전트 설치 시 입력한 원격 워크스페이스 루트 경로';
@@ -0,0 +1,8 @@
ALTER TABLE "launcher_notices"
ALTER COLUMN "app_key" SET DEFAULT 'Launcher';
UPDATE "launcher_notices"
SET "app_key" = 'Launcher'
WHERE "app_key" IN ('login', 'launcher');
COMMENT ON COLUMN "launcher_notices"."app_key" IS '공지 대상 앱 식별자. Launcher는 런처 로그인 화면 공지';
@@ -0,0 +1,2 @@
UPDATE "launcher_notices"
SET "priority" = 0;
@@ -0,0 +1,33 @@
ALTER TABLE "launcher_app_versions"
ADD COLUMN IF NOT EXISTS "repository" TEXT,
ADD COLUMN IF NOT EXISTS "branch" TEXT,
ADD COLUMN IF NOT EXISTS "commit_sha" TEXT;
CREATE TABLE IF NOT EXISTS "launcher_common_settings" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"key" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL,
"value" TEXT NOT NULL,
"description" TEXT,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"is_secret" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "launcher_common_settings_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "launcher_common_settings_key_key"
ON "launcher_common_settings"("key");
CREATE INDEX IF NOT EXISTS "launcher_common_settings_type_is_active_idx"
ON "launcher_common_settings"("type", "is_active");
COMMENT ON TABLE "launcher_common_settings" IS '런처 공통 연결 설정과 플랫폼 연동 설정';
COMMENT ON COLUMN "launcher_common_settings"."key" IS '공통 설정 식별 키';
COMMENT ON COLUMN "launcher_common_settings"."name" IS '공통 설정 표시 이름';
COMMENT ON COLUMN "launcher_common_settings"."type" IS '공통 설정 유형';
COMMENT ON COLUMN "launcher_common_settings"."value" IS '공통 설정 값';
COMMENT ON COLUMN "launcher_common_settings"."description" IS '공통 설정 설명';
COMMENT ON COLUMN "launcher_common_settings"."is_active" IS '공통 설정 활성 여부';
COMMENT ON COLUMN "launcher_common_settings"."is_secret" IS '민감 정보 여부';
+3
View File
@@ -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"
+230
View File
@@ -0,0 +1,230 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model LauncherNotice {
id String @id @default(uuid()) @db.Uuid
appKey String @default("Launcher") @map("app_key")
title String
body String
linkUrl String? @map("link_url")
isActive Boolean @default(true) @map("is_active")
startsAt DateTime? @map("starts_at") @db.Timestamptz(3)
endsAt DateTime? @map("ends_at") @db.Timestamptz(3)
priority Int @default(0)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
@@index([appKey, isActive, priority])
@@map("launcher_notices")
}
model LauncherAppVersion {
id String @id @default(uuid()) @db.Uuid
appKey String @unique @map("app_key")
displayName String @map("display_name")
latestVersion String @map("latest_version")
minimumSupportedVersion String? @map("minimum_supported_version")
downloadUrl String? @map("download_url")
releaseNotes String? @map("release_notes")
repository String?
branch String?
commitSha String? @map("commit_sha")
isEnabled Boolean @default(true) @map("is_enabled")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
@@index([isEnabled])
@@map("launcher_app_versions")
}
model LauncherCommonSetting {
id String @id @default(uuid()) @db.Uuid
key String @unique
name String
type String
value String
description String?
isActive Boolean @default(true) @map("is_active")
isSecret Boolean @default(false) @map("is_secret")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
@@index([type, isActive])
@@map("launcher_common_settings")
}
model LauncherApp {
id String @id @default(uuid()) @db.Uuid
appKey String @unique @map("app_key")
displayName String @map("display_name")
description String
subtitle String?
detailUrl String? @map("detail_url")
videoPath String? @map("video_path")
iconKey String @map("icon_key")
launchUrl String? @map("launch_url")
devAppPath String? @map("dev_app_path")
execPath String? @map("exec_path")
sortOrder Int @default(0) @map("sort_order")
isEnabled Boolean @default(true) @map("is_enabled")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
userApps LauncherUserApp[]
@@index([isEnabled, sortOrder])
@@map("launcher_apps")
}
model LauncherUserApp {
id String @id @default(uuid()) @db.Uuid
authUserId String @map("auth_user_id") @db.Uuid
appId String @map("app_id") @db.Uuid
canView Boolean @default(true) @map("can_view")
canLaunch Boolean @default(true) @map("can_launch")
assignedById String? @map("assigned_by") @db.Uuid
assignedAt DateTime @default(now()) @map("assigned_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
app LauncherApp @relation(fields: [appId], references: [id], onDelete: Cascade)
@@unique([authUserId, appId])
@@index([appId])
@@index([authUserId, canView, canLaunch])
@@map("launcher_user_apps")
}
model Notification {
id String @id @default(uuid()) @db.Uuid
appKey String @default("all") @map("app_key")
audience String @default("all")
category String @default("notice")
title String
body String
linkUrl String? @map("link_url")
priority Int @default(0)
isActive Boolean @default(true) @map("is_active")
startsAt DateTime? @map("starts_at") @db.Timestamptz(3)
endsAt DateTime? @map("ends_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
reads NotificationRead[]
@@index([appKey, audience, isActive, priority])
@@index([isActive, createdAt])
@@map("notifications")
}
model NotificationRead {
id String @id @default(uuid()) @db.Uuid
notificationId String @map("notification_id") @db.Uuid
authUserId String @map("auth_user_id") @db.Uuid
readAt DateTime @default(now()) @map("read_at") @db.Timestamptz(3)
notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade)
@@unique([notificationId, authUserId])
@@index([authUserId])
@@map("notification_reads")
}
model CommonCode {
id String @id @default(uuid()) @db.Uuid
groupKey String @map("group_key")
code String
label String
description String?
sortOrder Int @default(0) @map("sort_order")
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)
@@unique([groupKey, code])
@@index([groupKey, isActive, sortOrder])
@@map("common_codes")
}
model DownloadFile {
id String @id @default(uuid()) @db.Uuid
appKey String @map("app_key")
contextKey String @default("general") @map("context_key")
title String
description String?
fileName String @map("file_name")
sourceUrl String @map("source_url")
contentType String? @map("content_type")
fileSizeBytes BigInt? @map("file_size_bytes")
uploadedByAppKey String? @map("uploaded_by_app_key")
uploadedByAuthId String? @map("uploaded_by_auth_id") @db.Uuid
sortOrder Int @default(0) @map("sort_order")
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)
events DownloadEvent[]
@@index([appKey, contextKey, isActive, sortOrder])
@@index([uploadedByAppKey])
@@map("download_files")
}
model DownloadEvent {
id String @id @default(uuid()) @db.Uuid
fileId String @map("file_id") @db.Uuid
authUserId String? @map("auth_user_id") @db.Uuid
appKey String @map("app_key")
userAgent String? @map("user_agent")
ipAddress String? @map("ip_address")
downloadedAt DateTime @default(now()) @map("downloaded_at") @db.Timestamptz(3)
file DownloadFile @relation(fields: [fileId], references: [id], onDelete: Cascade)
@@index([fileId, downloadedAt])
@@index([authUserId, downloadedAt])
@@index([appKey, downloadedAt])
@@map("download_events")
}
model ProvisionedDevice {
id String @id @default(uuid()) @db.Uuid
accountEmail String @map("account_email")
authUserId String? @map("auth_user_id") @db.Uuid
hostname String
tailscaleHostname String @map("tailscale_hostname")
sshUser String? @map("ssh_user")
workspaceRoot String? @map("workspace_root")
usageType String @default("personal") @map("usage_type")
os String?
arch String?
kernel String?
distro String?
device Json?
installedAt DateTime @default(now()) @map("installed_at") @db.Timestamptz(3)
lastProvisionedAt DateTime @default(now()) @map("last_provisioned_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
@@unique([accountEmail, tailscaleHostname])
@@index([accountEmail])
@@index([tailscaleHostname])
@@map("provisioned_devices")
}
model LauncherSshKey {
id String @id @default(uuid()) @db.Uuid
accountEmail String @map("account_email")
authUserId String? @map("auth_user_id") @db.Uuid
publicKey String @map("public_key")
publicKeyHash String @map("public_key_hash")
keyType String? @map("key_type")
label String?
lastProvisionedAt DateTime @default(now()) @map("last_provisioned_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
@@unique([accountEmail, publicKeyHash])
@@index([accountEmail])
@@map("launcher_ssh_keys")
}
+383
View File
@@ -0,0 +1,383 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const appNotices = [
{
id: 'app-notice-ads-ide-v1-1-0',
appKey: 'ADS IDE',
title: 'v1.0.1 업데이트 안내',
body: 'ADS IDE 신규 기능 및 성능 개선 사항이 포함된 업데이트가 배포되었습니다.',
linkUrl: 'https://www.futuredrive.net/solutions/astudio',
createdAt: new Date('2025-04-23T00:00:00.000Z'),
priority: 0,
},
{
id: 'app-notice-ads-ide-maintenance-2025-04-10',
appKey: 'ADS IDE',
title: '정기 점검 안내',
body: '4월 15일 02:00 ~ 04:00 서버 정기 점검이 진행됩니다.',
linkUrl: 'https://www.futuredrive.net/solutions/astudio',
createdAt: new Date('2025-04-10T00:00:00.000Z'),
priority: 0,
},
{
id: 'app-notice-data-management-retention-2025-04-18',
appKey: 'Data Management',
title: '데이터 보존 정책 변경 안내',
body: '5월 1일부터 로그 데이터 보존 기간이 90일로 변경됩니다.',
createdAt: new Date('2025-04-18T00:00:00.000Z'),
priority: 0,
},
{
id: 'app-notice-fms-v2-3-1',
appKey: 'FMS',
title: 'FMS v2.3.1 배포',
body: '차량 관제 대시보드 UI 개선 및 실시간 알림 기능이 추가되었습니다.',
createdAt: new Date('2025-04-20T00:00:00.000Z'),
priority: 0,
},
{
id: 'app-notice-ads-education-curriculum-2025-04-15',
appKey: 'ADS Education',
title: '신규 커리큘럼 추가',
body: '자율주행 센서 융합 과정이 새롭게 추가되었습니다.',
createdAt: new Date('2025-04-15T00:00:00.000Z'),
priority: 0,
},
{
id: 'app-notice-calibration-lidar-2025-04-21',
appKey: 'Calibration',
title: 'LiDAR 캘리브레이션 개선',
body: '다중 LiDAR 동시 캘리브레이션 지원이 추가되었습니다.',
createdAt: new Date('2025-04-21T00:00:00.000Z'),
priority: 0,
},
{
id: 'app-notice-hdmap-builder-format-2025-04-12',
appKey: 'HDMAP Builder',
title: '지도 포맷 업데이트',
body: 'OpenDRIVE 1.7 포맷 지원이 추가되었습니다.',
createdAt: new Date('2025-04-12T00:00:00.000Z'),
priority: 0,
},
];
const appVersions = [
{
appKey: 'launcher',
displayName: 'Autonomy Studio Launcher',
latestVersion: '1.0.1',
minimumSupportedVersion: '1.0.0',
downloadUrl: 'https://testfile.autonomystudio.net/media/platform/autonomy-studio-launcher-1.0.1.deb',
releaseNotes: '런처 업데이트 확인 및 앱별 설치 상태 판단 기능이 추가되었습니다.',
},
{
appKey: 'ADS IDE',
displayName: 'ADS IDE',
latestVersion: '1.1.0',
minimumSupportedVersion: '0.1.0',
downloadUrl: 'https://testfile.autonomystudio.net/media/platform/ADS-IDE-1.1.0.AppImage',
releaseNotes: 'ADS IDE 신규 기능 및 성능 개선 사항이 포함되어 있습니다.',
},
{
appKey: 'LMS',
displayName: 'LMS',
latestVersion: '1.0.0',
minimumSupportedVersion: '1.0.0',
downloadUrl: 'https://autonomystudio.example.com/downloads/lms',
releaseNotes: '학습 관리 시스템 최초 배포 버전입니다.',
},
{
appKey: 'Data Management',
displayName: 'Data Management',
latestVersion: '1.0.0',
minimumSupportedVersion: '1.0.0',
downloadUrl: 'https://autonomystudio.example.com/downloads/data-management',
releaseNotes: '데이터 관리 앱 최초 배포 버전입니다.',
},
{
appKey: 'HMI',
displayName: 'HMI',
latestVersion: '1.0.0',
minimumSupportedVersion: '1.0.0',
downloadUrl: 'https://autonomystudio.example.com/downloads/hmi',
releaseNotes: 'HMI 앱 최초 배포 버전입니다.',
},
{
appKey: 'FMS',
displayName: 'FMS',
latestVersion: '2.3.1',
minimumSupportedVersion: '2.0.0',
downloadUrl: 'https://autonomystudio.example.com/downloads/fms',
releaseNotes: '차량 관제 대시보드 UI 개선 및 실시간 알림 기능이 추가되었습니다.',
},
{
appKey: 'ADS Education',
displayName: 'ADS Education',
latestVersion: '1.0.0',
minimumSupportedVersion: '1.0.0',
downloadUrl: 'https://autonomystudio.example.com/downloads/ads-education',
releaseNotes: '자율주행 교육 앱 최초 배포 버전입니다.',
},
{
appKey: 'AI Benchmark',
displayName: 'AI Benchmark',
latestVersion: '1.0.0',
minimumSupportedVersion: '1.0.0',
downloadUrl: 'https://autonomystudio.example.com/downloads/ai-benchmark',
releaseNotes: 'AI Benchmark 앱 최초 배포 버전입니다.',
},
{
appKey: 'Calibration',
displayName: 'Calibration',
latestVersion: '1.0.4',
minimumSupportedVersion: '1.0.0',
downloadUrl: 'https://autonomystudio.example.com/downloads/calibration',
releaseNotes: '다중 LiDAR 동시 캘리브레이션 지원이 추가되었습니다.',
},
{
appKey: 'IP Repository',
displayName: 'IP Repository',
latestVersion: '1.0.0',
minimumSupportedVersion: '1.0.0',
downloadUrl: 'https://autonomystudio.example.com/downloads/ip-repository',
releaseNotes: 'IP Repository 앱 최초 배포 버전입니다.',
},
{
appKey: 'HDMAP Builder',
displayName: 'HDMAP Builder',
latestVersion: '1.0.0',
minimumSupportedVersion: '1.0.0',
downloadUrl: 'https://autonomystudio.example.com/downloads/hdmap-builder',
releaseNotes: 'OpenDRIVE 1.7 포맷 지원이 추가되었습니다.',
},
];
const launcherApps = [
{
appKey: 'ADS IDE',
displayName: 'ADS IDE',
description: '클라우드 기반에서 자율주행 소프트웨어를 설계·빌드·실행까지 빠르게 수행할 수 있는 통합 개발 플랫폼으로, 오픈소스 ADS 기반의 유연한 구조를 통해 기능 수정과 배포가 가능하며 다양한 실차 및 시뮬레이션 환경을 지원합니다.',
subtitle: '자율주행 모빌리티 소프트웨어\n통합 개발 환경 (IDE)',
detailUrl: 'https://www.futuredrive.net/solutions/astudio',
videoPath: './video/ads_ide.mp4',
iconKey: 'code',
launchUrl: 'http://localhost:5175',
devAppPath: './apps/ads-ide/ads-ide-fe',
execPath: './ADS-IDE-1.1.0.AppImage',
sortOrder: 10,
},
{
appKey: 'LMS',
displayName: 'LMS',
description: '자율주행 교육 과정, 실습 콘텐츠, 사용자 학습 이력을 통합 관리하는 학습 관리 시스템입니다.',
subtitle: '자율주행 교육 콘텐츠\n학습 관리 시스템',
detailUrl: null,
videoPath: './video/lms.mp4',
iconKey: 'graduation',
launchUrl: 'http://localhost:5176',
devAppPath: './apps/lms/lms-fe',
execPath: './LMS-1.0.0.AppImage',
sortOrder: 20,
},
{
appKey: 'Data Management',
displayName: 'Data Management',
description: '주행 데이터 수집 · 저장 · 분석 플랫폼',
subtitle: null,
detailUrl: null,
videoPath: './video/data_management.mp4',
iconKey: 'bar-chart',
launchUrl: null,
devAppPath: null,
execPath: null,
sortOrder: 30,
},
{
appKey: 'HMI',
displayName: 'HMI',
description: '차량 인터페이스 모니터링 및 제어',
subtitle: null,
detailUrl: null,
videoPath: './video/hmi.mp4',
iconKey: 'monitor',
launchUrl: null,
devAppPath: null,
execPath: null,
sortOrder: 40,
},
{
appKey: 'FMS',
displayName: 'FMS',
description: '플릿 운영 및 차량 관제 시스템',
subtitle: null,
detailUrl: null,
videoPath: './video/fms.mp4',
iconKey: 'truck',
launchUrl: null,
devAppPath: null,
execPath: null,
sortOrder: 50,
},
{
appKey: 'ADS Education',
displayName: 'ADS Education',
description: '자율주행 기술 학습 및 교육 콘텐츠',
subtitle: null,
detailUrl: null,
videoPath: './video/ads_education.mp4',
iconKey: 'graduation',
launchUrl: null,
devAppPath: null,
execPath: null,
sortOrder: 60,
},
{
appKey: 'AI Benchmark',
displayName: 'AI Benchmark',
description: 'AI 모델 성능 평가 및 비교 분석',
subtitle: null,
detailUrl: null,
videoPath: './video/ai_benchmark.mp4',
iconKey: 'activity',
launchUrl: null,
devAppPath: null,
execPath: null,
sortOrder: 70,
},
{
appKey: 'Calibration',
displayName: 'Calibration',
description: '센서 캘리브레이션 및 설정 도구',
subtitle: null,
detailUrl: null,
videoPath: './video/calibration.mp4',
iconKey: 'sliders',
launchUrl: null,
devAppPath: null,
execPath: null,
sortOrder: 80,
},
{
appKey: 'IP Repository',
displayName: 'IP Repository',
description: '지식재산 및 소프트웨어 자산 저장소',
subtitle: null,
detailUrl: null,
videoPath: './video/ip_repository.mp4',
iconKey: 'shopping-cart',
launchUrl: null,
devAppPath: null,
execPath: null,
sortOrder: 90,
},
{
appKey: 'HDMAP Builder',
displayName: 'HDMAP Builder',
description: '고정밀 지도 생성 및 편집 도구',
subtitle: null,
detailUrl: null,
videoPath: './video/hdmap_builder.mp4',
iconKey: 'map',
launchUrl: null,
devAppPath: null,
execPath: null,
sortOrder: 100,
},
];
async function main(): Promise<void> {
await prisma.launcherNotice.upsert({
where: { id: 'launcher-notice-v1-1-0' },
create: {
id: 'launcher-notice-v1-1-0',
appKey: 'Launcher',
title: '공지',
body: 'v1.0.1 업데이트가 배포되었습니다. ADS IDE 신규 기능 및 성능 개선 사항이 포함되어 있습니다.',
isActive: true,
priority: 0,
},
update: {
title: '공지',
body: 'v1.0.1 업데이트가 배포되었습니다. ADS IDE 신규 기능 및 성능 개선 사항이 포함되어 있습니다.',
appKey: 'Launcher',
isActive: true,
priority: 0,
},
});
for (const notice of appNotices) {
await prisma.launcherNotice.upsert({
where: { id: notice.id },
create: {
...notice,
isActive: true,
},
update: {
appKey: notice.appKey,
title: notice.title,
body: notice.body,
linkUrl: notice.linkUrl ?? null,
createdAt: notice.createdAt,
isActive: true,
priority: notice.priority,
},
});
}
for (const appVersion of appVersions) {
await prisma.launcherAppVersion.upsert({
where: { appKey: appVersion.appKey },
create: {
...appVersion,
isEnabled: true,
},
update: {
displayName: appVersion.displayName,
latestVersion: appVersion.latestVersion,
minimumSupportedVersion: appVersion.minimumSupportedVersion,
downloadUrl: appVersion.downloadUrl,
releaseNotes: appVersion.releaseNotes,
isEnabled: true,
},
});
}
for (const launcherApp of launcherApps) {
await prisma.launcherApp.upsert({
where: { appKey: launcherApp.appKey },
create: {
...launcherApp,
isEnabled: true,
},
update: {
displayName: launcherApp.displayName,
description: launcherApp.description,
subtitle: launcherApp.subtitle,
detailUrl: launcherApp.detailUrl,
videoPath: launcherApp.videoPath,
iconKey: launcherApp.iconKey,
launchUrl: launcherApp.launchUrl,
devAppPath: launcherApp.devAppPath,
execPath: launcherApp.execPath,
sortOrder: launcherApp.sortOrder,
isEnabled: true,
},
});
}
console.log('Seeded launcher notice: launcher-notice-v1-1-0');
console.log(`Seeded app notices: ${appNotices.length}`);
console.log(`Seeded app versions: ${appVersions.length}`);
console.log(`Seeded launcher apps: ${launcherApps.length}`);
}
main()
.catch((error) => {
console.error(error);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});
+531
View File
@@ -0,0 +1,531 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Autonomy Studio</title>
<link href="https://fonts.googleapis.com/css2?family=Rajdhani:wght@400;500;600;700&family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg-deep: #0a0b0e;
--bg-sidebar: #0d0f14;
--bg-main: #0f1117;
--border: rgba(255,255,255,0.06);
--accent: #3b7ffa;
--accent-glow: rgba(59,127,250,0.35);
--text-primary: #e8eaf0;
--text-secondary: rgba(200,205,220,0.65);
--text-muted: rgba(160,168,185,0.45);
--sidebar-width: 280px;
}
html, body {
width: 100%; height: 100%;
background: #000;
font-family: 'Noto Sans KR', sans-serif;
}
/* ── Window Shell ── */
.window {
width: 100vw; height: 100vh;
background: var(--bg-deep);
border-radius: 0;
overflow: hidden;
display: flex; flex-direction: column;
position: relative;
}
/* ── Title Bar ── */
.titlebar {
height: 38px;
background: #080a0f;
border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between;
padding: 0 16px 0 20px;
flex-shrink: 0;
user-select: none;
}
.titlebar-title {
font-family: 'Rajdhani', sans-serif;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.18em;
color: rgba(180,188,210,0.7);
text-transform: uppercase;
}
.titlebar-logo {
height: 18px;
width: auto;
opacity: 0.9;
margin-right: 8px;
}
.window-controls {
display: flex; gap: 8px; align-items: center;
}
.wc {
width: 14px; height: 14px;
border-radius: 50%;
cursor: pointer;
transition: filter 0.2s;
}
.wc:hover { filter: brightness(1.3); }
.wc-min { background: #3a3d45; }
.wc-max { background: #3a3d45; }
.wc-close { background: #3a3d45; }
.wc-min::after { content: '─'; font-size: 8px; line-height: 14px; display: flex; align-items: center; justify-content: center; color: rgba(255,255,255,0.4); }
.wc-max::after { content: '□'; font-size: 8px; line-height: 14px; display: flex; align-items: center; justify-content: center; color: rgba(255,255,255,0.4); }
.wc-close::after { content: '✕'; font-size: 8px; line-height: 14px; display: flex; align-items: center; justify-content: center; color: rgba(255,255,255,0.4); }
/* ── App Body ── */
.app-body {
flex: 1;
display: flex;
overflow: hidden;
}
/* ── Sidebar ── */
.sidebar {
width: var(--sidebar-width);
background: var(--bg-sidebar);
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
padding: 8px 0;
flex-shrink: 0;
position: relative;
z-index: 2;
}
.sidebar::after {
content: '';
position: absolute; right: 0; top: 0; bottom: 0;
width: 1px;
background: linear-gradient(to bottom, transparent, rgba(59,127,250,0.15) 40%, rgba(59,127,250,0.15) 60%, transparent);
pointer-events: none;
}
.nav-item {
display: flex; align-items: center;
padding: 13px 24px;
font-size: 13.5px;
font-weight: 400;
color: var(--text-secondary);
cursor: pointer;
position: relative;
letter-spacing: 0.01em;
transition: color 0.2s, background 0.2s;
}
.nav-item:hover {
color: var(--text-primary);
background: rgba(255,255,255,0.03);
}
.nav-item.active {
color: var(--accent);
font-weight: 500;
background: rgba(59,127,250,0.07);
}
.nav-item.active::before {
content: '';
position: absolute; left: 0; top: 8px; bottom: 8px;
width: 3px;
background: var(--accent);
border-radius: 0 3px 3px 0;
box-shadow: 0 0 12px var(--accent-glow);
}
/* ── Main Content ── */
.main {
flex: 1;
background: var(--bg-main);
position: relative;
overflow: hidden;
display: flex; flex-direction: column;
}
/* Subtle background gradient */
.main::before {
content: '';
position: absolute; inset: 0;
background:
radial-gradient(ellipse 80% 60% at 65% 50%, rgba(20,25,40,0.9) 0%, transparent 70%),
radial-gradient(ellipse 50% 50% at 20% 50%, rgba(15,30,60,0.3) 0%, transparent 60%);
pointer-events: none;
}
/* ── Top Bar ── */
.topbar { display: none; }
.user-profile {
display: flex; align-items: center; gap: 12px;
cursor: pointer;
padding: 6px 10px;
border-radius: 8px;
transition: background 0.2s;
}
.user-profile:hover { background: rgba(255,255,255,0.04); }
.avatar {
width: 34px; height: 34px;
border-radius: 50%;
background: linear-gradient(135deg, #2c4faa, #1a3580);
display: flex; align-items: center; justify-content: center;
font-family: 'Rajdhani', sans-serif;
font-size: 13px; font-weight: 700;
color: #fff;
letter-spacing: 0.05em;
border: 1px solid rgba(59,127,250,0.4);
box-shadow: 0 0 12px rgba(59,127,250,0.2);
}
.user-info { display: flex; flex-direction: column; }
.user-name {
font-size: 13px; font-weight: 500;
color: var(--text-primary);
line-height: 1.3;
}
.user-email {
font-size: 11px;
color: var(--text-muted);
line-height: 1.3;
}
.chevron {
color: var(--text-muted);
font-size: 10px;
margin-left: 4px;
}
/* ── Hero Content ── */
.hero {
flex: 1;
display: flex; align-items: center;
padding: 80px 0 80px 52px; align-items: flex-start;
position: relative;
overflow: hidden;
}
/* Car image area - atmospheric background */
.car-area {
position: absolute; inset: 0;
overflow: hidden;
}
.car-bg {
position: absolute; inset: 0;
background:
radial-gradient(ellipse 90% 70% at 60% 55%, rgba(25,35,70,0.15) 0%, transparent 60%);
}
/* Car silhouette using CSS art + SVG simulation */
.car-image {
position: absolute; right: -20px; bottom: 40px;
width: 680px; height: 380px;
background:
radial-gradient(ellipse 80% 60% at 50% 50%, rgba(30,40,70,0.2) 0%, transparent 70%);
display: flex; align-items: center; justify-content: center;
}
.car-image img {
width: 100%; height: 100%;
object-fit: contain;
filter: brightness(1.5) contrast(1.05);
opacity: 0;
animation: carFadeIn 1.2s 0.6s ease forwards;
}
/* Fog/atmosphere layers */
.fog-layer {
position: absolute; inset: 0;
background:
radial-gradient(ellipse 70% 50% at 55% 60%, rgba(15,25,55,0.25) 0%, transparent 65%),
radial-gradient(ellipse 60% 40% at 70% 70%, rgba(10,15,40,0.3) 0%, transparent 55%);
pointer-events: none;
}
/* Left gradient fade for text readability */
.left-fade {
position: absolute; left: 0; top: 0; bottom: 0;
width: 45%;
background: linear-gradient(to right, var(--bg-main) 30%, rgba(15,17,23,0.7) 60%, transparent 100%);
pointer-events: none;
z-index: 1;
}
/* Floor reflection */
.floor-reflection {
position: absolute; bottom: 0; left: 0; right: 0;
height: 120px;
background: linear-gradient(to top, rgba(8,10,18,0.8) 0%, transparent 100%);
pointer-events: none;
z-index: 1;
}
/* ── Text Content ── */
.text-content {
position: relative; z-index: 10;
max-width: 480px;
animation: textReveal 1s 0.2s ease both;
}
.brand-title {
font-family: 'Rajdhani', sans-serif;
font-size: 52px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: 0.02em;
line-height: 1.0;
margin-bottom: 20px;
text-shadow: 0 0 60px rgba(59,127,250,0.1);
}
.brand-title span {
color: var(--accent);
}
.subtitle {
font-size: 17px;
font-weight: 400;
color: rgba(200,210,230,0.85);
line-height: 1.7;
margin-bottom: 28px;
letter-spacing: 0.01em;
}
.divider {
width: 40px; height: 2px;
background: linear-gradient(to right, var(--accent), transparent);
margin-bottom: 22px;
border-radius: 2px;
box-shadow: 0 0 10px var(--accent-glow);
}
.description {
font-size: 13.5px;
color: var(--text-secondary);
line-height: 2.0;
letter-spacing: 0.005em;
}
/* ── Version ── */
.version {
position: absolute; bottom: 16px; right: 24px;
font-family: 'Rajdhani', sans-serif;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.12em;
color: var(--text-muted);
z-index: 5;
}
/* ── Animations ── */
@keyframes textReveal {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes carFadeIn {
from { opacity: 0; transform: translateX(30px); }
to { opacity: 1; transform: translateX(0); }
}
.sidebar-footer {
margin-top: auto;
border-top: 1px solid var(--border);
padding: 14px 16px;
}
.sidebar-footer .user-profile {
width: 100%;
padding: 10px 12px;
border-radius: 8px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.06);
}
.sidebar-footer .user-profile:hover {
background: rgba(255,255,255,0.06);
}
.sidebar-footer .user-name { font-size: 13px; }
.sidebar-footer .user-email { font-size: 11px; }
/* Blue accent line at top of sidebar active item area */
.sidebar-header {
padding: 16px 24px 20px;
border-bottom: 1px solid var(--border);
margin-bottom: 8px;
}
.sidebar-logo {
font-family: 'Rajdhani', sans-serif;
font-size: 9px;
font-weight: 600;
letter-spacing: 0.22em;
color: var(--text-muted);
text-transform: uppercase;
}
/* ── Update Button ── */
.update-btn-wrap {
position: absolute;
top: 88px;
right: 82px;
z-index: 10;
}
.update-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 22px;
background: var(--accent);
border: 1px solid var(--accent);
border-radius: 6px;
color: #fff;
font-family: 'Noto Sans KR', sans-serif;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.03em;
cursor: pointer;
transition: background 0.2s, box-shadow 0.2s, transform 0.15s;
box-shadow: 0 4px 20px rgba(59,127,250,0.35);
}
.update-btn:hover {
background: #5592ff;
box-shadow: 0 6px 28px rgba(59,127,250,0.55);
transform: translateY(-1px);
}
.update-btn:active { transform: translateY(0); }
.update-btn svg {
width: 14px; height: 14px;
stroke: #fff;
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
flex-shrink: 0;
}
/* ── Car Video ── */
.car-video {
position: absolute;
right: 0; bottom: 0;
width: 100%; height: 100%;
object-fit: cover;
object-position: center bottom;
opacity: 0;
animation: carFadeIn 1.4s 0.5s ease forwards;
z-index: 2;
}
/* Natural shadow vignette over video */
.car-video-shadow {
position: absolute;
inset: 0;
background:
linear-gradient(to right, var(--bg-main) 0%, var(--bg-main) 14%, rgba(15,17,23,0.45) 32%, transparent 52%),
linear-gradient(to top, rgba(5,7,12,0.9) 0%, transparent 45%),
radial-gradient(ellipse 100% 100% at 80% 40%, transparent 40%, rgba(5,7,12,0.5) 100%);
pointer-events: none;
z-index: 3;
}
</style>
</head>
<body>
<div class="window">
<!-- Title Bar -->
<div class="titlebar">
<div style="display:flex;align-items:center;">
<img src="https://autonomystudio.net/static/logo/autonomy.png" class="titlebar-logo" alt="logo">
<span class="titlebar-title">Autonomy Studio</span>
</div>
<div class="window-controls">
<div class="wc wc-min"></div>
<div class="wc wc-max"></div>
<div class="wc wc-close"></div>
</div>
</div>
<!-- App Body -->
<div class="app-body">
<!-- Sidebar -->
<nav class="sidebar">
<div class="sidebar-header">
<div class="sidebar-logo"></div>
</div>
<div class="nav-item active">ADS IDE</div>
<div class="nav-item">Data Management</div>
<div class="nav-item">HMI</div>
<div class="nav-item">FMS</div>
<div class="nav-item">ADS Education</div>
<div class="nav-item">AI Benchmark</div>
<div class="nav-item">Calibration</div>
<div class="nav-item">IP Repository</div>
<div class="nav-item">HDMAP Builder</div>
<div class="sidebar-footer">
<div class="user-profile">
<div class="avatar">C</div>
<div class="user-info">
<span class="user-name">changhoon yoo</span>
<span class="user-email">chyoo@futuredrive.net</span>
</div>
<span class="chevron">▾</span>
</div>
</div>
</nav>
<!-- Main Content -->
<main class="main">
<!-- Update Button -->
<div class="update-btn-wrap">
<button class="update-btn">
업데이트
</button>
</div>
<!-- Hero -->
<div class="hero">
<!-- Car visual area -->
<div class="car-area">
<div class="car-bg"></div>
<video class="car-video" src="car_move.mp4" autoplay loop muted playsinline></video>
<div class="car-video-shadow"></div>
<div class="floor-reflection"></div>
</div>
<!-- Text Content -->
<div class="text-content">
<h1 class="brand-title">Autonomy<br><span>Studio</span></h1>
<p class="subtitle">클라우드 기반 자율주행 모빌리티<br>소프트웨어 개발, 검증 및 운영 환경</p>
<div class="divider"></div>
<p class="description">
충분히 검증된 오픈소스 기반의 레퍼런스<br>
자율주행 소프트웨어(ADS)를 통해 실차 수준에서 다양한 시뮬레이션과 검증,<br>
AI 기능 성능 평가까지 가능한 통합 DevOps 플랫폼을 제공합니다.
</p>
</div>
</div>
<!-- Version -->
<div class="version">v1.0.0</div>
</main>
</div>
</div>
<script>
// Nav item click interaction
document.querySelectorAll('.nav-item').forEach(item => {
item.addEventListener('click', () => {
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
item.classList.add('active');
});
});
// Window control interactions
document.querySelector('.wc-close').addEventListener('click', () => {
document.querySelector('.window').style.opacity = '0';
document.querySelector('.window').style.transform = 'scale(0.97)';
document.querySelector('.window').style.transition = 'opacity 0.3s, transform 0.3s';
setTimeout(() => {
document.querySelector('.window').style.opacity = '1';
document.querySelector('.window').style.transform = 'scale(1)';
}, 600);
});
</script>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});
+12
View File
@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}
+32
View File
@@ -0,0 +1,32 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AuthModule } from './auth/auth.module';
import { AppsModule } from './apps/apps.module';
import { NoticesModule } from './notices/notices.module';
import { NotificationsModule } from './notifications/notifications.module';
import { DownloadsModule } from './downloads/downloads.module';
import { ProvisioningModule } from './provisioning/provisioning.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', 'public'),
}),
AuthModule,
NoticesModule,
NotificationsModule,
DownloadsModule,
ProvisioningModule,
AppsModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
+8
View File
@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}
+48
View File
@@ -0,0 +1,48 @@
import { Body, Controller, Get, Param, Post, Query, Req } from '@nestjs/common';
import type { Request } from 'express';
import { AppsService } from './apps.service';
import { GrantAppPermissionDto } from './dto/grant-app-permission.dto';
@Controller('api/apps')
export class AppsController {
constructor(private readonly appsService: AppsService) {}
@Get()
getApps(@Req() request: Request) {
return this.appsService.getApps(this.getBearerToken(request));
}
@Get(':appKey/update')
getUpdateStatus(
@Param('appKey') appKey: string,
@Req() request: Request,
@Query('currentVersion') currentVersion?: string,
@Query('installed') installed?: string,
) {
return this.appsService.getUpdateStatus(appKey, {
currentVersion,
installed: installed === 'true',
accessToken: this.getBearerToken(request),
});
}
@Post(':appKey/users/:authUserId/permission')
grantAppPermission(
@Param('appKey') appKey: string,
@Param('authUserId') authUserId: string,
@Body() body: GrantAppPermissionDto,
@Req() request: Request,
) {
return this.appsService.grantAppPermission(
appKey,
authUserId,
body,
this.getBearerToken(request),
);
}
private getBearerToken(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PrismaModule } from '../prisma/prisma.module';
import { AppsController } from './apps.controller';
import { AppsService } from './apps.service';
@Module({
imports: [PrismaModule, AuthModule],
controllers: [AppsController],
providers: [AppsService],
})
export class AppsModule {}
+299
View File
@@ -0,0 +1,299 @@
import {
ForbiddenException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { AuthService } from '../auth/auth.service';
import { PrismaService } from '../prisma/prisma.service';
import { GrantAppPermissionDto } from './dto/grant-app-permission.dto';
type UpdateAction = 'INSTALL' | 'UPDATE' | 'LAUNCH' | 'UNAVAILABLE';
type UpdateStatus = 'NOT_INSTALLED' | 'UPDATE_AVAILABLE' | 'READY' | 'DISABLED' | 'UNKNOWN_APP';
type UpdateStatusInput = {
currentVersion?: string;
installed: boolean;
accessToken?: string;
};
type AuthMeResponse = {
user?: {
id?: string;
role?: string;
roles?: string[];
};
};
const APP_PERMISSION_ADMIN_ROLES = new Set(['ADMIN']);
function normalizeVersion(version?: string): string | undefined {
return version?.trim().replace(/^v/i, '') || undefined;
}
function compareVersions(left?: string, right?: string): number {
const a = normalizeVersion(left);
const b = normalizeVersion(right);
if (!a && !b) return 0;
if (!a) return -1;
if (!b) return 1;
const leftParts = a.split(/[.-]/).map((part) => Number.parseInt(part, 10));
const rightParts = b.split(/[.-]/).map((part) => Number.parseInt(part, 10));
const length = Math.max(leftParts.length, rightParts.length);
for (let index = 0; index < length; index += 1) {
const leftValue = Number.isNaN(leftParts[index]) ? 0 : leftParts[index] ?? 0;
const rightValue = Number.isNaN(rightParts[index]) ? 0 : rightParts[index] ?? 0;
if (leftValue > rightValue) return 1;
if (leftValue < rightValue) return -1;
}
return 0;
}
@Injectable()
export class AppsService {
constructor(
private readonly prisma: PrismaService,
private readonly authService: AuthService,
) {}
async getApps(accessToken?: string) {
const authUserId = await this.getAuthUserId(accessToken);
const apps = await this.prisma.launcherApp.findMany({
where: { isEnabled: true },
orderBy: [{ sortOrder: 'asc' }, { displayName: 'asc' }],
select: {
appKey: true,
displayName: true,
description: true,
subtitle: true,
detailUrl: true,
videoPath: true,
iconKey: true,
launchUrl: true,
devAppPath: true,
execPath: true,
sortOrder: true,
userApps: authUserId
? {
where: { authUserId },
select: {
canView: true,
canLaunch: true,
},
take: 1,
}
: false,
},
});
return {
apps: apps.map((app) => {
const permission = app.userApps?.[0];
const canView = permission?.canView ?? false;
return {
appKey: app.appKey,
displayName: app.displayName,
description: app.description,
subtitle: app.subtitle,
detailUrl: app.detailUrl,
videoPath: app.videoPath,
iconKey: app.iconKey,
launchUrl: app.launchUrl,
devAppPath: app.devAppPath,
execPath: app.execPath,
sortOrder: app.sortOrder,
canView,
canLaunch: canView && (permission?.canLaunch ?? false),
};
}),
};
}
async getUpdateStatus(appKey: string, input: UpdateStatusInput) {
const isLauncherUpdate = appKey.toLowerCase() === 'launcher';
const authUserId = isLauncherUpdate ? null : await this.getAuthUserId(input.accessToken);
const canLaunch = isLauncherUpdate
? true
: authUserId
? await this.canLaunchApp(authUserId, appKey)
: false;
const appVersion = await this.prisma.launcherAppVersion.findUnique({
where: { appKey },
});
if (!appVersion) {
return {
appKey,
installed: input.installed,
localVersion: input.currentVersion ?? null,
latestVersion: null,
status: 'UNKNOWN_APP' satisfies UpdateStatus,
action: 'UNAVAILABLE' satisfies UpdateAction,
updateRequired: false,
};
}
if (!appVersion.isEnabled) {
return {
appKey,
displayName: appVersion.displayName,
installed: input.installed,
localVersion: input.currentVersion ?? null,
latestVersion: appVersion.latestVersion,
status: 'DISABLED' satisfies UpdateStatus,
action: 'UNAVAILABLE' satisfies UpdateAction,
updateRequired: false,
downloadUrl: appVersion.downloadUrl,
releaseNotes: appVersion.releaseNotes,
};
}
if (!canLaunch) {
return {
appKey,
displayName: appVersion.displayName,
installed: input.installed,
localVersion: input.currentVersion ?? null,
latestVersion: appVersion.latestVersion,
status: 'DISABLED' satisfies UpdateStatus,
action: 'UNAVAILABLE' satisfies UpdateAction,
updateRequired: false,
downloadUrl: appVersion.downloadUrl,
releaseNotes: appVersion.releaseNotes,
canLaunch: false,
};
}
const installed = input.installed;
const localVersion = normalizeVersion(input.currentVersion);
const hasUpdate = installed && compareVersions(localVersion, appVersion.latestVersion) < 0;
const status: UpdateStatus = !installed
? 'NOT_INSTALLED'
: hasUpdate
? 'UPDATE_AVAILABLE'
: 'READY';
const action: UpdateAction = status === 'NOT_INSTALLED'
? 'INSTALL'
: status === 'UPDATE_AVAILABLE'
? 'UPDATE'
: 'LAUNCH';
return {
appKey,
displayName: appVersion.displayName,
installed,
localVersion: localVersion ?? null,
latestVersion: appVersion.latestVersion,
minimumSupportedVersion: appVersion.minimumSupportedVersion,
status,
action,
updateRequired: status === 'UPDATE_AVAILABLE',
downloadUrl: appVersion.downloadUrl,
releaseNotes: appVersion.releaseNotes,
canLaunch: true,
};
}
async grantAppPermission(
appKey: string,
authUserId: string,
input: GrantAppPermissionDto,
accessToken?: string,
) {
const actor = await this.requireAppPermissionAdmin(accessToken);
const app = await this.prisma.launcherApp.findUnique({
where: { appKey },
select: { id: true, appKey: true },
});
if (!app) throw new NotFoundException('App not found');
const permission = await this.prisma.launcherUserApp.upsert({
where: {
authUserId_appId: {
authUserId,
appId: app.id,
},
},
update: {
canView: input.canView ?? true,
canLaunch: input.canLaunch ?? true,
assignedById: actor.id,
assignedAt: new Date(),
},
create: {
authUserId,
appId: app.id,
canView: input.canView ?? true,
canLaunch: input.canLaunch ?? true,
assignedById: actor.id,
},
select: {
id: true,
authUserId: true,
canView: true,
canLaunch: true,
assignedAt: true,
app: {
select: {
appKey: true,
displayName: true,
},
},
},
});
return { permission };
}
private async getAuthUserId(accessToken?: string): Promise<string | null> {
if (!accessToken) return null;
try {
const response = await this.authService.me(accessToken) as { user?: { id?: string } };
return response.user?.id ?? null;
} catch {
return null;
}
}
private async requireAppPermissionAdmin(accessToken?: string) {
if (!accessToken) throw new UnauthorizedException('Bearer token required');
const response = await this.authService.me(accessToken) as AuthMeResponse;
const user = response.user;
const roles = new Set(
[user?.role, ...(user?.roles ?? [])]
.filter(Boolean)
.map((role) => String(role).toUpperCase()),
);
if (!user?.id || ![...APP_PERMISSION_ADMIN_ROLES].some((role) => roles.has(role))) {
throw new ForbiddenException('App permission admin role required');
}
return { id: user.id };
}
private async canLaunchApp(authUserId: string, appKey: string): Promise<boolean> {
const permission = await this.prisma.launcherUserApp.findFirst({
where: {
authUserId,
canView: true,
canLaunch: true,
app: {
appKey,
isEnabled: true,
},
},
select: { id: true },
});
return Boolean(permission);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { IsBoolean, IsOptional } from 'class-validator';
export class GrantAppPermissionDto {
@IsOptional()
@IsBoolean()
canView?: boolean;
@IsOptional()
@IsBoolean()
canLaunch?: boolean;
}
+100
View File
@@ -0,0 +1,100 @@
import { Body, Controller, Delete, Get, Post, Put, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { AuthService } from './auth.service';
import { ChangePasswordDto } from './dto/change-password.dto';
import { HandoffDto } from './dto/handoff.dto';
import { LoginDto } from './dto/login.dto';
@Controller('api/auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('login')
login(
@Body() loginDto: LoginDto,
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
) {
return this.authService.login(
loginDto.email,
loginDto.password,
request.headers.cookie,
response,
);
}
@Post('refresh')
refresh(
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
) {
return this.authService.refresh(request.headers.cookie, response);
}
@Post('logout')
logout(
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
) {
return this.authService.logout(request.headers.cookie, response);
}
@Get('me')
me(@Req() request: Request) {
return this.authService.me(this.getBearerToken(request));
}
@Post('password/change')
changePassword(@Req() request: Request, @Body() body: ChangePasswordDto) {
return this.authService.changePassword(
this.getBearerToken(request),
body.currentPassword,
body.newPassword,
);
}
@Get('profile/avatar')
async getProfileAvatar(
@Req() request: Request,
@Res() response: Response,
) {
const result = await this.authService.getProfileAvatar(
this.getBearerToken(request),
);
response.setHeader('Content-Type', result.contentType);
response.setHeader('Cache-Control', 'no-store');
response.send(result.body);
}
@Put('profile/avatar')
async updateProfileAvatar(@Req() request: Request) {
const body = Buffer.isBuffer(request.body)
? request.body
: Buffer.from([]);
await this.authService.updateProfileAvatar(
this.getBearerToken(request),
request.headers['content-type'],
body,
);
return { ok: true };
}
@Delete('profile/avatar')
deleteProfileAvatar(@Req() request: Request) {
return this.authService.deleteProfileAvatar(this.getBearerToken(request));
}
@Post('handoff')
handoff(@Req() request: Request, @Body() body: HandoffDto) {
return this.authService.handoff(
this.getBearerToken(request),
body.audienceApp ?? 'platform-ide',
);
}
private getBearerToken(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
@Module({
imports: [ConfigModule],
controllers: [AuthController],
providers: [AuthService],
exports: [AuthService],
})
export class AuthModule {}
+297
View File
@@ -0,0 +1,297 @@
import {
BadRequestException,
BadGatewayException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { Response } from 'express';
export type AuthUser = {
id: string;
email: string;
name: string | null;
role: string;
roles?: string[];
};
type AuthTokenResponse = {
accessToken: string;
accessTokenExpiresIn?: number;
user: AuthUser;
};
@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 login(
email: string,
password: string,
cookieHeader: string | undefined,
response: Response,
) {
const authResponse = await this.request<AuthTokenResponse>('/auth/login', {
method: 'POST',
body: { email, password },
cookieHeader,
response,
unauthorizedMessage: 'Invalid email or password',
});
return this.frontendTokenResponse(authResponse);
}
async refresh(cookieHeader: string | undefined, response: Response) {
const authResponse = await this.request<AuthTokenResponse>(
'/auth/refresh',
{
method: 'POST',
cookieHeader,
response,
unauthorizedMessage: 'Invalid session',
},
);
return this.frontendTokenResponse(authResponse);
}
async logout(cookieHeader: string | undefined, response: Response) {
await this.request('/auth/logout', {
method: 'POST',
cookieHeader,
response,
ignoreAuthErrors: true,
});
return { ok: true };
}
async me(accessToken: string | undefined) {
return this.request('/auth/me', {
method: 'GET',
accessToken,
unauthorizedMessage: 'Invalid bearer token',
});
}
async handoff(accessToken: string | undefined, audienceApp: string) {
return this.request('/auth/handoff', {
method: 'POST',
accessToken,
body: { audienceApp },
unauthorizedMessage: 'Invalid bearer token',
});
}
async changePassword(
accessToken: string | undefined,
currentPassword: string,
newPassword: string,
) {
return this.request('/auth/password/change', {
method: 'POST',
accessToken,
body: { currentPassword, newPassword },
unauthorizedMessage: 'Invalid bearer token',
});
}
async getProfileAvatar(accessToken: string | undefined) {
return this.binaryRequest('/auth/profile/avatar', {
method: 'GET',
accessToken,
unauthorizedMessage: 'Invalid bearer token',
});
}
async updateProfileAvatar(
accessToken: string | undefined,
contentType: string | undefined,
body: Buffer,
) {
return this.binaryRequest('/auth/profile/avatar', {
method: 'PUT',
accessToken,
contentType,
body,
unauthorizedMessage: 'Invalid bearer token',
});
}
async deleteProfileAvatar(accessToken: string | undefined) {
await this.binaryRequest('/auth/profile/avatar', {
method: 'DELETE',
accessToken,
unauthorizedMessage: 'Invalid bearer token',
});
return { ok: true };
}
private frontendTokenResponse(authResponse: AuthTokenResponse) {
return {
accessToken: authResponse.accessToken,
accessTokenExpiresIn: authResponse.accessTokenExpiresIn,
tokenType: 'Bearer',
user: authResponse.user,
};
}
private async request<T = unknown>(
path: string,
options: {
method: 'GET' | 'POST';
body?: unknown;
accessToken?: string;
cookieHeader?: string;
response?: Response;
unauthorizedMessage?: string;
ignoreAuthErrors?: boolean;
},
): Promise<T> {
const headers: Record<string, string> = {};
if (options.body) headers['content-type'] = 'application/json';
if (options.accessToken) {
headers.authorization = `Bearer ${options.accessToken}`;
}
if (options.cookieHeader) {
headers.cookie = options.cookieHeader;
}
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');
}
this.forwardSetCookie(authResponse, options.response);
const payload: unknown = await authResponse.json().catch((): null => null);
if (authResponse.status === 401) {
if (options.ignoreAuthErrors) return { ok: true } as T;
throw new UnauthorizedException(
options.unauthorizedMessage ?? 'Unauthorized',
);
}
if (authResponse.status === 400) {
throw new BadRequestException(payload);
}
if (!authResponse.ok) {
throw new BadGatewayException({
message: 'Auth server request failed',
statusCode: authResponse.status,
error: payload,
});
}
return payload as T;
}
private async binaryRequest(
path: string,
options: {
method: 'GET' | 'PUT' | 'DELETE';
accessToken?: string;
contentType?: string;
body?: Buffer;
unauthorizedMessage?: string;
},
) {
const headers: Record<string, string> = {};
if (options.accessToken) headers.authorization = `Bearer ${options.accessToken}`;
if (options.contentType) headers['content-type'] = options.contentType;
const requestBody = options.body
? new Uint8Array(options.body).buffer
: undefined;
let authResponse: globalThis.Response;
try {
authResponse = await fetch(`${this.authServerUrl}${path}`, {
method: options.method,
headers,
body: requestBody,
});
} catch {
throw new BadGatewayException('Auth server is unavailable');
}
if (authResponse.status === 401) {
throw new UnauthorizedException(
options.unauthorizedMessage ?? 'Unauthorized',
);
}
if (!authResponse.ok) {
const payload: unknown = await authResponse.json().catch((): null => null);
if (authResponse.status === 404) {
throw new NotFoundException(payload);
}
if ([400, 413, 415].includes(authResponse.status)) {
throw new BadRequestException(payload);
}
throw new BadGatewayException({
message: 'Auth server request failed',
statusCode: authResponse.status,
error: payload,
});
}
return {
contentType: authResponse.headers.get('content-type') ?? 'application/octet-stream',
body: Buffer.from(await authResponse.arrayBuffer()),
};
}
private forwardSetCookie(
authResponse: globalThis.Response,
response?: Response,
) {
if (!response) return;
const cookies = this.getSetCookieHeaders(authResponse.headers);
for (const cookie of cookies) {
response.append('Set-Cookie', cookie);
}
}
private getSetCookieHeaders(headers: Headers): string[] {
const maybeHeaders = headers as unknown as {
getSetCookie?: unknown;
};
if (typeof maybeHeaders.getSetCookie === 'function') {
const cookies = maybeHeaders.getSetCookie.call(headers);
return Array.isArray(cookies)
? cookies.filter(
(cookie): cookie is string => typeof cookie === 'string',
)
: [];
}
return this.splitSetCookieHeader(headers.get('set-cookie'));
}
private splitSetCookieHeader(header: string | null): string[] {
if (!header) return [];
return header
.split(/,(?=\s*[^;,=]+=[^;,]+)/)
.map((cookie) => cookie.trim());
}
}
+11
View File
@@ -0,0 +1,11 @@
import { IsString, MinLength } from 'class-validator';
export class ChangePasswordDto {
@IsString()
@MinLength(1)
currentPassword: string;
@IsString()
@MinLength(8)
newPassword: string;
}
+9
View File
@@ -0,0 +1,9 @@
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class HandoffDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(120)
audienceApp?: string;
}
+10
View File
@@ -0,0 +1,10 @@
import { IsEmail, IsString, MinLength } from 'class-validator';
export class LoginDto {
@IsEmail()
email: string;
@IsString()
@MinLength(8)
password: string;
}
+47
View File
@@ -0,0 +1,47 @@
import { Body, Controller, Get, Param, Post, Query, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { CreateDownloadFileDto } from './dto/create-download-file.dto';
import { DownloadsService } from './downloads.service';
@Controller('api/downloads')
export class DownloadsController {
constructor(private readonly downloadsService: DownloadsService) {}
@Get()
list(
@Query('appKey') appKey = 'all',
@Query('contextKey') contextKey = 'general',
) {
return this.downloadsService.list(appKey, contextKey);
}
@Post()
create(@Body() body: CreateDownloadFileDto, @Req() request: Request) {
return this.downloadsService.create(body, this.getBearerToken(request));
}
@Get(':id/file')
async download(
@Param('id') id: string,
@Req() request: Request,
@Res() response: Response,
) {
const result = await this.downloadsService.download(id, {
accessToken: this.getBearerToken(request),
userAgent: request.headers['user-agent'],
ipAddress: request.ip,
});
response.setHeader('Content-Type', result.contentType);
response.setHeader('Content-Disposition', result.contentDisposition);
response.setHeader('Cache-Control', 'no-store');
if (result.contentLength) response.setHeader('Content-Length', result.contentLength);
response.send(result.body);
}
private getBearerToken(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PrismaModule } from '../prisma/prisma.module';
import { DownloadsController } from './downloads.controller';
import { DownloadsService } from './downloads.service';
@Module({
imports: [AuthModule, PrismaModule],
controllers: [DownloadsController],
providers: [DownloadsService],
})
export class DownloadsModule {}
+169
View File
@@ -0,0 +1,169 @@
import {
BadGatewayException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { AuthService } from '../auth/auth.service';
import { PrismaService } from '../prisma/prisma.service';
import { CreateDownloadFileDto } from './dto/create-download-file.dto';
type AuthMeResponse = {
user?: {
id: string;
};
};
type DownloadRequestMeta = {
accessToken?: string;
userAgent?: string;
ipAddress?: string;
};
@Injectable()
export class DownloadsService {
constructor(
private readonly authService: AuthService,
private readonly prisma: PrismaService,
) {}
async list(appKey: string, contextKey: string) {
const files = await this.prisma.downloadFile.findMany({
where: {
appKey: appKey.trim() || 'all',
contextKey: contextKey.trim() || 'general',
isActive: true,
},
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
});
return {
files: files.map((file) => ({
id: file.id,
appKey: file.appKey,
contextKey: file.contextKey,
title: file.title,
description: file.description,
fileName: file.fileName,
contentType: file.contentType ?? inferContentType(file.fileName, file.sourceUrl),
fileSizeBytes: file.fileSizeBytes?.toString() ?? null,
uploadedByAppKey: file.uploadedByAppKey,
sortOrder: file.sortOrder,
downloadUrl: `/api/downloads/${file.id}/file`,
createdAt: file.createdAt.toISOString(),
})),
};
}
async create(input: CreateDownloadFileDto, accessToken: string | undefined) {
if (!accessToken) throw new UnauthorizedException('Missing bearer token');
const authUserId = await this.getAuthUserId(accessToken);
const contentType = input.contentType?.trim() || inferContentType(input.fileName, input.sourceUrl);
const fileSizeBytes = await this.readRemoteFileSize(input.sourceUrl).catch(() => null);
const file = await this.prisma.downloadFile.create({
data: {
appKey: input.appKey.trim(),
contextKey: input.contextKey?.trim() || 'general',
title: input.title.trim(),
description: input.description?.trim() || null,
fileName: input.fileName.trim(),
sourceUrl: input.sourceUrl.trim(),
contentType,
fileSizeBytes,
uploadedByAppKey: input.uploadedByAppKey?.trim() || input.appKey.trim(),
uploadedByAuthId: authUserId,
sortOrder: input.sortOrder ?? 0,
isActive: input.isActive ?? true,
},
});
return {
file: {
...file,
fileSizeBytes: file.fileSizeBytes?.toString() ?? null,
},
};
}
async download(id: string, meta: DownloadRequestMeta) {
const file = await this.prisma.downloadFile.findFirst({
where: {
id,
isActive: true,
},
});
if (!file) throw new NotFoundException('Download file not found');
const remoteResponse = await fetch(file.sourceUrl).catch(() => null);
if (!remoteResponse) throw new BadGatewayException('Download source is unavailable');
if (!remoteResponse.ok) {
throw new BadGatewayException({
message: 'Download source request failed',
statusCode: remoteResponse.status,
});
}
const authUserId = meta.accessToken
? await this.getAuthUserId(meta.accessToken).catch(() => null)
: null;
await this.prisma.downloadEvent.create({
data: {
fileId: file.id,
authUserId,
appKey: file.appKey,
userAgent: meta.userAgent ?? null,
ipAddress: meta.ipAddress ?? null,
},
});
const contentType =
file.contentType ||
inferContentType(file.fileName, file.sourceUrl) ||
remoteResponse.headers.get('content-type') ||
'application/octet-stream';
const contentLength = remoteResponse.headers.get('content-length') ?? file.fileSizeBytes?.toString() ?? null;
return {
contentType,
contentDisposition: buildContentDisposition(file.fileName),
contentLength,
body: Buffer.from(await remoteResponse.arrayBuffer()),
};
}
private async getAuthUserId(accessToken: string): Promise<string> {
const response = (await this.authService.me(accessToken)) as AuthMeResponse;
if (!response.user?.id) throw new UnauthorizedException('Invalid bearer token');
return response.user.id;
}
private async readRemoteFileSize(sourceUrl: string): Promise<bigint | null> {
const response = await fetch(sourceUrl, { method: 'HEAD' });
if (!response.ok) return null;
const contentLength = response.headers.get('content-length');
return contentLength && /^\d+$/.test(contentLength) ? BigInt(contentLength) : null;
}
}
function inferContentType(fileName: string, sourceUrl: string): string | null {
const path = `${fileName || sourceUrl}`.split('?')[0]?.toLowerCase() ?? '';
if (path.endsWith('.pdf')) return 'application/pdf';
if (path.endsWith('.zip')) return 'application/zip';
if (path.endsWith('.json')) return 'application/json';
if (path.endsWith('.txt')) return 'text/plain; charset=utf-8';
if (path.endsWith('.png')) return 'image/png';
if (path.endsWith('.jpg') || path.endsWith('.jpeg')) return 'image/jpeg';
if (path.endsWith('.webp')) return 'image/webp';
return null;
}
function buildContentDisposition(fileName: string): string {
const fallback = fileName.replace(/[^\w. -]/g, '_') || 'download';
return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(fileName)}`;
}
@@ -0,0 +1,47 @@
import { IsBoolean, IsInt, IsOptional, IsString, IsUrl, MaxLength } from 'class-validator';
export class CreateDownloadFileDto {
@IsString()
@MaxLength(80)
appKey: string;
@IsOptional()
@IsString()
@MaxLength(120)
contextKey?: string;
@IsString()
@MaxLength(200)
title: string;
@IsOptional()
@IsString()
@MaxLength(1000)
description?: string;
@IsString()
@MaxLength(255)
fileName: string;
@IsUrl({ require_protocol: true })
@MaxLength(2000)
sourceUrl: string;
@IsOptional()
@IsString()
@MaxLength(120)
contentType?: string;
@IsOptional()
@IsString()
@MaxLength(80)
uploadedByAppKey?: string;
@IsOptional()
@IsInt()
sortOrder?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
+47
View File
@@ -0,0 +1,47 @@
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { raw } from 'express';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
const port = configService.get<number>('PORT', 4200);
const corsOrigin = configService.get<string>('CORS_ORIGIN', '*');
const configuredOrigins = corsOrigin
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
const isLocalDevelopmentOrigin = (origin: string): boolean => {
try {
const url = new URL(origin);
return ['localhost', '127.0.0.1'].includes(url.hostname);
} catch {
return false;
}
};
app.enableCors({
origin: (origin, callback) => {
if (!origin || corsOrigin === '*' || configuredOrigins.includes(origin) || isLocalDevelopmentOrigin(origin)) {
callback(null, true);
return;
}
callback(new Error(`CORS origin not allowed: ${origin}`));
},
credentials: true,
});
app.use('/api/auth/profile/avatar', raw({ type: ['image/webp', 'image/png', 'image/jpeg'], limit: '80kb' }));
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}),
);
await app.listen(port);
}
void bootstrap();
+17
View File
@@ -0,0 +1,17 @@
import { Controller, Get, Param } from '@nestjs/common';
import { NoticesService } from './notices.service';
@Controller('api/notices')
export class NoticesController {
constructor(private readonly noticesService: NoticesService) {}
@Get('login')
getLoginNotice() {
return this.noticesService.getLoginNotice();
}
@Get('apps/:appKey')
getAppNotices(@Param('appKey') appKey: string) {
return this.noticesService.getAppNotices(appKey);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { NoticesController } from './notices.controller';
import { NoticesService } from './notices.service';
@Module({
imports: [PrismaModule],
controllers: [NoticesController],
providers: [NoticesService],
})
export class NoticesModule {}
+73
View File
@@ -0,0 +1,73 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class NoticesService {
constructor(private readonly prisma: PrismaService) {}
async getLoginNotice() {
const now = new Date();
const notice = await this.prisma.launcherNotice.findFirst({
where: {
appKey: 'Launcher',
isActive: true,
OR: [
{ startsAt: null, endsAt: null },
{ startsAt: { lte: now }, endsAt: null },
{ startsAt: null, endsAt: { gte: now } },
{ startsAt: { lte: now }, endsAt: { gte: now } },
],
},
orderBy: [{ priority: 'desc' }, { createdAt: 'desc' }],
select: {
id: true,
title: true,
body: true,
linkUrl: true,
},
});
return { notice };
}
async getAppNotices(appKey: string) {
const now = new Date();
const notices = await this.prisma.launcherNotice.findMany({
where: {
appKey,
isActive: true,
OR: [
{ startsAt: null, endsAt: null },
{ startsAt: { lte: now }, endsAt: null },
{ startsAt: null, endsAt: { gte: now } },
{ startsAt: { lte: now }, endsAt: { gte: now } },
],
},
orderBy: [{ priority: 'desc' }, { createdAt: 'desc' }],
select: {
id: true,
title: true,
body: true,
linkUrl: true,
createdAt: true,
},
});
return {
notices: notices.map((notice) => ({
...notice,
date: formatNoticeDate(notice.createdAt),
})),
};
}
}
function formatNoticeDate(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}.${month}.${day}`;
}
@@ -0,0 +1,34 @@
import { IsInt, IsOptional, IsString, IsUrl, MaxLength } from 'class-validator';
export class CreateNotificationDto {
@IsString()
@MaxLength(80)
appKey: string;
@IsOptional()
@IsString()
@MaxLength(80)
audience?: string;
@IsOptional()
@IsString()
@MaxLength(80)
category?: string;
@IsString()
@MaxLength(200)
title: string;
@IsString()
@MaxLength(1000)
body: string;
@IsOptional()
@IsUrl({ require_protocol: true })
@MaxLength(2000)
linkUrl?: string;
@IsOptional()
@IsInt()
priority?: number;
}
@@ -0,0 +1,58 @@
import { Body, Controller, Get, Param, Post, Query, Req } from '@nestjs/common';
import type { Request } from 'express';
import { CreateNotificationDto } from './dto/create-notification.dto';
import { NotificationsService } from './notifications.service';
type ReadBody = {
notificationIds?: string[];
appKey?: string;
audience?: string;
query?: string;
};
@Controller('api/notifications')
export class NotificationsController {
constructor(private readonly notificationsService: NotificationsService) {}
@Get()
list(
@Query('appKey') appKey = 'all',
@Query('audience') audience = 'all',
@Query('page') page = '1',
@Query('pageSize') pageSize = '50',
@Query('query') query = '',
@Query('read') read = 'all',
@Req() request: Request,
) {
return this.notificationsService.list({
appKey,
audience,
page,
pageSize,
query,
read,
accessToken: this.getBearerToken(request),
});
}
@Post()
create(@Body() body: CreateNotificationDto, @Req() request: Request) {
return this.notificationsService.create(body, this.getBearerToken(request));
}
@Post(':id/read')
markRead(@Param('id') id: string, @Req() request: Request) {
return this.notificationsService.markRead([id], this.getBearerToken(request));
}
@Post('read-all')
markReadAll(@Body() body: ReadBody, @Req() request: Request) {
return this.notificationsService.markReadAll(body, this.getBearerToken(request));
}
private getBearerToken(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { PrismaModule } from '../prisma/prisma.module';
import { NotificationsController } from './notifications.controller';
import { NotificationsService } from './notifications.service';
@Module({
imports: [ConfigModule, PrismaModule],
controllers: [NotificationsController],
providers: [NotificationsService],
})
export class NotificationsModule {}
+288
View File
@@ -0,0 +1,288 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../prisma/prisma.service';
import { CreateNotificationDto } from './dto/create-notification.dto';
type AuthMeResponse = {
user?: {
id: string;
};
};
type ListInput = {
appKey: string;
audience: string;
page?: string;
pageSize?: string;
query?: string;
read?: string;
accessToken?: string;
};
type MarkAllReadInput = {
notificationIds?: string[];
appKey?: string;
audience?: string;
query?: string;
};
const NOTIFICATION_CATEGORY_GROUP = 'NOTIFICATION_CATEGORY';
@Injectable()
export class NotificationsService {
private readonly authServerUrl: string;
constructor(
private readonly configService: ConfigService,
private readonly prisma: PrismaService,
) {
this.authServerUrl = this.configService
.get<string>('AUTH_SERVER_URL', 'http://localhost:4100')
.replace(/\/$/, '');
}
async list(input: ListInput) {
const now = new Date();
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const appKey = input.appKey.trim() || 'all';
const audience = input.audience.trim() || 'all';
const page = Math.max(1, Number.parseInt(input.page ?? '1', 10) || 1);
const pageSize = Math.min(100, Math.max(1, Number.parseInt(input.pageSize ?? '50', 10) || 50));
const query = input.query?.trim() ?? '';
const readFilter = input.read === 'read' || input.read === 'unread' ? input.read : 'all';
const authUserId = input.accessToken ? await this.getAuthUserId(input.accessToken).catch(() => null) : null;
const notifications = await this.prisma.notification.findMany({
where: {
isActive: true,
appKey: { in: ['all', appKey] },
audience: { in: ['all', audience] },
createdAt: {
gte: oneDayAgo,
lte: now,
},
...(query
? {
OR: [{ title: { contains: query } }, { body: { contains: query } }, { category: { contains: query } }],
}
: {}),
AND: [
{
OR: [
{ startsAt: null, endsAt: null },
{ startsAt: { lte: now }, endsAt: null },
{ startsAt: null, endsAt: { gte: now } },
{ startsAt: { lte: now }, endsAt: { gte: now } },
],
},
],
},
orderBy: [{ priority: 'desc' }, { createdAt: 'desc' }],
select: {
id: true,
appKey: true,
audience: true,
category: true,
title: true,
body: true,
linkUrl: true,
createdAt: true,
reads: authUserId
? {
where: { authUserId },
select: { readAt: true },
take: 1,
}
: false,
},
});
const categoryCodes = [...new Set(notifications.map((notification) => notification.category))];
const commonCodes = categoryCodes.length
? await this.prisma.commonCode.findMany({
where: {
groupKey: NOTIFICATION_CATEGORY_GROUP,
code: { in: categoryCodes },
isActive: true,
},
select: {
code: true,
label: true,
},
})
: [];
const categoryLabelByCode = new Map(commonCodes.map((code) => [code.code, code.label]));
const mappedNotifications = notifications
.map((notification) => ({
id: notification.id,
appKey: notification.appKey,
audience: notification.audience,
category: notification.category,
categoryLabel: categoryLabelByCode.get(notification.category) ?? notification.category,
title: notification.title,
body: notification.body,
linkUrl: notification.linkUrl,
createdAt: notification.createdAt.toISOString(),
date: formatNotificationDate(notification.createdAt),
read: authUserId ? notification.reads.length > 0 : false,
}))
.filter((notification) => {
if (readFilter === 'read') return notification.read;
if (readFilter === 'unread') return !notification.read;
return true;
})
.sort((left, right) => {
if (left.read !== right.read) return left.read ? 1 : -1;
return new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime();
});
const total = mappedNotifications.length;
const unreadCount = mappedNotifications.filter((notification) => !notification.read).length;
const start = (page - 1) * pageSize;
return {
notifications: mappedNotifications.slice(start, start + pageSize),
total,
page,
pageSize,
unreadCount,
retentionHours: 24,
};
}
async create(input: CreateNotificationDto, accessToken: string | undefined) {
if (!accessToken) throw new UnauthorizedException('Missing bearer token');
await this.getAuthUserId(accessToken);
const notification = await this.prisma.notification.create({
data: {
appKey: input.appKey.trim(),
audience: input.audience?.trim() || 'all',
category: input.category?.trim() || 'notice',
title: input.title.trim(),
body: input.body.trim(),
linkUrl: input.linkUrl?.trim() || null,
priority: input.priority ?? 0,
isActive: true,
},
});
return {
notification: {
id: notification.id,
appKey: notification.appKey,
audience: notification.audience,
category: notification.category,
title: notification.title,
body: notification.body,
linkUrl: notification.linkUrl,
createdAt: notification.createdAt.toISOString(),
date: formatNotificationDate(notification.createdAt),
read: false,
},
};
}
async markRead(notificationIds: string[], accessToken: string | undefined) {
if (!notificationIds.length) return { ok: true, readCount: 0 };
if (!accessToken) throw new UnauthorizedException('Missing bearer token');
const authUserId = await this.getAuthUserId(accessToken);
const ids = [...new Set(notificationIds)];
await this.prisma.$transaction(
ids.map((notificationId) =>
this.prisma.notificationRead.upsert({
where: {
notificationId_authUserId: {
notificationId,
authUserId,
},
},
create: {
notificationId,
authUserId,
},
update: {
readAt: new Date(),
},
}),
),
);
return { ok: true, readCount: ids.length };
}
async markReadAll(input: MarkAllReadInput, accessToken: string | undefined) {
if (!accessToken) throw new UnauthorizedException('Missing bearer token');
if (input.notificationIds?.length) {
return this.markRead(input.notificationIds, accessToken);
}
const now = new Date();
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const appKey = input.appKey?.trim() || 'all';
const audience = input.audience?.trim() || 'all';
const query = input.query?.trim() ?? '';
const notifications = await this.prisma.notification.findMany({
where: {
isActive: true,
appKey: { in: ['all', appKey] },
audience: { in: ['all', audience] },
createdAt: {
gte: oneDayAgo,
lte: now,
},
...(query
? {
OR: [{ title: { contains: query } }, { body: { contains: query } }, { category: { contains: query } }],
}
: {}),
AND: [
{
OR: [
{ startsAt: null, endsAt: null },
{ startsAt: { lte: now }, endsAt: null },
{ startsAt: null, endsAt: { gte: now } },
{ startsAt: { lte: now }, endsAt: { gte: now } },
],
},
],
},
select: {
id: true,
},
});
return this.markRead(
notifications.map((notification) => notification.id),
accessToken,
);
}
private async getAuthUserId(accessToken: string): Promise<string> {
const response = await fetch(`${this.authServerUrl}/auth/me`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) throw new UnauthorizedException('Invalid bearer token');
const payload = (await response.json().catch(() => null)) as AuthMeResponse | null;
if (!payload?.user?.id) throw new UnauthorizedException('Invalid bearer token');
return payload.user.id;
}
}
function formatNotificationDate(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}.${month}.${day}`;
}
+8
View File
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
+16
View File
@@ -0,0 +1,16 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}
@@ -0,0 +1,98 @@
import { Type } from 'class-transformer';
import {
IsEmail,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
ValidateNested,
} from 'class-validator';
export class ProvisioningDeviceDto {
@IsString()
@IsOptional()
hostname?: string;
@IsString()
@IsOptional()
os?: string;
@IsString()
@IsOptional()
arch?: string;
@IsString()
@IsOptional()
kernel?: string;
@IsString()
@IsOptional()
distro?: string;
@IsString()
@IsOptional()
usageType?: string;
@IsString()
@IsOptional()
sshUser?: string;
@IsString()
@IsOptional()
workspaceRoot?: string;
@IsString()
@IsOptional()
publicSshKey?: string;
}
export class CreateProvisioningDto {
@IsEmail()
account: string;
@IsString()
@IsNotEmpty()
password: string;
@IsString()
@IsNotEmpty()
hostname: string;
@IsString()
@IsOptional()
os?: string;
@IsString()
@IsOptional()
arch?: string;
@IsString()
@IsOptional()
kernel?: string;
@IsString()
@IsOptional()
distro?: string;
@IsString()
@IsOptional()
usageType?: string;
@IsString()
@IsOptional()
sshUser?: string;
@IsString()
@IsOptional()
workspaceRoot?: string;
@IsString()
@IsOptional()
publicSshKey?: string;
@IsObject()
@ValidateNested()
@Type(() => ProvisioningDeviceDto)
@IsOptional()
device?: ProvisioningDeviceDto;
}
@@ -0,0 +1,36 @@
import { Body, Controller, Headers, Post, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { CreateProvisioningDto } from './dto/create-provisioning.dto';
import { ProvisioningService } from './provisioning.service';
import type { ProvisioningResponse } from './provisioning.service';
@Controller('api/provision')
export class ProvisioningController {
constructor(
private readonly provisioningService: ProvisioningService,
private readonly configService: ConfigService,
) {}
@Post()
createProvisioning(
@Body() createProvisioningDto: CreateProvisioningDto,
): Promise<ProvisioningResponse> {
return this.provisioningService.createProvisioning(createProvisioningDto);
}
@Post('ssh-keys/lookup')
listSshKeys(
@Headers('x-lms-gateway-token') token: string | undefined,
@Body() body: { emails?: string[] },
) {
const expectedToken =
this.configService.get<string>('LMS_GATEWAY_TOKEN') ??
this.configService.get<string>('PLATFORM_GATEWAY_TOKEN') ??
'';
if (!expectedToken || token !== expectedToken) {
throw new UnauthorizedException('invalid_gateway_token');
}
return this.provisioningService.listLauncherSshKeys(body.emails ?? []);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { ProvisioningController } from './provisioning.controller';
import { ProvisioningService } from './provisioning.service';
@Module({
imports: [PrismaModule],
controllers: [ProvisioningController],
providers: [ProvisioningService],
})
export class ProvisioningModule {}
+622
View File
@@ -0,0 +1,622 @@
import {
BadGatewayException,
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Prisma } from '@prisma/client';
import { createHash } from 'crypto';
import { PrismaService } from '../prisma/prisma.service';
import { CreateProvisioningDto } from './dto/create-provisioning.dto';
export type ProvisioningResponse = {
installTailscale: boolean;
tailscaleAuthKey: string;
tailscaleLoginServer: string;
tailscaleHostname: string;
tailscaleExtraArgs: string;
provisioningMode: 'personal' | 'class';
agentGatewayToken: string;
};
type AuthLoginResponse = {
user?: {
id: string;
email: string;
name?: string | null;
role?: string;
roles?: string[];
};
};
@Injectable()
export class ProvisioningService {
private readonly authServerUrl: string;
private readonly headscaleBaseUrl: string;
constructor(
private readonly configService: ConfigService,
private readonly prisma: PrismaService,
) {
this.authServerUrl = this.configService
.get<string>('AUTH_SERVER_URL', 'http://localhost:4100')
.replace(/\/$/, '');
this.headscaleBaseUrl = this.configService
.get<string>(
'HEADSCALE_BASE_URL',
this.configService.get<string>(
'TAILSCALE_LOGIN_SERVER',
'https://atnmy-hs.duckdns.org',
),
)
.replace(/\/$/, '');
}
async createProvisioning(
provisioningDto: CreateProvisioningDto,
): Promise<ProvisioningResponse> {
const authUser = await this.authenticateAccount(
provisioningDto.account,
provisioningDto.password,
);
const headscaleUser = this.toHeadscaleUserName(authUser.email);
const provisioningMode = this.normalizeProvisioningMode(
provisioningDto.usageType ?? provisioningDto.device?.usageType,
);
if (provisioningMode === 'class' && !this.isInstructor(authUser)) {
throw new ForbiddenException('Instructor role required for class device');
}
const aclTags =
provisioningMode === 'class' ? this.getClassDeviceAclTags() : [];
const tailscaleHostname = this.toTailscaleHostname(
provisioningDto.hostname || 'agent',
headscaleUser,
);
await this.recordProvisionedDevice(
provisioningDto,
authUser,
tailscaleHostname,
provisioningMode,
);
await this.recordLauncherSshKey(provisioningDto, authUser);
return {
installTailscale: true,
tailscaleAuthKey: await this.createTailscaleAuthKey(
headscaleUser,
aclTags,
),
tailscaleLoginServer: this.configService.get<string>(
'TAILSCALE_LOGIN_SERVER',
this.headscaleBaseUrl,
),
tailscaleHostname,
tailscaleExtraArgs: this.configService.get<string>(
'TAILSCALE_EXTRA_ARGS',
'--accept-routes',
),
provisioningMode,
agentGatewayToken: this.configService.get<string>(
'AGENT_GATEWAY_TOKEN',
'',
),
};
}
async listLauncherSshKeys(emails: string[]) {
const normalizedEmails = Array.from(
new Set(
emails
.map((email) => email.trim().toLowerCase())
.filter((email) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)),
),
);
if (!normalizedEmails.length) return { keysByEmail: {} };
const keys = await this.prisma.launcherSshKey.findMany({
where: { accountEmail: { in: normalizedEmails } },
orderBy: { updatedAt: 'desc' },
select: {
accountEmail: true,
publicKey: true,
keyType: true,
label: true,
updatedAt: true,
},
});
const keysByEmail: Record<string, Array<{
publicKey: string;
keyType: string | null;
label: string | null;
updatedAt: string;
}>> = {};
for (const key of keys) {
const bucket = keysByEmail[key.accountEmail] ?? [];
bucket.push({
publicKey: key.publicKey,
keyType: key.keyType,
label: key.label,
updatedAt: key.updatedAt.toISOString(),
});
keysByEmail[key.accountEmail] = bucket;
}
return { keysByEmail };
}
private async recordLauncherSshKey(
provisioningDto: CreateProvisioningDto,
authUser: NonNullable<AuthLoginResponse['user']>,
) {
const publicKey = this.normalizePublicSshKey(
provisioningDto.publicSshKey || provisioningDto.device?.publicSshKey,
);
if (!publicKey) return;
const publicKeyHash = createHash('sha256').update(publicKey).digest('hex');
const keyType = publicKey.split(/\s+/, 1)[0] || null;
await this.prisma.launcherSshKey.upsert({
where: {
accountEmail_publicKeyHash: {
accountEmail: authUser.email,
publicKeyHash,
},
},
create: {
accountEmail: authUser.email,
authUserId: authUser.id,
publicKey,
publicKeyHash,
keyType,
label: 'launcher',
lastProvisionedAt: new Date(),
},
update: {
authUserId: authUser.id,
publicKey,
keyType,
label: 'launcher',
lastProvisionedAt: new Date(),
},
});
}
private async recordProvisionedDevice(
provisioningDto: CreateProvisioningDto,
authUser: NonNullable<AuthLoginResponse['user']>,
tailscaleHostname: string,
provisioningMode: 'personal' | 'class',
) {
const hostname = this.cleanString(
provisioningDto.hostname || provisioningDto.device?.hostname || 'agent',
) ?? 'agent';
const sshUser = this.cleanString(
provisioningDto.sshUser || provisioningDto.device?.sshUser,
);
const workspaceRoot = this.cleanString(
provisioningDto.workspaceRoot || provisioningDto.device?.workspaceRoot,
);
const now = new Date();
await this.prisma.provisionedDevice.upsert({
where: {
accountEmail_tailscaleHostname: {
accountEmail: authUser.email,
tailscaleHostname,
},
},
create: {
accountEmail: authUser.email,
authUserId: authUser.id,
hostname,
tailscaleHostname,
sshUser,
workspaceRoot,
usageType: provisioningMode,
os: this.cleanString(provisioningDto.os || provisioningDto.device?.os),
arch: this.cleanString(
provisioningDto.arch || provisioningDto.device?.arch,
),
kernel: this.cleanString(
provisioningDto.kernel || provisioningDto.device?.kernel,
),
distro: this.cleanString(
provisioningDto.distro || provisioningDto.device?.distro,
),
device: this.cleanJson(provisioningDto.device),
installedAt: now,
lastProvisionedAt: now,
},
update: {
authUserId: authUser.id,
hostname,
sshUser,
workspaceRoot,
usageType: provisioningMode,
os: this.cleanString(provisioningDto.os || provisioningDto.device?.os),
arch: this.cleanString(
provisioningDto.arch || provisioningDto.device?.arch,
),
kernel: this.cleanString(
provisioningDto.kernel || provisioningDto.device?.kernel,
),
distro: this.cleanString(
provisioningDto.distro || provisioningDto.device?.distro,
),
device: this.cleanJson(provisioningDto.device),
lastProvisionedAt: now,
},
});
}
private cleanString(value?: string | null) {
const normalized = value?.trim();
return normalized || null;
}
private normalizePublicSshKey(value?: string | null) {
const normalized = value?.trim().replace(/\s+/g, ' ');
if (!normalized) return null;
if (!/^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp256|ecdsa-sha2-nistp384|ecdsa-sha2-nistp521)\s+[A-Za-z0-9+/=]+(?:\s+.*)?$/.test(normalized)) {
return null;
}
return normalized;
}
private cleanJson(value: unknown): Prisma.InputJsonValue | undefined {
if (value === undefined || value === null) return undefined;
return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
}
private async authenticateAccount(
email: string,
password: string,
): Promise<NonNullable<AuthLoginResponse['user']>> {
let response: globalThis.Response;
try {
response = await fetch(`${this.authServerUrl}/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, password }),
});
} catch {
throw new BadGatewayException('Auth server is unavailable');
}
const payload = (await response.json().catch(() => null)) as
| AuthLoginResponse
| null;
if (response.status === 401) {
throw new UnauthorizedException('Invalid account or password');
}
if (!response.ok || !payload?.user?.email) {
throw new BadGatewayException({
message: 'Auth server request failed',
statusCode: response.status,
error: payload,
});
}
return payload.user;
}
private async createTailscaleAuthKey(
headscaleUser: string,
aclTags: string[],
): Promise<string> {
const headscaleApiKey = this.configService.get<string>('HEADSCALE_API_KEY');
if (!headscaleApiKey) {
throw new BadGatewayException('HEADSCALE_API_KEY is not configured');
}
const headscaleUserId = await this.ensureHeadscaleUser(
headscaleUser,
headscaleApiKey,
);
return this.createHeadscalePreAuthKey(
headscaleUserId,
headscaleApiKey,
aclTags,
);
}
private async ensureHeadscaleUser(
userName: string,
apiKey: string,
): Promise<number> {
const response = await this.headscaleRequest('/api/v1/user', apiKey, {
method: 'POST',
body: { name: userName },
});
const payload = await this.readResponsePayload(response);
if (!response.ok) {
if (response.status === 409 || this.isAlreadyExistsResponse(payload)) {
return this.findHeadscaleUserId(userName, apiKey);
}
throw new BadGatewayException(
this.formatHeadscaleError(
'Headscale user provisioning failed',
response.status,
payload,
),
);
}
const createdUserId = this.findUserId(payload, userName);
return createdUserId ?? this.findHeadscaleUserId(userName, apiKey);
}
private async createHeadscalePreAuthKey(
userId: number,
apiKey: string,
aclTags: string[],
): Promise<string> {
const expiresInSeconds = this.configService.get<number>(
'TAILSCALE_PREAUTH_KEY_EXPIRATION_SECONDS',
60 * 60 * 24,
);
const expiration = new Date(
Date.now() + expiresInSeconds * 1000,
).toISOString();
const { response, payload } = await this.createHeadscalePreAuthKeyRequest(
userId,
apiKey,
expiration,
aclTags,
);
if (!response.ok) {
if (this.isAlreadyExistsResponse(payload)) {
const retry = await this.createHeadscalePreAuthKeyRequest(
userId,
apiKey,
new Date(Date.now() + (expiresInSeconds + 30) * 1000).toISOString(),
aclTags,
);
if (retry.response.ok) {
const retryKey = this.findAuthKey(retry.payload);
if (retryKey) return retryKey;
}
}
throw new BadGatewayException(
this.formatHeadscaleError(
'Headscale pre-auth key provisioning failed',
response.status,
payload,
),
);
}
const key = this.findAuthKey(payload);
if (!key) {
throw new BadGatewayException('Headscale response did not include a key');
}
return key;
}
private async createHeadscalePreAuthKeyRequest(
userId: number,
apiKey: string,
expiration: string,
aclTags: string[],
) {
const response = await this.headscaleRequest('/api/v1/preauthkey', apiKey, {
method: 'POST',
body: {
user: userId,
reusable: false,
ephemeral: false,
expiration,
aclTags,
},
});
return {
response,
payload: await this.readResponsePayload(response),
};
}
private async headscaleRequest(
path: string,
apiKey: string,
options: { method: 'GET' | 'POST'; body?: unknown },
) {
try {
return await fetch(`${this.headscaleBaseUrl}${path}`, {
method: options.method,
headers: {
authorization: `Bearer ${apiKey}`,
...(options.body ? { 'content-type': 'application/json' } : {}),
},
body: options.body ? JSON.stringify(options.body) : undefined,
});
} catch {
throw new BadGatewayException('Headscale server is unavailable');
}
}
private async readResponsePayload(response: Response): Promise<unknown> {
const text = await response.text().catch(() => '');
if (!text) return null;
try {
return JSON.parse(text) as unknown;
} catch {
return text;
}
}
private formatHeadscaleError(
message: string,
statusCode: number,
payload: unknown,
): string {
const detail = this.formatPayloadMessage(payload);
return detail
? `${message}: HTTP ${statusCode} ${detail}`
: `${message}: HTTP ${statusCode}`;
}
private formatPayloadMessage(payload: unknown): string {
if (!payload) return '';
if (typeof payload === 'string') return payload;
if (Array.isArray(payload)) {
return payload
.map((item) => this.formatPayloadMessage(item))
.filter(Boolean)
.join(', ');
}
if (typeof payload !== 'object') return String(payload);
const record = payload as Record<string, unknown>;
for (const key of ['message', 'error', 'details']) {
const message = this.formatPayloadMessage(record[key]);
if (message) return message;
}
try {
return JSON.stringify(payload);
} catch {
return '';
}
}
private async findHeadscaleUserId(
userName: string,
apiKey: string,
): Promise<number> {
const listResponse = await this.headscaleRequest('/api/v1/user', apiKey, {
method: 'GET',
});
const listPayload = (await listResponse.json().catch(() => null)) as unknown;
const listedUserId = this.findUserId(listPayload, userName);
if (listedUserId) return listedUserId;
const detailResponse = await this.headscaleRequest(
`/api/v1/user/${encodeURIComponent(userName)}`,
apiKey,
{ method: 'GET' },
);
const detailPayload = (await detailResponse
.json()
.catch(() => null)) as unknown;
const detailUserId = this.findUserId(detailPayload, userName);
if (detailUserId) return detailUserId;
throw new BadGatewayException({
message: 'Headscale user id was not found',
error: { user: userName },
});
}
private findAuthKey(payload: unknown): string | null {
if (!payload || typeof payload !== 'object') return null;
const record = payload as Record<string, unknown>;
if (typeof record.key === 'string') return record.key;
for (const value of Object.values(record)) {
const nestedKey = this.findAuthKey(value);
if (nestedKey) return nestedKey;
}
return null;
}
private isAlreadyExistsResponse(payload: unknown) {
const serialized =
typeof payload === 'string' ? payload : JSON.stringify(payload ?? {});
return /already exists|exists|duplicate|unique constraint/i.test(
serialized,
);
}
private findUserId(payload: unknown, userName: string): number | null {
if (!payload || typeof payload !== 'object') return null;
if (Array.isArray(payload)) {
for (const item of payload) {
const userId = this.findUserId(item, userName);
if (userId) return userId;
}
return null;
}
const record = payload as Record<string, unknown>;
const recordNames = ['name', 'username', 'email']
.map((key) => (typeof record[key] === 'string' ? String(record[key]) : ''))
.filter(Boolean);
const normalizedRecordNames = recordNames.map((name) =>
this.toHeadscaleUserName(name),
);
const normalizedUserName = this.toHeadscaleUserName(userName);
if (
normalizedRecordNames.includes(normalizedUserName) &&
(typeof record.id === 'number' || typeof record.id === 'string')
) {
const parsedId = Number(record.id);
return Number.isFinite(parsedId) && parsedId > 0 ? parsedId : null;
}
for (const value of Object.values(record)) {
const userId = this.findUserId(value, userName);
if (userId) return userId;
}
return null;
}
private toHeadscaleUserName(email: string) {
return email
.trim()
.toLowerCase()
.replace(/@/g, '-at-')
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 63);
}
private normalizeProvisioningMode(value?: string | null): 'personal' | 'class' {
const normalized = String(value ?? '').trim().toLowerCase();
return ['class', 'classroom', 'education', 'educational', 'training', 'use for class'].includes(normalized)
? 'class'
: 'personal';
}
private isInstructor(user: NonNullable<AuthLoginResponse['user']>) {
const roles = new Set(
[user.role, ...(user.roles ?? [])]
.filter(Boolean)
.map((role) => String(role).toUpperCase()),
);
return ['INSTRUCTOR', 'LMS_ADMIN', 'ADMIN'].some((role) =>
roles.has(role),
);
}
private getClassDeviceAclTags() {
return this.configService
.get<string>('HEADSCALE_CLASS_DEVICE_TAGS', '')
.split(',')
.map((tag) => tag.trim())
.filter(Boolean)
.map((tag) => (tag.startsWith('tag:') ? tag : `tag:${tag}`));
}
private toTailscaleHostname(hostname: string, headscaleUser: string) {
const normalizedHost = hostname
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
return `${headscaleUser}-${normalizedHost || 'agent'}`.slice(0, 63);
}
}
+29
View File
@@ -0,0 +1,29 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
afterEach(async () => {
await app.close();
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"incremental": false
},
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
}
}