From b9d9b9ed1717e2c79c3b7cb3241a2c2b9f896c2c Mon Sep 17 00:00:00 2001 From: Bonchellon Date: Fri, 6 Feb 2026 02:17:59 +0300 Subject: [PATCH] project init --- .env.example | 66 + .eslintrc.js | 25 + .gitignore | 44 + .prettierrc | 10 + README.md | 160 + apps/ai-service/package.json | 26 + apps/ai-service/src/index.ts | 108 + .../pipeline/course-generation.pipeline.ts | 289 + .../src/providers/openrouter.provider.ts | 393 + apps/ai-service/tsconfig.json | 9 + apps/api/nest-cli.json | 8 + apps/api/package.json | 57 + apps/api/src/app.module.ts | 46 + apps/api/src/auth/auth.controller.ts | 43 + apps/api/src/auth/auth.module.ts | 40 + apps/api/src/auth/auth.service.ts | 65 + .../auth/decorators/current-user.decorator.ts | 11 + .../src/auth/decorators/public.decorator.ts | 4 + apps/api/src/auth/dto/exchange-token.dto.ts | 12 + apps/api/src/auth/guards/jwt-auth.guard.ts | 83 + .../src/auth/guards/supabase-auth.guard.ts | 72 + apps/api/src/auth/strategies/jwt.strategy.ts | 62 + apps/api/src/auth/supabase.service.ts | 56 + apps/api/src/common/prisma/prisma.module.ts | 9 + apps/api/src/common/prisma/prisma.service.ts | 19 + apps/api/src/courses/chapters.controller.ts | 67 + apps/api/src/courses/chapters.service.ts | 131 + apps/api/src/courses/courses.controller.ts | 78 + apps/api/src/courses/courses.module.ts | 14 + apps/api/src/courses/courses.service.ts | 231 + .../api/src/courses/dto/create-chapter.dto.ts | 22 + apps/api/src/courses/dto/create-course.dto.ts | 25 + apps/api/src/courses/dto/create-lesson.dto.ts | 30 + .../api/src/courses/dto/update-chapter.dto.ts | 17 + apps/api/src/courses/dto/update-course.dto.ts | 71 + apps/api/src/courses/dto/update-lesson.dto.ts | 34 + apps/api/src/courses/lessons.controller.ts | 67 + apps/api/src/courses/lessons.service.ts | 141 + .../generation/dto/answer-questions.dto.ts | 15 + .../generation/dto/start-generation.dto.ts | 16 + .../src/generation/generation.controller.ts | 90 + apps/api/src/generation/generation.module.ts | 24 + apps/api/src/generation/generation.service.ts | 226 + apps/api/src/main.ts | 60 + apps/api/src/payments/payments.controller.ts | 41 + apps/api/src/payments/payments.module.ts | 12 + apps/api/src/payments/payments.service.ts | 231 + apps/api/src/payments/stripe.service.ts | 69 + apps/api/src/payments/webhooks.controller.ts | 52 + apps/api/src/search/meilisearch.service.ts | 156 + apps/api/src/search/search.controller.ts | 58 + apps/api/src/search/search.module.ts | 11 + apps/api/src/search/search.service.ts | 181 + apps/api/src/users/dto/create-user.dto.ts | 17 + apps/api/src/users/dto/update-settings.dto.ts | 35 + apps/api/src/users/dto/update-user.dto.ts | 14 + apps/api/src/users/users.controller.ts | 60 + apps/api/src/users/users.module.ts | 10 + apps/api/src/users/users.service.ts | 150 + apps/api/tsconfig.json | 18 + apps/web/next-env.d.ts | 5 + apps/web/next.config.js | 19 + apps/web/package.json | 68 + apps/web/postcss.config.js | 6 + apps/web/src/app/(auth)/layout.tsx | 13 + apps/web/src/app/(auth)/login/page.tsx | 133 + apps/web/src/app/(auth)/register/page.tsx | 179 + .../(dashboard)/dashboard/billing/page.tsx | 139 + .../dashboard/courses/[id]/edit/page.tsx | 218 + .../dashboard/courses/[id]/page.tsx | 235 + .../dashboard/courses/new/page.tsx | 512 + .../src/app/(dashboard)/dashboard/page.tsx | 139 + .../(dashboard)/dashboard/settings/page.tsx | 137 + apps/web/src/app/(dashboard)/layout.tsx | 18 + apps/web/src/app/globals.css | 106 + apps/web/src/app/layout.tsx | 45 + apps/web/src/app/page.tsx | 23 + .../src/components/dashboard/course-card.tsx | 199 + apps/web/src/components/dashboard/header.tsx | 95 + apps/web/src/components/dashboard/sidebar.tsx | 98 + .../src/components/editor/course-editor.tsx | 394 + .../editor/lesson-content-viewer.tsx | 70 + .../src/components/editor/lesson-sidebar.tsx | 118 + apps/web/src/components/landing/faq.tsx | 103 + apps/web/src/components/landing/features.tsx | 111 + apps/web/src/components/landing/footer.tsx | 99 + apps/web/src/components/landing/header.tsx | 144 + apps/web/src/components/landing/hero.tsx | 105 + .../src/components/landing/how-it-works.tsx | 82 + apps/web/src/components/landing/pricing.tsx | 108 + .../components/providers/theme-provider.tsx | 9 + apps/web/src/components/ui/alert-dialog.tsx | 126 + apps/web/src/components/ui/avatar.tsx | 50 + apps/web/src/components/ui/button.tsx | 50 + apps/web/src/components/ui/card.tsx | 56 + apps/web/src/components/ui/dropdown-menu.tsx | 187 + apps/web/src/components/ui/input.tsx | 24 + apps/web/src/components/ui/label.tsx | 21 + apps/web/src/components/ui/progress.tsx | 25 + apps/web/src/components/ui/toast.tsx | 124 + apps/web/src/components/ui/toaster.tsx | 33 + apps/web/src/components/ui/use-toast.ts | 188 + apps/web/src/contexts/auth-context.tsx | 160 + apps/web/src/lib/api.ts | 240 + apps/web/src/lib/supabase-server.ts | 32 + apps/web/src/lib/supabase.ts | 18 + apps/web/src/lib/utils.ts | 6 + apps/web/src/middleware.ts | 80 + apps/web/tailwind.config.ts | 88 + apps/web/tsconfig.json | 26 + docker/docker-compose.yml | 57 + docker/init-scripts/01-extensions.sql | 5 + instruct.md | 43 + package.json | 36 + packages/database/package.json | 27 + packages/database/prisma/schema.prisma | 362 + packages/database/src/index.ts | 36 + packages/database/tsconfig.json | 9 + packages/shared/package.json | 16 + packages/shared/src/constants/index.ts | 204 + packages/shared/src/index.ts | 4 + packages/shared/src/types/index.ts | 197 + packages/shared/src/utils/index.ts | 128 + packages/shared/tsconfig.json | 9 + pnpm-lock.yaml | 12147 ++++++++++++++++ pnpm-workspace.yaml | 3 + run.sh | 39 + tsconfig.base.json | 25 + turbo.json | 33 + 129 files changed, 22835 insertions(+) create mode 100644 .env.example create mode 100644 .eslintrc.js create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 README.md create mode 100644 apps/ai-service/package.json create mode 100644 apps/ai-service/src/index.ts create mode 100644 apps/ai-service/src/pipeline/course-generation.pipeline.ts create mode 100644 apps/ai-service/src/providers/openrouter.provider.ts create mode 100644 apps/ai-service/tsconfig.json create mode 100644 apps/api/nest-cli.json create mode 100644 apps/api/package.json create mode 100644 apps/api/src/app.module.ts create mode 100644 apps/api/src/auth/auth.controller.ts create mode 100644 apps/api/src/auth/auth.module.ts create mode 100644 apps/api/src/auth/auth.service.ts create mode 100644 apps/api/src/auth/decorators/current-user.decorator.ts create mode 100644 apps/api/src/auth/decorators/public.decorator.ts create mode 100644 apps/api/src/auth/dto/exchange-token.dto.ts create mode 100644 apps/api/src/auth/guards/jwt-auth.guard.ts create mode 100644 apps/api/src/auth/guards/supabase-auth.guard.ts create mode 100644 apps/api/src/auth/strategies/jwt.strategy.ts create mode 100644 apps/api/src/auth/supabase.service.ts create mode 100644 apps/api/src/common/prisma/prisma.module.ts create mode 100644 apps/api/src/common/prisma/prisma.service.ts create mode 100644 apps/api/src/courses/chapters.controller.ts create mode 100644 apps/api/src/courses/chapters.service.ts create mode 100644 apps/api/src/courses/courses.controller.ts create mode 100644 apps/api/src/courses/courses.module.ts create mode 100644 apps/api/src/courses/courses.service.ts create mode 100644 apps/api/src/courses/dto/create-chapter.dto.ts create mode 100644 apps/api/src/courses/dto/create-course.dto.ts create mode 100644 apps/api/src/courses/dto/create-lesson.dto.ts create mode 100644 apps/api/src/courses/dto/update-chapter.dto.ts create mode 100644 apps/api/src/courses/dto/update-course.dto.ts create mode 100644 apps/api/src/courses/dto/update-lesson.dto.ts create mode 100644 apps/api/src/courses/lessons.controller.ts create mode 100644 apps/api/src/courses/lessons.service.ts create mode 100644 apps/api/src/generation/dto/answer-questions.dto.ts create mode 100644 apps/api/src/generation/dto/start-generation.dto.ts create mode 100644 apps/api/src/generation/generation.controller.ts create mode 100644 apps/api/src/generation/generation.module.ts create mode 100644 apps/api/src/generation/generation.service.ts create mode 100644 apps/api/src/main.ts create mode 100644 apps/api/src/payments/payments.controller.ts create mode 100644 apps/api/src/payments/payments.module.ts create mode 100644 apps/api/src/payments/payments.service.ts create mode 100644 apps/api/src/payments/stripe.service.ts create mode 100644 apps/api/src/payments/webhooks.controller.ts create mode 100644 apps/api/src/search/meilisearch.service.ts create mode 100644 apps/api/src/search/search.controller.ts create mode 100644 apps/api/src/search/search.module.ts create mode 100644 apps/api/src/search/search.service.ts create mode 100644 apps/api/src/users/dto/create-user.dto.ts create mode 100644 apps/api/src/users/dto/update-settings.dto.ts create mode 100644 apps/api/src/users/dto/update-user.dto.ts create mode 100644 apps/api/src/users/users.controller.ts create mode 100644 apps/api/src/users/users.module.ts create mode 100644 apps/api/src/users/users.service.ts create mode 100644 apps/api/tsconfig.json create mode 100644 apps/web/next-env.d.ts create mode 100644 apps/web/next.config.js create mode 100644 apps/web/package.json create mode 100644 apps/web/postcss.config.js create mode 100644 apps/web/src/app/(auth)/layout.tsx create mode 100644 apps/web/src/app/(auth)/login/page.tsx create mode 100644 apps/web/src/app/(auth)/register/page.tsx create mode 100644 apps/web/src/app/(dashboard)/dashboard/billing/page.tsx create mode 100644 apps/web/src/app/(dashboard)/dashboard/courses/[id]/edit/page.tsx create mode 100644 apps/web/src/app/(dashboard)/dashboard/courses/[id]/page.tsx create mode 100644 apps/web/src/app/(dashboard)/dashboard/courses/new/page.tsx create mode 100644 apps/web/src/app/(dashboard)/dashboard/page.tsx create mode 100644 apps/web/src/app/(dashboard)/dashboard/settings/page.tsx create mode 100644 apps/web/src/app/(dashboard)/layout.tsx create mode 100644 apps/web/src/app/globals.css create mode 100644 apps/web/src/app/layout.tsx create mode 100644 apps/web/src/app/page.tsx create mode 100644 apps/web/src/components/dashboard/course-card.tsx create mode 100644 apps/web/src/components/dashboard/header.tsx create mode 100644 apps/web/src/components/dashboard/sidebar.tsx create mode 100644 apps/web/src/components/editor/course-editor.tsx create mode 100644 apps/web/src/components/editor/lesson-content-viewer.tsx create mode 100644 apps/web/src/components/editor/lesson-sidebar.tsx create mode 100644 apps/web/src/components/landing/faq.tsx create mode 100644 apps/web/src/components/landing/features.tsx create mode 100644 apps/web/src/components/landing/footer.tsx create mode 100644 apps/web/src/components/landing/header.tsx create mode 100644 apps/web/src/components/landing/hero.tsx create mode 100644 apps/web/src/components/landing/how-it-works.tsx create mode 100644 apps/web/src/components/landing/pricing.tsx create mode 100644 apps/web/src/components/providers/theme-provider.tsx create mode 100644 apps/web/src/components/ui/alert-dialog.tsx create mode 100644 apps/web/src/components/ui/avatar.tsx create mode 100644 apps/web/src/components/ui/button.tsx create mode 100644 apps/web/src/components/ui/card.tsx create mode 100644 apps/web/src/components/ui/dropdown-menu.tsx create mode 100644 apps/web/src/components/ui/input.tsx create mode 100644 apps/web/src/components/ui/label.tsx create mode 100644 apps/web/src/components/ui/progress.tsx create mode 100644 apps/web/src/components/ui/toast.tsx create mode 100644 apps/web/src/components/ui/toaster.tsx create mode 100644 apps/web/src/components/ui/use-toast.ts create mode 100644 apps/web/src/contexts/auth-context.tsx create mode 100644 apps/web/src/lib/api.ts create mode 100644 apps/web/src/lib/supabase-server.ts create mode 100644 apps/web/src/lib/supabase.ts create mode 100644 apps/web/src/lib/utils.ts create mode 100644 apps/web/src/middleware.ts create mode 100644 apps/web/tailwind.config.ts create mode 100644 apps/web/tsconfig.json create mode 100644 docker/docker-compose.yml create mode 100644 docker/init-scripts/01-extensions.sql create mode 100644 instruct.md create mode 100644 package.json create mode 100644 packages/database/package.json create mode 100644 packages/database/prisma/schema.prisma create mode 100644 packages/database/src/index.ts create mode 100644 packages/database/tsconfig.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/constants/index.ts create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/types/index.ts create mode 100644 packages/shared/src/utils/index.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 run.sh create mode 100644 tsconfig.base.json create mode 100644 turbo.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..228bbc5 --- /dev/null +++ b/.env.example @@ -0,0 +1,66 @@ +# =========================================== +# CourseCraft Environment Variables +# =========================================== +# +# ОБЯЗАТЕЛЬНО ДЛЯ ВХОДА И API (без них будет 401 на /api/courses): +# - NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY — один и тот же проект Supabase +# - SUPABASE_SERVICE_ROLE_KEY — из того же проекта (Settings → API → service_role secret) +# - JWT_SECRET — любая длинная строка (для подписи JWT на бэкенде) +# - NEXT_PUBLIC_API_URL — URL бэкенда (для фронта; если не задан, по умолчанию localhost:3001) +# +# =========================================== + +# Database +DATABASE_URL="postgresql://postgres:postgres@localhost:5432/coursecraft?schema=public" + +# Redis (порт 6395) +REDIS_URL="redis://localhost:6395" +REDIS_HOST="localhost" +REDIS_PORT="6395" + +# Supabase Auth (все три — из одного проекта Supabase!) +NEXT_PUBLIC_SUPABASE_URL="https://your-project.supabase.co" +NEXT_PUBLIC_SUPABASE_ANON_KEY="your-anon-key" +SUPABASE_SERVICE_ROLE_KEY="your-service-role-key" + +# OpenRouter AI +OPENROUTER_API_KEY="your-openrouter-api-key" +OPENROUTER_BASE_URL="https://openrouter.ai/api/v1" + +# Default AI model (used for all tiers; PRO users can override in settings) +AI_MODEL_DEFAULT="openai/gpt-4o-mini" + +# Testing: set to true to ignore monthly course limit (for local/dev only) +# BYPASS_COURSE_LIMIT="true" + +# Stripe Payments +STRIPE_SECRET_KEY="sk_test_..." +STRIPE_WEBHOOK_SECRET="whsec_..." +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..." + +# Stripe Price IDs +STRIPE_PRICE_PREMIUM="price_..." +STRIPE_PRICE_PRO="price_..." + +# Meilisearch +MEILISEARCH_HOST="http://localhost:7700" +MEILISEARCH_API_KEY="your-meilisearch-master-key" + +# S3 / Cloudflare R2 Storage +S3_ENDPOINT="https://your-account.r2.cloudflarestorage.com" +S3_ACCESS_KEY_ID="your-access-key" +S3_SECRET_ACCESS_KEY="your-secret-key" +S3_BUCKET_NAME="coursecraft" +S3_REGION="auto" + +# App URLs +NEXT_PUBLIC_APP_URL="http://localhost:3125" +NEXT_PUBLIC_API_URL="http://localhost:3001" +API_URL="http://localhost:3001" +AI_SERVICE_URL="http://localhost:3002" + +# JWT Secret — ОБЯЗАТЕЛЕН для API (подпись токенов после обмена с Supabase) +JWT_SECRET="your-super-secret-jwt-key-change-in-production" + +# Environment +NODE_ENV="development" diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..0069780 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,25 @@ +module.exports = { + root: true, + env: { + node: true, + es2022: true, + }, + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + }, + plugins: ['@typescript-eslint'], + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'prettier', + ], + rules: { + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-non-null-assertion': 'warn', + }, + ignorePatterns: ['node_modules', 'dist', '.next', '.turbo'], +}; diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..37cbff1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Dependencies +node_modules +.pnpm-store + +# Build outputs +dist +.next +.turbo +*.tsbuildinfo + +# Environment +.env +.env.local +.env.*.local + +# IDE +.vscode +.idea +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ +.start.pid +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Testing +coverage +.nyc_output + +# Misc +*.pem +.vercel + +# Docker +docker/data diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..b6b0fde --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100, + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..778f3ee --- /dev/null +++ b/README.md @@ -0,0 +1,160 @@ +# CourseCraft - AI-Powered Course Creation Platform + +CourseCraft - это платформа для создания образовательных курсов с помощью искусственного интеллекта. Просто опишите тему курса, и AI создаст полноценный курс с структурой, главами, уроками и контентом. + +## Возможности + +- **AI-генерация курсов** - создание курсов по текстовому описанию +- **Уточняющие вопросы** - AI задаёт вопросы для лучшего понимания потребностей +- **WYSIWYG редактор** - редактирование курсов с помощью TipTap +- **AI-режим редактирования** - переписывание текста с помощью нейросети +- **Система тарифов** - FREE, PREMIUM, PRO с разными лимитами +- **Настраиваемые AI модели** - возможность выбора модели через OpenRouter + +## Технологический стек + +### Frontend +- Next.js 14 (App Router) +- TypeScript +- TailwindCSS + shadcn/ui +- TipTap - WYSIWYG редактор +- Framer Motion - анимации + +### Backend +- NestJS +- PostgreSQL + Prisma +- Redis + BullMQ +- Supabase Auth +- Stripe - платежи + +### AI +- OpenRouter API +- Настраиваемые модели (GPT-4, Claude, Llama и др.) + +### Инфраструктура +- Docker Compose +- Meilisearch - полнотекстовый поиск +- pgvector - embeddings + +## Быстрый старт + +### Требования +- Node.js 20+ +- pnpm 9+ +- Docker и Docker Compose + +### Установка + +1. Клонируйте репозиторий: +```bash +git clone https://github.com/your-username/coursecraft.git +cd coursecraft +``` + +2. Установите зависимости: +```bash +pnpm install +``` + +3. Создайте файл `.env` из примера: +```bash +cp .env.example .env +``` + +4. Заполните переменные окружения в `.env`: +- `DATABASE_URL` - URL PostgreSQL +- `REDIS_URL` - URL Redis +- `NEXT_PUBLIC_SUPABASE_URL` - URL вашего Supabase проекта +- `NEXT_PUBLIC_SUPABASE_ANON_KEY` - Anon key Supabase +- `SUPABASE_SERVICE_ROLE_KEY` - Service role key Supabase +- `OPENROUTER_API_KEY` - API ключ OpenRouter +- `STRIPE_SECRET_KEY` - Secret key Stripe +- `STRIPE_WEBHOOK_SECRET` - Webhook secret Stripe +- `MEILISEARCH_API_KEY` - Master key Meilisearch + +5. Запустите Docker контейнеры: +```bash +pnpm docker:up +``` + +6. Примените миграции базы данных: +```bash +pnpm db:push +``` + +7. Запустите приложение в режиме разработки: +```bash +pnpm dev +``` + +Приложение будет доступно по адресам: +- Frontend: http://localhost:3000 +- API: http://localhost:3001 +- API Docs: http://localhost:3001/docs +- Meilisearch: http://localhost:7700 + +## Структура проекта + +``` +coursecraft/ +├── apps/ +│ ├── web/ # Next.js Frontend +│ ├── api/ # NestJS Backend +│ └── ai-service/ # AI Pipeline Service +├── packages/ +│ ├── database/ # Prisma схема +│ └── shared/ # Общие типы и утилиты +├── docker/ +│ └── docker-compose.yml +└── turbo.json +``` + +## Команды + +```bash +# Разработка +pnpm dev # Запуск всех сервисов +pnpm build # Сборка всех пакетов +pnpm lint # Проверка кода + +# База данных +pnpm db:generate # Генерация Prisma клиента +pnpm db:push # Применение схемы +pnpm db:migrate # Создание миграции +pnpm db:studio # Открыть Prisma Studio + +# Docker +pnpm docker:up # Запуск контейнеров +pnpm docker:down # Остановка контейнеров +pnpm docker:logs # Логи контейнеров +``` + +## Тарифные планы + +| План | Курсов/мес | AI Модель | Цена | +|------|-----------|-----------|------| +| Free | 2 | GPT-4o Mini | $0 | +| Premium | 5 | GPT-4o Mini | $9.99 | +| Pro | 15 | GPT-4o Mini (или своя в настройках) | $29.99 | + +На Pro плане можно указать любую модель OpenRouter в настройках. + +## Конфигурация AI моделей + +По умолчанию используется **openai/gpt-4o-mini**. На плане Pro в настройках можно указать свою модель OpenRouter, например: +``` +openai/gpt-4o-mini +anthropic/claude-3.5-sonnet +openai/gpt-4-turbo +``` + +Формат: `provider/model-name` + +## Лицензия + +MIT + +## Поддержка + +- Email: support@coursecraft.ai +- Docs: https://docs.coursecraft.ai diff --git a/apps/ai-service/package.json b/apps/ai-service/package.json new file mode 100644 index 0000000..bf17002 --- /dev/null +++ b/apps/ai-service/package.json @@ -0,0 +1,26 @@ +{ + "name": "@coursecraft/ai-service", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "tsc", + "clean": "rm -rf dist .turbo", + "dev": "ts-node-dev --respawn --transpile-only src/index.ts", + "start": "node dist/index.js", + "lint": "eslint src/**/*.ts --fix" + }, + "dependencies": { + "@coursecraft/database": "workspace:*", + "@coursecraft/shared": "workspace:*", + "bullmq": "^5.1.0", + "dotenv": "^16.4.0", + "ioredis": "^5.3.0", + "openai": "^4.28.0", + "zod": "^3.22.4" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "ts-node-dev": "^2.0.0", + "typescript": "^5.4.0" + } +} diff --git a/apps/ai-service/src/index.ts b/apps/ai-service/src/index.ts new file mode 100644 index 0000000..97df002 --- /dev/null +++ b/apps/ai-service/src/index.ts @@ -0,0 +1,108 @@ +import dotenv from 'dotenv'; +import path from 'path'; + +// Load .env from project root +dotenv.config({ path: path.resolve(__dirname, '../../../.env') }); +import { Worker, Job } from 'bullmq'; +import Redis from 'ioredis'; +import { CourseGenerationPipeline } from './pipeline/course-generation.pipeline'; +import { OpenRouterProvider } from './providers/openrouter.provider'; + +// Logger helper +const log = { + info: (msg: string) => console.log(`\x1b[36m[AI Service]\x1b[0m ${msg}`), + success: (msg: string) => console.log(`\x1b[32m[AI Service] ✓\x1b[0m ${msg}`), + error: (msg: string, error?: unknown) => console.error(`\x1b[31m[AI Service] ✗\x1b[0m ${msg}`, error || ''), + job: (action: string, job: Job) => { + console.log(`\x1b[33m[AI Service]\x1b[0m ${action}: ${job.name} (ID: ${job.id})`); + console.log(`\x1b[90m Data: ${JSON.stringify(job.data)}\x1b[0m`); + }, +}; + +// Check environment variables +log.info('Checking environment variables...'); +log.info(`REDIS_URL: ${process.env.REDIS_URL || 'redis://localhost:6395 (default)'}`); +log.info(`OPENROUTER_API_KEY: ${process.env.OPENROUTER_API_KEY ? 'SET' : 'NOT SET'}`); +log.info(`OPENROUTER_BASE_URL: ${process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1 (default)'}`); + +const redisConnection = new Redis(process.env.REDIS_URL || 'redis://localhost:6395', { + maxRetriesPerRequest: null, +}); + +redisConnection.on('connect', () => log.success('Connected to Redis')); +redisConnection.on('error', (err) => log.error('Redis connection error', err)); + +let openRouterProvider: OpenRouterProvider; +let pipeline: CourseGenerationPipeline; + +try { + openRouterProvider = new OpenRouterProvider(); + pipeline = new CourseGenerationPipeline(openRouterProvider); +} catch (error) { + log.error('Failed to initialize AI providers', error); + process.exit(1); +} + +// Worker for course generation +const worker = new Worker( + 'course-generation', + async (job) => { + log.job('Processing job', job); + const startTime = Date.now(); + + try { + let result; + if (job.name === 'generate-course') { + const { generationId, prompt, aiModel } = job.data; + result = await pipeline.generateCourse(generationId, prompt, aiModel); + } else if (job.name === 'continue-generation') { + const { generationId, stage } = job.data; + result = await pipeline.continueGeneration(generationId, stage); + } else { + log.error(`Unknown job name: ${job.name}`); + throw new Error(`Unknown job name: ${job.name}`); + } + + const duration = ((Date.now() - startTime) / 1000).toFixed(2); + log.success(`Job ${job.name} completed in ${duration}s`); + return result; + } catch (error) { + const duration = ((Date.now() - startTime) / 1000).toFixed(2); + log.error(`Job ${job.name} failed after ${duration}s`, error); + throw error; + } + }, + { + connection: redisConnection, + concurrency: 5, + } +); + +worker.on('completed', (job) => { + log.success(`Job ${job.id} completed successfully`); +}); + +worker.on('failed', (job, error) => { + log.error(`Job ${job?.id} failed: ${error.message}`); + if (error.stack) { + console.error(`\x1b[90m${error.stack}\x1b[0m`); + } +}); + +worker.on('error', (error) => { + log.error('Worker error', error); +}); + +console.log(''); +console.log('🤖 ═══════════════════════════════════════════════════════'); +console.log('🤖 AI Service started and listening for jobs...'); +console.log('🤖 ═══════════════════════════════════════════════════════'); +console.log(''); + +// Graceful shutdown +process.on('SIGTERM', async () => { + log.info('Shutting down AI Service...'); + await worker.close(); + await redisConnection.quit(); + process.exit(0); +}); diff --git a/apps/ai-service/src/pipeline/course-generation.pipeline.ts b/apps/ai-service/src/pipeline/course-generation.pipeline.ts new file mode 100644 index 0000000..ddb6f01 --- /dev/null +++ b/apps/ai-service/src/pipeline/course-generation.pipeline.ts @@ -0,0 +1,289 @@ +import { prisma, GenerationStatus, CourseStatus } from '@coursecraft/database'; +import { generateUniqueSlug } from '@coursecraft/shared'; +import { OpenRouterProvider, CourseOutline } from '../providers/openrouter.provider'; + +// Logger helper +const log = { + step: (step: string, msg: string) => { + console.log(`\x1b[35m[Pipeline]\x1b[0m \x1b[1m${step}\x1b[0m - ${msg}`); + }, + info: (msg: string, data?: unknown) => { + console.log(`\x1b[35m[Pipeline]\x1b[0m ${msg}`, data ? JSON.stringify(data, null, 2) : ''); + }, + success: (msg: string) => { + console.log(`\x1b[32m[Pipeline] ✓\x1b[0m ${msg}`); + }, + error: (msg: string, error?: unknown) => { + console.error(`\x1b[31m[Pipeline] ✗\x1b[0m ${msg}`, error); + }, + progress: (generationId: string, progress: number, step: string) => { + console.log(`\x1b[34m[Pipeline]\x1b[0m [${generationId.substring(0, 8)}...] ${progress}% - ${step}`); + }, +}; + +export class CourseGenerationPipeline { + constructor(private aiProvider: OpenRouterProvider) { + log.info('CourseGenerationPipeline initialized'); + } + + async generateCourse( + generationId: string, + prompt: string, + aiModel: string + ): Promise { + log.step('START', `Beginning course generation`); + log.info(`Generation ID: ${generationId}`); + log.info(`AI Model: ${aiModel}`); + log.info(`Prompt: "${prompt}"`); + + try { + // Step 1: Update status to analyzing + log.step('STEP 1', 'Analyzing request...'); + await this.updateProgress(generationId, GenerationStatus.ANALYZING, 10, 'Analyzing your request...'); + + // Step 2: Generate clarifying questions + log.step('STEP 2', 'Generating clarifying questions...'); + await this.updateProgress(generationId, GenerationStatus.ASKING_QUESTIONS, 15, 'Generating questions...'); + + const questionsResult = await this.aiProvider.generateClarifyingQuestions(prompt, aiModel); + log.success(`Generated ${questionsResult.questions.length} clarifying questions`); + + // Step 3: Wait for user answers + log.step('STEP 3', 'Waiting for user answers...'); + await prisma.courseGeneration.update({ + where: { id: generationId }, + data: { + status: GenerationStatus.WAITING_FOR_ANSWERS, + progress: 20, + currentStep: 'Waiting for your answers...', + questions: questionsResult.questions as unknown as Record, + }, + }); + + log.success('Generation paused - waiting for user answers'); + // Job will be continued when user answers via continue-generation + } catch (error) { + log.error('Course generation failed', error); + await this.updateProgress( + generationId, + GenerationStatus.FAILED, + 0, + 'Generation failed', + { errorMessage: error instanceof Error ? error.message : 'Unknown error' } + ); + throw error; + } + } + + async continueGeneration(generationId: string, stage: string): Promise { + log.step('CONTINUE', `Continuing generation at stage: ${stage}`); + log.info(`Generation ID: ${generationId}`); + + const generation = await prisma.courseGeneration.findUnique({ + where: { id: generationId }, + }); + + if (!generation) { + log.error('Generation not found'); + throw new Error('Generation not found'); + } + + log.info(`Current status: ${generation.status}`); + log.info(`AI Model: ${generation.aiModel}`); + + try { + if (stage === 'after-questions') { + await this.continueAfterQuestions(generation); + } + } catch (error) { + log.error('Continue generation failed', error); + await this.updateProgress( + generationId, + GenerationStatus.FAILED, + 0, + 'Generation failed', + { errorMessage: error instanceof Error ? error.message : 'Unknown error' } + ); + throw error; + } + } + + private async continueAfterQuestions(generation: { + id: string; + userId: string; + initialPrompt: string; + aiModel: string; + answers: unknown; + }): Promise { + const { id: generationId, userId, initialPrompt, aiModel, answers } = generation; + + log.info('User answers received:', answers); + + // Step 4: Research (simulated - in production would use web search) + log.step('STEP 4', 'Researching the topic...'); + await this.updateProgress(generationId, GenerationStatus.RESEARCHING, 30, 'Researching the topic...'); + await this.delay(2000); // Simulate research time + log.success('Research completed'); + + // Step 5: Generate outline + log.step('STEP 5', 'Generating course outline...'); + await this.updateProgress(generationId, GenerationStatus.GENERATING_OUTLINE, 50, 'Creating course structure...'); + + const outline = await this.aiProvider.generateCourseOutline( + initialPrompt, + (answers as Record) || {}, + aiModel + ); + + log.success(`Course outline generated: "${outline.title}"`); + log.info(`Chapters: ${outline.chapters.length}`); + + await prisma.courseGeneration.update({ + where: { id: generationId }, + data: { + generatedOutline: outline as unknown as Record, + progress: 55, + }, + }); + + // Step 6: Generate content + log.step('STEP 6', 'Generating course content...'); + await this.updateProgress(generationId, GenerationStatus.GENERATING_CONTENT, 60, 'Writing course content...'); + + // Create course structure first + log.info('Creating course structure in database...'); + const course = await this.createCourseFromOutline(userId, outline); + log.success(`Course created with ID: ${course.id}`); + + // Link course to generation + await prisma.courseGeneration.update({ + where: { id: generationId }, + data: { courseId: course.id }, + }); + + // Generate content for each lesson + const totalLessons = outline.chapters.reduce((acc, ch) => acc + ch.lessons.length, 0); + let processedLessons = 0; + + log.info(`Total lessons to generate: ${totalLessons}`); + + for (const chapter of course.chapters) { + log.info(`Processing chapter: "${chapter.title}" (${chapter.lessons.length} lessons)`); + + for (const lesson of chapter.lessons) { + const progress = 60 + Math.floor((processedLessons / totalLessons) * 35); + log.progress(generationId, progress, `Writing: ${lesson.title}`); + + await this.updateProgress( + generationId, + GenerationStatus.GENERATING_CONTENT, + progress, + `Writing: ${lesson.title}...` + ); + + // Generate lesson content + const lessonContent = await this.aiProvider.generateLessonContent( + course.title, + chapter.title, + lesson.title, + { + difficulty: outline.difficulty, + }, + aiModel + ); + + // Update lesson with generated content + await prisma.lesson.update({ + where: { id: lesson.id }, + data: { + content: lessonContent.content as unknown as Record, + }, + }); + + processedLessons++; + log.success(`Lesson ${processedLessons}/${totalLessons} completed: "${lesson.title}"`); + } + } + + // Increment user's course count + log.info('Updating user subscription...'); + await prisma.subscription.update({ + where: { userId }, + data: { + coursesCreatedThisMonth: { + increment: 1, + }, + }, + }); + + // Step 7: Complete + log.step('COMPLETE', 'Course generation finished!'); + await this.updateProgress(generationId, GenerationStatus.COMPLETED, 100, 'Course completed!'); + log.success(`Course "${course.title}" is ready!`); + } + + private async createCourseFromOutline(userId: string, outline: CourseOutline) { + const slug = generateUniqueSlug(outline.title); + + return prisma.course.create({ + data: { + authorId: userId, + title: outline.title, + description: outline.description, + slug, + status: CourseStatus.DRAFT, + difficulty: outline.difficulty, + estimatedHours: outline.estimatedTotalHours, + tags: outline.tags, + chapters: { + create: outline.chapters.map((chapter, chapterIndex) => ({ + title: chapter.title, + description: chapter.description, + order: chapterIndex, + lessons: { + create: chapter.lessons.map((lesson, lessonIndex) => ({ + title: lesson.title, + order: lessonIndex, + durationMinutes: lesson.estimatedMinutes, + content: null, // Will be filled later + })), + }, + })), + }, + }, + include: { + chapters: { + include: { + lessons: { + orderBy: { order: 'asc' }, + }, + }, + orderBy: { order: 'asc' }, + }, + }, + }); + } + + private async updateProgress( + generationId: string, + status: GenerationStatus, + progress: number, + currentStep: string, + additionalData?: Record + ): Promise { + await prisma.courseGeneration.update({ + where: { id: generationId }, + data: { + status, + progress, + currentStep, + ...(additionalData?.errorMessage && { errorMessage: additionalData.errorMessage as string }), + ...(status === GenerationStatus.COMPLETED && { completedAt: new Date() }), + }, + }); + } + + private delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/apps/ai-service/src/providers/openrouter.provider.ts b/apps/ai-service/src/providers/openrouter.provider.ts new file mode 100644 index 0000000..a67e03b --- /dev/null +++ b/apps/ai-service/src/providers/openrouter.provider.ts @@ -0,0 +1,393 @@ +import OpenAI from 'openai'; +import { z } from 'zod'; + +// Logger helper +const log = { + info: (msg: string, data?: unknown) => { + console.log(`\x1b[36m[AI Provider]\x1b[0m ${msg}`, data ? JSON.stringify(data, null, 2) : ''); + }, + success: (msg: string, data?: unknown) => { + console.log(`\x1b[32m[AI Provider] ✓\x1b[0m ${msg}`, data ? JSON.stringify(data, null, 2) : ''); + }, + error: (msg: string, error?: unknown) => { + console.error(`\x1b[31m[AI Provider] ✗\x1b[0m ${msg}`, error); + }, + debug: (msg: string, data?: unknown) => { + if (process.env.DEBUG === 'true') { + console.log(`\x1b[90m[AI Provider DEBUG]\x1b[0m ${msg}`, data ? JSON.stringify(data, null, 2) : ''); + } + }, + request: (method: string, model: string) => { + console.log(`\x1b[33m[AI Provider] →\x1b[0m Calling ${method} with model: ${model}`); + }, + response: (method: string, tokens?: { prompt?: number; completion?: number }) => { + const tokenInfo = tokens ? ` (tokens: ${tokens.prompt || '?'}/${tokens.completion || '?'})` : ''; + console.log(`\x1b[33m[AI Provider] ←\x1b[0m ${method} completed${tokenInfo}`); + }, +}; + +// Course outline schema for structured output +const CourseOutlineSchema = z.object({ + title: z.string(), + description: z.string(), + chapters: z.array( + z.object({ + title: z.string(), + description: z.string(), + lessons: z.array( + z.object({ + title: z.string(), + estimatedMinutes: z.number(), + }) + ), + }) + ), + estimatedTotalHours: z.number(), + difficulty: z.enum(['beginner', 'intermediate', 'advanced']), + tags: z.array(z.string()), +}); + +const ClarifyingQuestionItemSchema = z.object({ + id: z.string().optional(), + question: z.string().optional(), + text: z.string().optional(), + type: z.enum(['single_choice', 'multiple_choice', 'text']).optional(), + options: z.array(z.string()).optional(), + required: z.boolean().optional(), +}).refine((q) => typeof (q.question ?? q.text) === 'string', { message: 'question or text required' }); + +const ClarifyingQuestionsSchema = z.object({ + questions: z.array(ClarifyingQuestionItemSchema).transform((items) => + items.map((q, i) => ({ + id: q.id ?? `q_${i}`, + question: (q.question ?? q.text ?? '').trim() || `Вопрос ${i + 1}`, + type: (q.type ?? 'text') as 'single_choice' | 'multiple_choice' | 'text', + options: q.options, + required: q.required ?? true, + })) + ), +}); + +const LessonContentSchema = z.object({ + content: z.object({ + type: z.literal('doc'), + content: z.array(z.any()), + }), +}); + +export type CourseOutline = z.infer; +export type ClarifyingQuestions = z.infer; +export type LessonContent = z.infer; + +export class OpenRouterProvider { + private client: OpenAI; + private maxRetries = 3; + private retryDelay = 2000; + + constructor() { + const apiKey = process.env.OPENROUTER_API_KEY; + const baseURL = process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1'; + + log.info('Initializing OpenRouter provider...'); + log.info(`Base URL: ${baseURL}`); + log.info(`API Key: ${apiKey ? apiKey.substring(0, 15) + '...' : 'NOT SET'}`); + + if (!apiKey) { + log.error('OPENROUTER_API_KEY environment variable is required'); + throw new Error('OPENROUTER_API_KEY environment variable is required'); + } + + this.client = new OpenAI({ + baseURL, + apiKey, + timeout: 120000, // 2 minute timeout + maxRetries: 3, + defaultHeaders: { + 'HTTP-Referer': process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000', + 'X-Title': 'CourseCraft', + }, + }); + + log.success('OpenRouter provider initialized'); + } + + private async withRetry(fn: () => Promise, operation: string): Promise { + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= this.maxRetries; attempt++) { + try { + return await fn(); + } catch (error: any) { + lastError = error; + log.error(`${operation} attempt ${attempt}/${this.maxRetries} failed: ${error.message}`); + + if (attempt < this.maxRetries) { + const delay = this.retryDelay * attempt; + log.info(`Retrying in ${delay}ms...`); + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + } + + throw lastError; + } + + async generateClarifyingQuestions( + prompt: string, + model: string + ): Promise { + log.request('generateClarifyingQuestions', model); + log.info(`User prompt: "${prompt.substring(0, 100)}${prompt.length > 100 ? '...' : ''}"`); + + const systemPrompt = `Ты - эксперт по созданию образовательных курсов. +Пользователь хочет создать курс. Твоя задача - задать уточняющие вопросы, +чтобы лучше понять его потребности и создать максимально релевантный курс. + +Сгенерируй 3-5 вопросов. Обязательно включи вопрос про объём курса: +- Короткий (2-4 главы, введение в тему) +- Средний (4-7 глав, хорошее покрытие) +- Длинный / полный (6-12 глав, глубокое погружение) + +Остальные вопросы: целевая аудитория, глубина материала, специфические темы. + +Ответь в формате JSON.`; + + return this.withRetry(async () => { + const response = await this.client.chat.completions.create({ + model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: `Запрос пользователя: "${prompt}"` }, + ], + response_format: { type: 'json_object' }, + temperature: 0.7, + }); + + log.response('generateClarifyingQuestions', { + prompt: response.usage?.prompt_tokens, + completion: response.usage?.completion_tokens, + }); + + const content = response.choices[0].message.content; + log.debug('Raw AI response:', content); + + if (!content) { + log.error('Empty response from AI'); + throw new Error('Empty response from AI'); + } + + const parsed = JSON.parse(content); + const validated = ClarifyingQuestionsSchema.parse(parsed); + + log.success(`Generated ${validated.questions.length} clarifying questions`); + log.info('Questions:', validated.questions.map(q => q.question)); + + return validated; + }, 'generateClarifyingQuestions'); + } + + async generateCourseOutline( + prompt: string, + answers: Record, + model: string + ): Promise { + log.request('generateCourseOutline', model); + log.info(`Prompt: "${prompt.substring(0, 100)}${prompt.length > 100 ? '...' : ''}"`); + log.info('User answers:', answers); + + const systemPrompt = `Ты - эксперт по созданию образовательных курсов. +Создай структуру курса на основе запроса пользователя и его ответов на уточняющие вопросы. + +ОБЪЁМ КУРСА (соблюдай строго по ответам пользователя): +- Если пользователь выбрал короткий курс / введение: 2–4 главы, в каждой 2–4 урока. estimatedTotalHours: 2–8. +- Если средний курс: 4–7 глав, в каждой 3–5 уроков. estimatedTotalHours: 8–20. +- Если длинный / полный курс: 6–12 глав, в каждой 4–8 уроков. estimatedTotalHours: 15–40. +- Если объём не указан — предложи средний (4–6 глав по 3–5 уроков). + +Укажи примерное время на каждый урок (estimatedMinutes: 10–45). estimatedTotalHours = сумма уроков. +Определи сложность (beginner|intermediate|advanced). Добавь релевантные теги. + +Ответь в формате JSON со структурой: +{ + "title": "Название курса", + "description": "Подробное описание курса", + "chapters": [ + { + "title": "Название главы", + "description": "Описание главы", + "lessons": [ + { "title": "Название урока", "estimatedMinutes": 25 } + ] + } + ], + "estimatedTotalHours": 20, + "difficulty": "beginner|intermediate|advanced", + "tags": ["тег1", "тег2"] +}`; + + const userMessage = `Запрос: "${prompt}" + +Ответы пользователя на уточняющие вопросы: +${Object.entries(answers) + .map(([key, value]) => `- ${key}: ${Array.isArray(value) ? value.join(', ') : value}`) + .join('\n')}`; + + return this.withRetry(async () => { + const response = await this.client.chat.completions.create({ + model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userMessage }, + ], + response_format: { type: 'json_object' }, + temperature: 0.7, + }); + + log.response('generateCourseOutline', { + prompt: response.usage?.prompt_tokens, + completion: response.usage?.completion_tokens, + }); + + const content = response.choices[0].message.content; + log.debug('Raw AI response:', content); + + if (!content) { + log.error('Empty response from AI'); + throw new Error('Empty response from AI'); + } + + const parsed = JSON.parse(content); + const validated = CourseOutlineSchema.parse(parsed); + + const totalLessons = validated.chapters.reduce((acc, ch) => acc + ch.lessons.length, 0); + log.success(`Generated course outline: "${validated.title}"`); + log.info(`Structure: ${validated.chapters.length} chapters, ${totalLessons} lessons`); + log.info(`Difficulty: ${validated.difficulty}, Est. hours: ${validated.estimatedTotalHours}`); + + return validated; + }, 'generateCourseOutline'); + } + + async generateLessonContent( + courseTitle: string, + chapterTitle: string, + lessonTitle: string, + context: { + difficulty: string; + targetAudience?: string; + }, + model: string + ): Promise { + log.request('generateLessonContent', model); + log.info(`Generating content for: "${lessonTitle}" (Chapter: "${chapterTitle}")`); + + const systemPrompt = `Ты - эксперт по созданию образовательного контента. +Пиши содержание урока СРАЗУ в форматированном виде — в формате TipTap JSON. Каждый блок контента должен быть правильным узлом TipTap (heading, paragraph, bulletList, orderedList, blockquote, codeBlock, image). + +ФОРМАТИРОВАНИЕ (используй обязательно): +- Заголовки: { "type": "heading", "attrs": { "level": 1|2|3 }, "content": [{ "type": "text", "text": "..." }] } +- Параграфы: { "type": "paragraph", "content": [{ "type": "text", "text": "..." }] } — для выделения используй "marks": [{ "type": "bold" }] или [{ "type": "italic" }] +- Списки: bulletList > listItem > paragraph; orderedList > listItem > paragraph +- Цитаты: { "type": "blockquote", "content": [{ "type": "paragraph", "content": [...] }] } +- Код: { "type": "codeBlock", "attrs": { "language": "javascript"|"python"|"text" }, "content": [{ "type": "text", "text": "код" }] } +- Mermaid-диаграммы: { "type": "codeBlock", "attrs": { "language": "mermaid" }, "content": [{ "type": "text", "text": "graph LR\\n A --> B" }] } — вставляй где уместно (схемы, процессы, связи) +- Картинки не генерируй (src нельзя выдумывать); если нужна иллюстрация — опиши в тексте или предложи место для изображения + +ОБЪЁМ: зависит от темы. Короткий урок: 300–600 слов (3–5 блоков). Средний: 600–1200 слов. Длинный: 1200–2500 слов. Структура: заголовок, введение, 2–4 секции с подзаголовками, примеры/код/списки, резюме. + +Уровень: ${context.difficulty}. ${context.targetAudience ? `Аудитория: ${context.targetAudience}` : ''} + +Ответь только валидным JSON: +{ + "content": { + "type": "doc", + "content": [ + { "type": "heading", "attrs": { "level": 1 }, "content": [{ "type": "text", "text": "Заголовок" }] }, + { "type": "paragraph", "content": [{ "type": "text", "text": "Текст..." }] } + ] + } +}`; + + return this.withRetry(async () => { + const response = await this.client.chat.completions.create({ + model, + messages: [ + { role: 'system', content: systemPrompt }, + { + role: 'user', + content: `Курс: "${courseTitle}" +Глава: "${chapterTitle}" +Урок: "${lessonTitle}" + +Создай содержание урока сразу в TipTap JSON: заголовки (heading), параграфы (paragraph), списки (bulletList/orderedList), цитаты (blockquote), блоки кода (codeBlock) и при необходимости Mermaid-диаграммы (codeBlock с "language": "mermaid"). Объём — по теме урока (короткий/средний/длинный).`, + }, + ], + response_format: { type: 'json_object' }, + temperature: 0.7, + max_tokens: 8000, + }); + + log.response('generateLessonContent', { + prompt: response.usage?.prompt_tokens, + completion: response.usage?.completion_tokens, + }); + + const content = response.choices[0].message.content; + log.debug('Raw AI response:', content?.substring(0, 200) + '...'); + + if (!content) { + log.error('Empty response from AI'); + throw new Error('Empty response from AI'); + } + + const parsed = JSON.parse(content); + const validated = LessonContentSchema.parse(parsed); + + log.success(`Generated content for lesson: "${lessonTitle}"`); + + return validated; + }, `generateLessonContent: ${lessonTitle}`); + } + + async rewriteText( + text: string, + instruction: string, + model: string + ): Promise { + const response = await this.client.chat.completions.create({ + model, + messages: [ + { + role: 'system', + content: + 'Ты - редактор образовательного контента. Переработай текст согласно инструкции пользователя.', + }, + { + role: 'user', + content: `Исходный текст: +"${text}" + +Инструкция: ${instruction} + +Верни только переработанный текст без дополнительных пояснений.`, + }, + ], + temperature: 0.7, + }); + + return response.choices[0].message.content || text; + } + + async chat( + messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>, + model: string + ): Promise { + const response = await this.client.chat.completions.create({ + model, + messages, + temperature: 0.7, + }); + + return response.choices[0].message.content || ''; + } +} diff --git a/apps/ai-service/tsconfig.json b/apps/ai-service/tsconfig.json new file mode 100644 index 0000000..b813d42 --- /dev/null +++ b/apps/ai-service/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/api/nest-cli.json b/apps/api/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/apps/api/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..8755cd5 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,57 @@ +{ + "name": "@coursecraft/api", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "nest build", + "clean": "rm -rf dist .turbo", + "dev": "nest start --watch", + "start": "node dist/main", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage" + }, + "dependencies": { + "@coursecraft/database": "workspace:*", + "@coursecraft/shared": "workspace:*", + "@nestjs/bullmq": "^10.0.0", + "@nestjs/common": "^10.3.0", + "@nestjs/config": "^3.2.0", + "@nestjs/core": "^10.3.0", + "@nestjs/jwt": "^10.2.0", + "@nestjs/passport": "^10.0.3", + "@nestjs/platform-express": "^10.3.0", + "@nestjs/swagger": "^7.3.0", + "@supabase/supabase-js": "^2.39.0", + "bullmq": "^5.1.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "helmet": "^7.1.0", + "ioredis": "^5.3.0", + "meilisearch": "^0.37.0", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "reflect-metadata": "^0.2.1", + "rxjs": "^7.8.1", + "stripe": "^14.14.0" + }, + "devDependencies": { + "@nestjs/cli": "^10.3.0", + "@nestjs/schematics": "^10.1.0", + "@nestjs/testing": "^10.3.0", + "@types/express": "^4.17.21", + "@types/jest": "^29.5.12", + "@types/node": "^20.11.0", + "@types/passport-jwt": "^4.0.1", + "jest": "^29.7.0", + "source-map-support": "^0.5.21", + "ts-jest": "^29.1.2", + "ts-loader": "^9.5.1", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.4.0" + } +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts new file mode 100644 index 0000000..1e8cd43 --- /dev/null +++ b/apps/api/src/app.module.ts @@ -0,0 +1,46 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { BullModule } from '@nestjs/bullmq'; +import { join } from 'path'; +import { AuthModule } from './auth/auth.module'; +import { UsersModule } from './users/users.module'; +import { CoursesModule } from './courses/courses.module'; +import { GenerationModule } from './generation/generation.module'; +import { PaymentsModule } from './payments/payments.module'; +import { SearchModule } from './search/search.module'; +import { PrismaModule } from './common/prisma/prisma.module'; + +@Module({ + imports: [ + // Configuration - load from project root + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: [ + join(__dirname, '../../../.env.local'), + join(__dirname, '../../../.env'), + '.env.local', + '.env', + ], + }), + + // BullMQ for job queues + BullModule.forRoot({ + connection: { + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT || '6395', 10), + }, + }), + + // Database + PrismaModule, + + // Feature modules + AuthModule, + UsersModule, + CoursesModule, + GenerationModule, + PaymentsModule, + SearchModule, + ], +}) +export class AppModule {} diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts new file mode 100644 index 0000000..68df678 --- /dev/null +++ b/apps/api/src/auth/auth.controller.ts @@ -0,0 +1,43 @@ +import { + Controller, + Post, + Get, + Body, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { AuthService } from './auth.service'; +import { CurrentUser } from './decorators/current-user.decorator'; +import { Public } from './decorators/public.decorator'; +import { User } from '@coursecraft/database'; +import { ExchangeTokenDto } from './dto/exchange-token.dto'; + +@ApiTags('auth') +@Controller('auth') +export class AuthController { + constructor(private authService: AuthService) {} + + @Public() + @Post('exchange') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Exchange Supabase token for API token' }) + async exchangeToken(@Body() dto: ExchangeTokenDto) { + const user = await this.authService.validateSupabaseToken(dto.supabaseToken); + return this.authService.generateTokens(user); + } + + @Get('me') + @ApiBearerAuth() + @ApiOperation({ summary: 'Get current user' }) + async getCurrentUser(@CurrentUser() user: User) { + return { + id: user.id, + email: user.email, + name: user.name, + avatarUrl: user.avatarUrl, + subscriptionTier: user.subscriptionTier, + createdAt: user.createdAt, + }; + } +} diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts new file mode 100644 index 0000000..85092d4 --- /dev/null +++ b/apps/api/src/auth/auth.module.ts @@ -0,0 +1,40 @@ +import { Module, Global } from '@nestjs/common'; +import { PassportModule } from '@nestjs/passport'; +import { JwtModule } from '@nestjs/jwt'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { APP_GUARD } from '@nestjs/core'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { SupabaseService } from './supabase.service'; +import { JwtAuthGuard } from './guards/jwt-auth.guard'; +import { UsersModule } from '../users/users.module'; + +@Global() +@Module({ + imports: [ + PassportModule.register({ defaultStrategy: 'jwt' }), + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => ({ + secret: configService.get('JWT_SECRET'), + signOptions: { + expiresIn: '7d', + }, + }), + inject: [ConfigService], + }), + UsersModule, + ], + controllers: [AuthController], + providers: [ + AuthService, + SupabaseService, + JwtAuthGuard, + { + provide: APP_GUARD, + useClass: JwtAuthGuard, + }, + ], + exports: [AuthService, SupabaseService, JwtAuthGuard], +}) +export class AuthModule {} diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts new file mode 100644 index 0000000..f7fb9f3 --- /dev/null +++ b/apps/api/src/auth/auth.service.ts @@ -0,0 +1,65 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { SupabaseService } from './supabase.service'; +import { UsersService } from '../users/users.service'; +import { User } from '@coursecraft/database'; + +export interface JwtPayload { + sub: string; // user id + email: string; + supabaseId: string; +} + +@Injectable() +export class AuthService { + constructor( + private supabaseService: SupabaseService, + private usersService: UsersService, + private jwtService: JwtService + ) {} + + async validateSupabaseToken(token: string): Promise { + const supabaseUser = await this.supabaseService.verifyToken(token); + + if (!supabaseUser) { + throw new UnauthorizedException('Invalid token'); + } + + // Find or create user in our database + let user = await this.usersService.findBySupabaseId(supabaseUser.id); + + if (!user) { + user = await this.usersService.create({ + supabaseId: supabaseUser.id, + email: supabaseUser.email!, + name: supabaseUser.user_metadata?.full_name || supabaseUser.user_metadata?.name || null, + avatarUrl: supabaseUser.user_metadata?.avatar_url || null, + }); + } + + return user; + } + + async generateTokens(user: User) { + const payload: JwtPayload = { + sub: user.id, + email: user.email, + supabaseId: user.supabaseId, + }; + + return { + accessToken: this.jwtService.sign(payload), + user: { + id: user.id, + email: user.email, + name: user.name, + avatarUrl: user.avatarUrl, + subscriptionTier: user.subscriptionTier, + }, + }; + } + + async validateJwtPayload(payload: JwtPayload): Promise { + return this.usersService.findById(payload.sub); + } +} diff --git a/apps/api/src/auth/decorators/current-user.decorator.ts b/apps/api/src/auth/decorators/current-user.decorator.ts new file mode 100644 index 0000000..4923517 --- /dev/null +++ b/apps/api/src/auth/decorators/current-user.decorator.ts @@ -0,0 +1,11 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { User } from '@coursecraft/database'; + +export const CurrentUser = createParamDecorator( + (data: keyof User | undefined, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + const user = request.user as User; + + return data ? user?.[data] : user; + } +); diff --git a/apps/api/src/auth/decorators/public.decorator.ts b/apps/api/src/auth/decorators/public.decorator.ts new file mode 100644 index 0000000..b3845e1 --- /dev/null +++ b/apps/api/src/auth/decorators/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/apps/api/src/auth/dto/exchange-token.dto.ts b/apps/api/src/auth/dto/exchange-token.dto.ts new file mode 100644 index 0000000..7174f2a --- /dev/null +++ b/apps/api/src/auth/dto/exchange-token.dto.ts @@ -0,0 +1,12 @@ +import { IsString, IsNotEmpty } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class ExchangeTokenDto { + @ApiProperty({ + description: 'Supabase access token', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + }) + @IsString() + @IsNotEmpty() + supabaseToken: string; +} diff --git a/apps/api/src/auth/guards/jwt-auth.guard.ts b/apps/api/src/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..a956c06 --- /dev/null +++ b/apps/api/src/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,83 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + UnauthorizedException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { JwtService } from '@nestjs/jwt'; +import { AuthService } from '../auth.service'; +import { SupabaseService } from '../supabase.service'; +import { UsersService } from '../../users/users.service'; +import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; +import { JwtPayload } from '../auth.service'; + +@Injectable() +export class JwtAuthGuard implements CanActivate { + constructor( + private reflector: Reflector, + private jwtService: JwtService, + private authService: AuthService, + private supabaseService: SupabaseService, + private usersService: UsersService + ) {} + + async canActivate(context: ExecutionContext): Promise { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (isPublic) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const authHeader = request.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + throw new UnauthorizedException('Missing or invalid authorization header'); + } + + const token = authHeader.replace('Bearer ', ''); + + try { + // 1) Try our own JWT (from POST /auth/exchange) + try { + const payload = this.jwtService.verify(token); + const user = await this.authService.validateJwtPayload(payload); + if (user) { + request.user = user; + return true; + } + } catch { + // Not our JWT or expired — try Supabase below + } + + // 2) Fallback: Supabase access_token (for backward compatibility) + const supabaseUser = await this.supabaseService.verifyToken(token); + if (!supabaseUser) { + throw new UnauthorizedException('Invalid token'); + } + + let user = await this.usersService.findBySupabaseId(supabaseUser.id); + if (!user) { + user = await this.usersService.create({ + supabaseId: supabaseUser.id, + email: supabaseUser.email!, + name: + supabaseUser.user_metadata?.full_name || + supabaseUser.user_metadata?.name || + null, + avatarUrl: supabaseUser.user_metadata?.avatar_url || null, + }); + } + + request.user = user; + return true; + } catch (error) { + if (error instanceof UnauthorizedException) throw error; + throw new UnauthorizedException('Token validation failed'); + } + } +} diff --git a/apps/api/src/auth/guards/supabase-auth.guard.ts b/apps/api/src/auth/guards/supabase-auth.guard.ts new file mode 100644 index 0000000..9394f1d --- /dev/null +++ b/apps/api/src/auth/guards/supabase-auth.guard.ts @@ -0,0 +1,72 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + UnauthorizedException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { SupabaseService } from '../supabase.service'; +import { UsersService } from '../../users/users.service'; +import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; + +@Injectable() +export class SupabaseAuthGuard implements CanActivate { + constructor( + private reflector: Reflector, + private supabaseService: SupabaseService, + private usersService: UsersService + ) {} + + async canActivate(context: ExecutionContext): Promise { + // Check if route is public + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (isPublic) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const authHeader = request.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + throw new UnauthorizedException('Missing or invalid authorization header'); + } + + const token = authHeader.replace('Bearer ', ''); + + try { + // Validate token with Supabase + const supabaseUser = await this.supabaseService.verifyToken(token); + + if (!supabaseUser) { + throw new UnauthorizedException('Invalid token'); + } + + // Find or create user in our database + let user = await this.usersService.findBySupabaseId(supabaseUser.id); + + if (!user) { + // Auto-create user on first API call + user = await this.usersService.create({ + supabaseId: supabaseUser.id, + email: supabaseUser.email!, + name: + supabaseUser.user_metadata?.full_name || + supabaseUser.user_metadata?.name || + null, + avatarUrl: supabaseUser.user_metadata?.avatar_url || null, + }); + } + + // Attach user to request + request.user = user; + + return true; + } catch (error) { + throw new UnauthorizedException('Token validation failed'); + } + } +} diff --git a/apps/api/src/auth/strategies/jwt.strategy.ts b/apps/api/src/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..f2a6163 --- /dev/null +++ b/apps/api/src/auth/strategies/jwt.strategy.ts @@ -0,0 +1,62 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { ConfigService } from '@nestjs/config'; +import { SupabaseService } from '../supabase.service'; +import { UsersService } from '../../users/users.service'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor( + private configService: ConfigService, + private supabaseService: SupabaseService, + private usersService: UsersService + ) { + // Use Supabase JWT secret for validation + const supabaseUrl = configService.get('NEXT_PUBLIC_SUPABASE_URL'); + const jwtSecret = configService.get('SUPABASE_JWT_SECRET') || + configService.get('JWT_SECRET'); + + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: jwtSecret, + // Pass the request to validate method + passReqToCallback: true, + }); + } + + async validate(req: any, payload: any) { + // Extract token from header + const authHeader = req.headers.authorization; + if (!authHeader) { + throw new UnauthorizedException('No authorization header'); + } + + const token = authHeader.replace('Bearer ', ''); + + // Validate with Supabase + const supabaseUser = await this.supabaseService.verifyToken(token); + + if (!supabaseUser) { + throw new UnauthorizedException('Invalid token'); + } + + // Find or create user in our database + let user = await this.usersService.findBySupabaseId(supabaseUser.id); + + if (!user) { + // Auto-create user on first API call + user = await this.usersService.create({ + supabaseId: supabaseUser.id, + email: supabaseUser.email!, + name: supabaseUser.user_metadata?.full_name || + supabaseUser.user_metadata?.name || + null, + avatarUrl: supabaseUser.user_metadata?.avatar_url || null, + }); + } + + return user; + } +} diff --git a/apps/api/src/auth/supabase.service.ts b/apps/api/src/auth/supabase.service.ts new file mode 100644 index 0000000..c9751b0 --- /dev/null +++ b/apps/api/src/auth/supabase.service.ts @@ -0,0 +1,56 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { createClient, SupabaseClient, User as SupabaseUser } from '@supabase/supabase-js'; + +@Injectable() +export class SupabaseService { + private supabase: SupabaseClient; + + constructor(private configService: ConfigService) { + const supabaseUrl = this.configService.get('NEXT_PUBLIC_SUPABASE_URL'); + const supabaseServiceKey = this.configService.get('SUPABASE_SERVICE_ROLE_KEY'); + + if (!supabaseUrl || !supabaseServiceKey) { + throw new Error('Supabase configuration is missing'); + } + + this.supabase = createClient(supabaseUrl, supabaseServiceKey, { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }); + } + + getClient(): SupabaseClient { + return this.supabase; + } + + async verifyToken(token: string): Promise { + try { + const { data, error } = await this.supabase.auth.getUser(token); + + if (error || !data.user) { + return null; + } + + return data.user; + } catch { + return null; + } + } + + async getUserById(userId: string): Promise { + try { + const { data, error } = await this.supabase.auth.admin.getUserById(userId); + + if (error || !data.user) { + return null; + } + + return data.user; + } catch { + return null; + } + } +} diff --git a/apps/api/src/common/prisma/prisma.module.ts b/apps/api/src/common/prisma/prisma.module.ts new file mode 100644 index 0000000..7207426 --- /dev/null +++ b/apps/api/src/common/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/apps/api/src/common/prisma/prisma.service.ts b/apps/api/src/common/prisma/prisma.service.ts new file mode 100644 index 0000000..0a57401 --- /dev/null +++ b/apps/api/src/common/prisma/prisma.service.ts @@ -0,0 +1,19 @@ +import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { PrismaClient } from '@coursecraft/database'; + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + constructor() { + super({ + log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], + }); + } + + async onModuleInit() { + await this.$connect(); + } + + async onModuleDestroy() { + await this.$disconnect(); + } +} diff --git a/apps/api/src/courses/chapters.controller.ts b/apps/api/src/courses/chapters.controller.ts new file mode 100644 index 0000000..de945e3 --- /dev/null +++ b/apps/api/src/courses/chapters.controller.ts @@ -0,0 +1,67 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { ChaptersService } from './chapters.service'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { User } from '@coursecraft/database'; +import { CreateChapterDto } from './dto/create-chapter.dto'; +import { UpdateChapterDto } from './dto/update-chapter.dto'; + +@ApiTags('chapters') +@Controller('courses/:courseId/chapters') +@ApiBearerAuth() +export class ChaptersController { + constructor(private chaptersService: ChaptersService) {} + + @Post() + @ApiOperation({ summary: 'Create a new chapter' }) + async create( + @Param('courseId') courseId: string, + @CurrentUser() user: User, + @Body() dto: CreateChapterDto + ) { + return this.chaptersService.create(courseId, user.id, dto); + } + + @Get() + @ApiOperation({ summary: 'Get all chapters for a course' }) + async findAll(@Param('courseId') courseId: string) { + return this.chaptersService.findAllByCourse(courseId); + } + + @Patch(':chapterId') + @ApiOperation({ summary: 'Update chapter' }) + async update( + @Param('chapterId') chapterId: string, + @CurrentUser() user: User, + @Body() dto: UpdateChapterDto + ) { + return this.chaptersService.update(chapterId, user.id, dto); + } + + @Delete(':chapterId') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Delete chapter' }) + async delete(@Param('chapterId') chapterId: string, @CurrentUser() user: User) { + await this.chaptersService.delete(chapterId, user.id); + } + + @Post('reorder') + @ApiOperation({ summary: 'Reorder chapters' }) + async reorder( + @Param('courseId') courseId: string, + @CurrentUser() user: User, + @Body('chapterIds') chapterIds: string[] + ) { + return this.chaptersService.reorder(courseId, user.id, chapterIds); + } +} diff --git a/apps/api/src/courses/chapters.service.ts b/apps/api/src/courses/chapters.service.ts new file mode 100644 index 0000000..72acce2 --- /dev/null +++ b/apps/api/src/courses/chapters.service.ts @@ -0,0 +1,131 @@ +import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { PrismaService } from '../common/prisma/prisma.service'; +import { Chapter } from '@coursecraft/database'; +import { CoursesService } from './courses.service'; +import { CreateChapterDto } from './dto/create-chapter.dto'; +import { UpdateChapterDto } from './dto/update-chapter.dto'; + +@Injectable() +export class ChaptersService { + constructor( + private prisma: PrismaService, + private coursesService: CoursesService + ) {} + + async create(courseId: string, userId: string, dto: CreateChapterDto): Promise { + // Check ownership + const isOwner = await this.coursesService.checkOwnership(courseId, userId); + if (!isOwner) { + throw new ForbiddenException('You can only edit your own courses'); + } + + // Get max order + const maxOrder = await this.prisma.chapter.aggregate({ + where: { courseId }, + _max: { order: true }, + }); + + const order = (maxOrder._max.order ?? -1) + 1; + + return this.prisma.chapter.create({ + data: { + courseId, + title: dto.title, + description: dto.description, + order, + }, + include: { + lessons: { + orderBy: { order: 'asc' }, + }, + }, + }); + } + + async findAllByCourse(courseId: string): Promise { + return this.prisma.chapter.findMany({ + where: { courseId }, + include: { + lessons: { + orderBy: { order: 'asc' }, + }, + }, + orderBy: { order: 'asc' }, + }); + } + + async findById(id: string): Promise { + return this.prisma.chapter.findUnique({ + where: { id }, + include: { + lessons: { + orderBy: { order: 'asc' }, + }, + }, + }); + } + + async update( + chapterId: string, + userId: string, + dto: UpdateChapterDto + ): Promise { + const chapter = await this.findById(chapterId); + if (!chapter) { + throw new NotFoundException('Chapter not found'); + } + + const isOwner = await this.coursesService.checkOwnership(chapter.courseId, userId); + if (!isOwner) { + throw new ForbiddenException('You can only edit your own courses'); + } + + return this.prisma.chapter.update({ + where: { id: chapterId }, + data: { + title: dto.title, + description: dto.description, + }, + include: { + lessons: { + orderBy: { order: 'asc' }, + }, + }, + }); + } + + async delete(chapterId: string, userId: string): Promise { + const chapter = await this.findById(chapterId); + if (!chapter) { + throw new NotFoundException('Chapter not found'); + } + + const isOwner = await this.coursesService.checkOwnership(chapter.courseId, userId); + if (!isOwner) { + throw new ForbiddenException('You can only edit your own courses'); + } + + await this.prisma.chapter.delete({ + where: { id: chapterId }, + }); + } + + async reorder(courseId: string, userId: string, chapterIds: string[]): Promise { + const isOwner = await this.coursesService.checkOwnership(courseId, userId); + if (!isOwner) { + throw new ForbiddenException('You can only edit your own courses'); + } + + // Update order for each chapter + await Promise.all( + chapterIds.map((id, index) => + this.prisma.chapter.update({ + where: { id }, + data: { order: index }, + }) + ) + ); + + return this.findAllByCourse(courseId); + } +} diff --git a/apps/api/src/courses/courses.controller.ts b/apps/api/src/courses/courses.controller.ts new file mode 100644 index 0000000..f53d01e --- /dev/null +++ b/apps/api/src/courses/courses.controller.ts @@ -0,0 +1,78 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Query, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { CoursesService } from './courses.service'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { User, CourseStatus } from '@coursecraft/database'; +import { CreateCourseDto } from './dto/create-course.dto'; +import { UpdateCourseDto } from './dto/update-course.dto'; + +@ApiTags('courses') +@Controller('courses') +@ApiBearerAuth() +export class CoursesController { + constructor(private coursesService: CoursesService) {} + + @Post() + @ApiOperation({ summary: 'Create a new course' }) + async create(@CurrentUser() user: User, @Body() dto: CreateCourseDto): Promise { + return this.coursesService.create(user.id, dto); + } + + @Get() + @ApiOperation({ summary: 'Get all courses for current user' }) + @ApiQuery({ name: 'status', required: false, enum: CourseStatus }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + async findAll( + @CurrentUser() user: User, + @Query('status') status?: CourseStatus, + @Query('page') page?: number, + @Query('limit') limit?: number + ) { + return this.coursesService.findAllByAuthor(user.id, { status, page, limit }); + } + + @Get(':id') + @ApiOperation({ summary: 'Get course by ID' }) + async findOne(@Param('id') id: string): Promise { + return this.coursesService.findById(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update course' }) + async update( + @Param('id') id: string, + @CurrentUser() user: User, + @Body() dto: UpdateCourseDto + ): Promise { + return this.coursesService.update(id, user.id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Delete course' }) + async delete(@Param('id') id: string, @CurrentUser() user: User) { + await this.coursesService.delete(id, user.id); + } + + @Patch(':id/status') + @ApiOperation({ summary: 'Update course status' }) + async updateStatus( + @Param('id') id: string, + @CurrentUser() user: User, + @Body('status') status: CourseStatus + ): Promise { + return this.coursesService.updateStatus(id, user.id, status); + } +} diff --git a/apps/api/src/courses/courses.module.ts b/apps/api/src/courses/courses.module.ts new file mode 100644 index 0000000..6d3cfc2 --- /dev/null +++ b/apps/api/src/courses/courses.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { CoursesController } from './courses.controller'; +import { CoursesService } from './courses.service'; +import { ChaptersController } from './chapters.controller'; +import { ChaptersService } from './chapters.service'; +import { LessonsController } from './lessons.controller'; +import { LessonsService } from './lessons.service'; + +@Module({ + controllers: [CoursesController, ChaptersController, LessonsController], + providers: [CoursesService, ChaptersService, LessonsService], + exports: [CoursesService, ChaptersService, LessonsService], +}) +export class CoursesModule {} diff --git a/apps/api/src/courses/courses.service.ts b/apps/api/src/courses/courses.service.ts new file mode 100644 index 0000000..447c6c4 --- /dev/null +++ b/apps/api/src/courses/courses.service.ts @@ -0,0 +1,231 @@ +import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { PrismaService } from '../common/prisma/prisma.service'; +import { Course, CourseStatus } from '@coursecraft/database'; +import { generateUniqueSlug } from '@coursecraft/shared'; +import { CreateCourseDto } from './dto/create-course.dto'; +import { UpdateCourseDto } from './dto/update-course.dto'; + +@Injectable() +export class CoursesService { + constructor(private prisma: PrismaService) {} + + async create(authorId: string, dto: CreateCourseDto): Promise { + const slug = generateUniqueSlug(dto.title); + + return this.prisma.course.create({ + data: { + authorId, + title: dto.title, + description: dto.description, + slug, + status: CourseStatus.DRAFT, + }, + include: { + chapters: { + include: { + lessons: true, + }, + orderBy: { order: 'asc' }, + }, + }, + }); + } + + async findAllByAuthor( + authorId: string, + options?: { + status?: CourseStatus; + page?: number; + limit?: number; + } + ) { + const page = options?.page || 1; + const limit = options?.limit || 10; + const skip = (page - 1) * limit; + + const where = { + authorId, + ...(options?.status && { status: options.status }), + }; + + const [courses, total] = await Promise.all([ + this.prisma.course.findMany({ + where, + include: { + _count: { + select: { + chapters: true, + }, + }, + chapters: { + include: { + _count: { + select: { lessons: true }, + }, + }, + }, + }, + orderBy: { updatedAt: 'desc' }, + skip, + take: limit, + }), + this.prisma.course.count({ where }), + ]); + + // Transform to include counts + const transformedCourses = courses.map((course) => ({ + id: course.id, + title: course.title, + description: course.description, + slug: course.slug, + coverImage: course.coverImage, + status: course.status, + chaptersCount: course._count.chapters, + lessonsCount: course.chapters.reduce((acc, ch) => acc + ch._count.lessons, 0), + createdAt: course.createdAt, + updatedAt: course.updatedAt, + })); + + return { + data: transformedCourses, + meta: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }; + } + + async findById(id: string): Promise { + return this.prisma.course.findUnique({ + where: { id }, + include: { + author: { + select: { + id: true, + name: true, + avatarUrl: true, + }, + }, + chapters: { + include: { + lessons: { + orderBy: { order: 'asc' }, + }, + }, + orderBy: { order: 'asc' }, + }, + category: true, + }, + }); + } + + async findBySlug(slug: string): Promise { + return this.prisma.course.findUnique({ + where: { slug }, + include: { + author: { + select: { + id: true, + name: true, + avatarUrl: true, + }, + }, + chapters: { + include: { + lessons: { + orderBy: { order: 'asc' }, + }, + }, + orderBy: { order: 'asc' }, + }, + }, + }); + } + + async update(id: string, userId: string, dto: UpdateCourseDto): Promise { + const course = await this.findById(id); + + if (!course) { + throw new NotFoundException('Course not found'); + } + + if (course.authorId !== userId) { + throw new ForbiddenException('You can only edit your own courses'); + } + + return this.prisma.course.update({ + where: { id }, + data: { + title: dto.title, + description: dto.description, + coverImage: dto.coverImage, + status: dto.status, + tags: dto.tags, + difficulty: dto.difficulty, + estimatedHours: dto.estimatedHours, + metaTitle: dto.metaTitle, + metaDescription: dto.metaDescription, + }, + include: { + chapters: { + include: { + lessons: { + orderBy: { order: 'asc' }, + }, + }, + orderBy: { order: 'asc' }, + }, + }, + }); + } + + async delete(id: string, userId: string): Promise { + const course = await this.findById(id); + + if (!course) { + throw new NotFoundException('Course not found'); + } + + if (course.authorId !== userId) { + throw new ForbiddenException('You can only delete your own courses'); + } + + await this.prisma.course.delete({ + where: { id }, + }); + } + + async updateStatus(id: string, userId: string, status: CourseStatus): Promise { + const course = await this.findById(id); + + if (!course) { + throw new NotFoundException('Course not found'); + } + + if (course.authorId !== userId) { + throw new ForbiddenException('You can only edit your own courses'); + } + + const updateData: { status: CourseStatus; publishedAt?: Date | null } = { status }; + + if (status === CourseStatus.PUBLISHED && !course.publishedAt) { + updateData.publishedAt = new Date(); + } + + return this.prisma.course.update({ + where: { id }, + data: updateData, + }); + } + + async checkOwnership(courseId: string, userId: string): Promise { + const course = await this.prisma.course.findUnique({ + where: { id: courseId }, + select: { authorId: true }, + }); + + return course?.authorId === userId; + } +} diff --git a/apps/api/src/courses/dto/create-chapter.dto.ts b/apps/api/src/courses/dto/create-chapter.dto.ts new file mode 100644 index 0000000..1c32b47 --- /dev/null +++ b/apps/api/src/courses/dto/create-chapter.dto.ts @@ -0,0 +1,22 @@ +import { IsString, IsOptional, MinLength, MaxLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { VALIDATION } from '@coursecraft/shared'; + +export class CreateChapterDto { + @ApiProperty({ + description: 'Chapter title', + example: 'Introduction to Digital Marketing', + }) + @IsString() + @MinLength(VALIDATION.CHAPTER.TITLE_MIN) + @MaxLength(VALIDATION.CHAPTER.TITLE_MAX) + title: string; + + @ApiPropertyOptional({ + description: 'Chapter description', + example: 'Learn the basics of digital marketing strategies', + }) + @IsOptional() + @IsString() + description?: string; +} diff --git a/apps/api/src/courses/dto/create-course.dto.ts b/apps/api/src/courses/dto/create-course.dto.ts new file mode 100644 index 0000000..752c911 --- /dev/null +++ b/apps/api/src/courses/dto/create-course.dto.ts @@ -0,0 +1,25 @@ +import { IsString, IsOptional, MinLength, MaxLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { VALIDATION } from '@coursecraft/shared'; + +export class CreateCourseDto { + @ApiProperty({ + description: 'Course title', + example: 'Introduction to Marketing', + minLength: VALIDATION.COURSE.TITLE_MIN, + maxLength: VALIDATION.COURSE.TITLE_MAX, + }) + @IsString() + @MinLength(VALIDATION.COURSE.TITLE_MIN) + @MaxLength(VALIDATION.COURSE.TITLE_MAX) + title: string; + + @ApiPropertyOptional({ + description: 'Course description', + example: 'Learn the fundamentals of digital marketing...', + }) + @IsOptional() + @IsString() + @MaxLength(VALIDATION.COURSE.DESCRIPTION_MAX) + description?: string; +} diff --git a/apps/api/src/courses/dto/create-lesson.dto.ts b/apps/api/src/courses/dto/create-lesson.dto.ts new file mode 100644 index 0000000..c90b6e7 --- /dev/null +++ b/apps/api/src/courses/dto/create-lesson.dto.ts @@ -0,0 +1,30 @@ +import { IsString, IsOptional, IsObject, IsNumber, MinLength, MaxLength, Min } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { VALIDATION } from '@coursecraft/shared'; + +export class CreateLessonDto { + @ApiProperty({ + description: 'Lesson title', + example: 'What is Digital Marketing?', + }) + @IsString() + @MinLength(VALIDATION.LESSON.TITLE_MIN) + @MaxLength(VALIDATION.LESSON.TITLE_MAX) + title: string; + + @ApiPropertyOptional({ + description: 'Lesson content in TipTap JSON format', + }) + @IsOptional() + @IsObject() + content?: Record; + + @ApiPropertyOptional({ + description: 'Estimated duration in minutes', + example: 15, + }) + @IsOptional() + @IsNumber() + @Min(0) + durationMinutes?: number; +} diff --git a/apps/api/src/courses/dto/update-chapter.dto.ts b/apps/api/src/courses/dto/update-chapter.dto.ts new file mode 100644 index 0000000..a852c18 --- /dev/null +++ b/apps/api/src/courses/dto/update-chapter.dto.ts @@ -0,0 +1,17 @@ +import { IsString, IsOptional, MinLength, MaxLength } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { VALIDATION } from '@coursecraft/shared'; + +export class UpdateChapterDto { + @ApiPropertyOptional({ description: 'Chapter title' }) + @IsOptional() + @IsString() + @MinLength(VALIDATION.CHAPTER.TITLE_MIN) + @MaxLength(VALIDATION.CHAPTER.TITLE_MAX) + title?: string; + + @ApiPropertyOptional({ description: 'Chapter description' }) + @IsOptional() + @IsString() + description?: string; +} diff --git a/apps/api/src/courses/dto/update-course.dto.ts b/apps/api/src/courses/dto/update-course.dto.ts new file mode 100644 index 0000000..101b5cf --- /dev/null +++ b/apps/api/src/courses/dto/update-course.dto.ts @@ -0,0 +1,71 @@ +import { + IsString, + IsOptional, + IsArray, + IsNumber, + IsEnum, + IsUrl, + MinLength, + MaxLength, + Min, +} from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { CourseStatus } from '@coursecraft/database'; +import { VALIDATION } from '@coursecraft/shared'; + +export class UpdateCourseDto { + @ApiPropertyOptional({ description: 'Course title' }) + @IsOptional() + @IsString() + @MinLength(VALIDATION.COURSE.TITLE_MIN) + @MaxLength(VALIDATION.COURSE.TITLE_MAX) + title?: string; + + @ApiPropertyOptional({ description: 'Course description' }) + @IsOptional() + @IsString() + @MaxLength(VALIDATION.COURSE.DESCRIPTION_MAX) + description?: string; + + @ApiPropertyOptional({ description: 'Cover image URL' }) + @IsOptional() + @IsUrl() + coverImage?: string; + + @ApiPropertyOptional({ description: 'Course status', enum: CourseStatus }) + @IsOptional() + @IsEnum(CourseStatus) + status?: CourseStatus; + + @ApiPropertyOptional({ description: 'Course tags', type: [String] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @ApiPropertyOptional({ + description: 'Course difficulty', + enum: ['beginner', 'intermediate', 'advanced'], + }) + @IsOptional() + @IsString() + difficulty?: string; + + @ApiPropertyOptional({ description: 'Estimated hours to complete' }) + @IsOptional() + @IsNumber() + @Min(0) + estimatedHours?: number; + + @ApiPropertyOptional({ description: 'SEO meta title' }) + @IsOptional() + @IsString() + @MaxLength(100) + metaTitle?: string; + + @ApiPropertyOptional({ description: 'SEO meta description' }) + @IsOptional() + @IsString() + @MaxLength(300) + metaDescription?: string; +} diff --git a/apps/api/src/courses/dto/update-lesson.dto.ts b/apps/api/src/courses/dto/update-lesson.dto.ts new file mode 100644 index 0000000..f083a03 --- /dev/null +++ b/apps/api/src/courses/dto/update-lesson.dto.ts @@ -0,0 +1,34 @@ +import { IsString, IsOptional, IsObject, IsNumber, IsUrl, MinLength, MaxLength, Min } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { VALIDATION } from '@coursecraft/shared'; + +export class UpdateLessonDto { + @ApiPropertyOptional({ description: 'Lesson title' }) + @IsOptional() + @IsString() + @MinLength(VALIDATION.LESSON.TITLE_MIN) + @MaxLength(VALIDATION.LESSON.TITLE_MAX) + title?: string; + + @ApiPropertyOptional({ + description: 'Lesson content in TipTap JSON format', + }) + @IsOptional() + @IsObject() + content?: Record; + + @ApiPropertyOptional({ + description: 'Estimated duration in minutes', + }) + @IsOptional() + @IsNumber() + @Min(0) + durationMinutes?: number; + + @ApiPropertyOptional({ + description: 'Video URL for the lesson', + }) + @IsOptional() + @IsUrl() + videoUrl?: string; +} diff --git a/apps/api/src/courses/lessons.controller.ts b/apps/api/src/courses/lessons.controller.ts new file mode 100644 index 0000000..f49db66 --- /dev/null +++ b/apps/api/src/courses/lessons.controller.ts @@ -0,0 +1,67 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { LessonsService } from './lessons.service'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { User } from '@coursecraft/database'; +import { CreateLessonDto } from './dto/create-lesson.dto'; +import { UpdateLessonDto } from './dto/update-lesson.dto'; + +@ApiTags('lessons') +@Controller('courses/:courseId') +@ApiBearerAuth() +export class LessonsController { + constructor(private lessonsService: LessonsService) {} + + @Post('chapters/:chapterId/lessons') + @ApiOperation({ summary: 'Create a new lesson' }) + async create( + @Param('chapterId') chapterId: string, + @CurrentUser() user: User, + @Body() dto: CreateLessonDto + ): Promise { + return this.lessonsService.create(chapterId, user.id, dto); + } + + @Get('lessons/:lessonId') + @ApiOperation({ summary: 'Get lesson by ID' }) + async findOne(@Param('lessonId') lessonId: string): Promise { + return this.lessonsService.findById(lessonId); + } + + @Patch('lessons/:lessonId') + @ApiOperation({ summary: 'Update lesson' }) + async update( + @Param('lessonId') lessonId: string, + @CurrentUser() user: User, + @Body() dto: UpdateLessonDto + ): Promise { + return this.lessonsService.update(lessonId, user.id, dto); + } + + @Delete('lessons/:lessonId') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Delete lesson' }) + async delete(@Param('lessonId') lessonId: string, @CurrentUser() user: User): Promise { + await this.lessonsService.delete(lessonId, user.id); + } + + @Post('chapters/:chapterId/lessons/reorder') + @ApiOperation({ summary: 'Reorder lessons in a chapter' }) + async reorder( + @Param('chapterId') chapterId: string, + @CurrentUser() user: User, + @Body('lessonIds') lessonIds: string[] + ): Promise { + return this.lessonsService.reorder(chapterId, user.id, lessonIds); + } +} diff --git a/apps/api/src/courses/lessons.service.ts b/apps/api/src/courses/lessons.service.ts new file mode 100644 index 0000000..01bf297 --- /dev/null +++ b/apps/api/src/courses/lessons.service.ts @@ -0,0 +1,141 @@ +import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { PrismaService } from '../common/prisma/prisma.service'; +import { Lesson } from '@coursecraft/database'; +import { CoursesService } from './courses.service'; +import { ChaptersService } from './chapters.service'; +import { CreateLessonDto } from './dto/create-lesson.dto'; +import { UpdateLessonDto } from './dto/update-lesson.dto'; + +@Injectable() +export class LessonsService { + constructor( + private prisma: PrismaService, + private coursesService: CoursesService, + private chaptersService: ChaptersService + ) {} + + async create(chapterId: string, userId: string, dto: CreateLessonDto): Promise { + const chapter = await this.chaptersService.findById(chapterId); + if (!chapter) { + throw new NotFoundException('Chapter not found'); + } + + const isOwner = await this.coursesService.checkOwnership(chapter.courseId, userId); + if (!isOwner) { + throw new ForbiddenException('You can only edit your own courses'); + } + + // Get max order + const maxOrder = await this.prisma.lesson.aggregate({ + where: { chapterId }, + _max: { order: true }, + }); + + const order = (maxOrder._max.order ?? -1) + 1; + + return this.prisma.lesson.create({ + data: { + chapterId, + title: dto.title, + content: dto.content as any, + order, + durationMinutes: dto.durationMinutes, + }, + }); + } + + async findById(id: string): Promise { + return this.prisma.lesson.findUnique({ + where: { id }, + include: { + chapter: { + select: { + id: true, + title: true, + courseId: true, + }, + }, + }, + }); + } + + async update(lessonId: string, userId: string, dto: UpdateLessonDto): Promise { + const lesson = await this.prisma.lesson.findUnique({ + where: { id: lessonId }, + include: { + chapter: { + select: { courseId: true }, + }, + }, + }); + + if (!lesson) { + throw new NotFoundException('Lesson not found'); + } + + const isOwner = await this.coursesService.checkOwnership(lesson.chapter.courseId, userId); + if (!isOwner) { + throw new ForbiddenException('You can only edit your own courses'); + } + + return this.prisma.lesson.update({ + where: { id: lessonId }, + data: { + title: dto.title, + content: dto.content as any, + durationMinutes: dto.durationMinutes, + videoUrl: dto.videoUrl, + }, + }); + } + + async delete(lessonId: string, userId: string): Promise { + const lesson = await this.prisma.lesson.findUnique({ + where: { id: lessonId }, + include: { + chapter: { + select: { courseId: true }, + }, + }, + }); + + if (!lesson) { + throw new NotFoundException('Lesson not found'); + } + + const isOwner = await this.coursesService.checkOwnership(lesson.chapter.courseId, userId); + if (!isOwner) { + throw new ForbiddenException('You can only edit your own courses'); + } + + await this.prisma.lesson.delete({ + where: { id: lessonId }, + }); + } + + async reorder(chapterId: string, userId: string, lessonIds: string[]): Promise { + const chapter = await this.chaptersService.findById(chapterId); + if (!chapter) { + throw new NotFoundException('Chapter not found'); + } + + const isOwner = await this.coursesService.checkOwnership(chapter.courseId, userId); + if (!isOwner) { + throw new ForbiddenException('You can only edit your own courses'); + } + + await Promise.all( + lessonIds.map((id, index) => + this.prisma.lesson.update({ + where: { id }, + data: { order: index }, + }) + ) + ); + + return this.prisma.lesson.findMany({ + where: { chapterId }, + orderBy: { order: 'asc' }, + }); + } +} diff --git a/apps/api/src/generation/dto/answer-questions.dto.ts b/apps/api/src/generation/dto/answer-questions.dto.ts new file mode 100644 index 0000000..5aa3ce0 --- /dev/null +++ b/apps/api/src/generation/dto/answer-questions.dto.ts @@ -0,0 +1,15 @@ +import { IsObject } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class AnswerQuestionsDto { + @ApiProperty({ + description: 'Object with question IDs as keys and answers as values', + example: { + target_audience: 'Начинающие', + course_depth: 'Стандартный', + specific_topics: 'React, TypeScript', + }, + }) + @IsObject() + answers!: Record; +} diff --git a/apps/api/src/generation/dto/start-generation.dto.ts b/apps/api/src/generation/dto/start-generation.dto.ts new file mode 100644 index 0000000..f007bba --- /dev/null +++ b/apps/api/src/generation/dto/start-generation.dto.ts @@ -0,0 +1,16 @@ +import { IsString, MinLength, MaxLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { VALIDATION } from '@coursecraft/shared'; + +export class StartGenerationDto { + @ApiProperty({ + description: 'Prompt describing the course you want to create', + example: 'Сделай курс по маркетингу для начинающих', + minLength: VALIDATION.PROMPT.MIN, + maxLength: VALIDATION.PROMPT.MAX, + }) + @IsString() + @MinLength(VALIDATION.PROMPT.MIN) + @MaxLength(VALIDATION.PROMPT.MAX) + prompt: string; +} diff --git a/apps/api/src/generation/generation.controller.ts b/apps/api/src/generation/generation.controller.ts new file mode 100644 index 0000000..ac376e1 --- /dev/null +++ b/apps/api/src/generation/generation.controller.ts @@ -0,0 +1,90 @@ +import { + Controller, + Get, + Post, + Body, + Param, + Sse, + MessageEvent, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { Observable, interval, map, takeWhile, switchMap, from, startWith } from 'rxjs'; +import { GenerationService } from './generation.service'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { User, GenerationStatus } from '@coursecraft/database'; +import { StartGenerationDto } from './dto/start-generation.dto'; +import { AnswerQuestionsDto } from './dto/answer-questions.dto'; + +@ApiTags('generation') +@Controller('generation') +@ApiBearerAuth() +export class GenerationController { + constructor(private generationService: GenerationService) {} + + @Post('start') + @ApiOperation({ summary: 'Start course generation' }) + async startGeneration( + @CurrentUser() user: User, + @Body() dto: StartGenerationDto + ): Promise { + return this.generationService.startGeneration(user.id, dto); + } + + @Get(':id/status') + @ApiOperation({ summary: 'Get generation status' }) + async getStatus( + @Param('id') id: string, + @CurrentUser() user: User + ): Promise { + return this.generationService.getStatus(id, user.id); + } + + @Sse(':id/stream') + @ApiOperation({ summary: 'Stream generation progress (SSE)' }) + streamProgress( + @Param('id') id: string, + @CurrentUser() user: User + ): Observable { + const terminalStatuses: string[] = [ + GenerationStatus.COMPLETED, + GenerationStatus.FAILED, + GenerationStatus.CANCELLED, + ]; + + let isComplete = false; + + return interval(1000).pipe( + startWith(0), + switchMap(() => from(this.generationService.getStatus(id, user.id))), + takeWhile((status) => { + if (terminalStatuses.includes(status.status as string)) { + isComplete = true; + return true; // Include the final status + } + return !isComplete; + }, true), + map((status) => ({ + data: JSON.stringify(status), + })) + ); + } + + @Post(':id/answer') + @ApiOperation({ summary: 'Answer clarifying questions' }) + async answerQuestions( + @Param('id') id: string, + @CurrentUser() user: User, + @Body() dto: AnswerQuestionsDto + ) { + return this.generationService.answerQuestions(id, user.id, dto); + } + + @Post(':id/cancel') + @ApiOperation({ summary: 'Cancel generation' }) + async cancelGeneration( + @Param('id') id: string, + @CurrentUser() user: User + ) { + return this.generationService.cancelGeneration(id, user.id); + } +} diff --git a/apps/api/src/generation/generation.module.ts b/apps/api/src/generation/generation.module.ts new file mode 100644 index 0000000..2e78637 --- /dev/null +++ b/apps/api/src/generation/generation.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bullmq'; +import { GenerationController } from './generation.controller'; +import { GenerationService } from './generation.service'; +import { UsersModule } from '../users/users.module'; +import { CoursesModule } from '../courses/courses.module'; + +/** + * Only ai-service processes course-generation jobs (single worker). + * API only enqueues jobs via GenerationService. + */ +@Module({ + imports: [ + BullModule.registerQueue({ + name: 'course-generation', + }), + UsersModule, + CoursesModule, + ], + controllers: [GenerationController], + providers: [GenerationService], + exports: [GenerationService], +}) +export class GenerationModule {} diff --git a/apps/api/src/generation/generation.service.ts b/apps/api/src/generation/generation.service.ts new file mode 100644 index 0000000..83da42c --- /dev/null +++ b/apps/api/src/generation/generation.service.ts @@ -0,0 +1,226 @@ +import { Injectable, NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; +import { PrismaService } from '../common/prisma/prisma.service'; +import { UsersService } from '../users/users.service'; +import { GenerationStatus, SubscriptionTier } from '@coursecraft/database'; +import { SUBSCRIPTION_LIMITS } from '@coursecraft/shared'; +import { StartGenerationDto } from './dto/start-generation.dto'; +import { AnswerQuestionsDto } from './dto/answer-questions.dto'; + +@Injectable() +export class GenerationService { + constructor( + @InjectQueue('course-generation') private generationQueue: Queue, + private prisma: PrismaService, + private usersService: UsersService + ) {} + + async startGeneration(userId: string, dto: StartGenerationDto) { + // Check if user can create more courses (skip in dev/test when BYPASS_COURSE_LIMIT=true) + const bypassLimit = process.env.BYPASS_COURSE_LIMIT === 'true'; + if (!bypassLimit) { + const canCreate = await this.usersService.canCreateCourse(userId); + if (!canCreate) { + throw new ForbiddenException('You have reached your monthly course limit'); + } + } + + // Get user's AI model preference + const settings = await this.usersService.getSettings(userId); + const user = await this.usersService.findById(userId); + + if (!user) { + throw new NotFoundException('User not found'); + } + + // Determine AI model to use + const tierLimits = SUBSCRIPTION_LIMITS[user.subscriptionTier as keyof typeof SUBSCRIPTION_LIMITS]; + const aiModel = settings.customAiModel || tierLimits.defaultAiModel; + + // Create generation record + const generation = await this.prisma.courseGeneration.create({ + data: { + userId, + initialPrompt: dto.prompt, + aiModel, + status: GenerationStatus.PENDING, + }, + }); + + // Add job to queue + const job = await this.generationQueue.add( + 'generate-course', + { + generationId: generation.id, + userId, + prompt: dto.prompt, + aiModel, + }, + { + attempts: 3, + backoff: { + type: 'exponential', + delay: 1000, + }, + } + ); + + // Update with job ID + await this.prisma.courseGeneration.update({ + where: { id: generation.id }, + data: { jobId: job.id }, + }); + + return { + id: generation.id, + status: generation.status, + progress: 0, + }; + } + + async getStatus(generationId: string, userId: string): Promise { + const generation = await this.prisma.courseGeneration.findUnique({ + where: { id: generationId }, + include: { + course: { + select: { + id: true, + slug: true, + }, + }, + }, + }); + + if (!generation) { + throw new NotFoundException('Generation not found'); + } + + if (generation.userId !== userId) { + throw new ForbiddenException('Access denied'); + } + + return { + id: generation.id, + status: generation.status, + progress: generation.progress, + currentStep: generation.currentStep, + questions: generation.questions, + generatedOutline: generation.generatedOutline, + errorMessage: generation.errorMessage, + course: generation.course, + }; + } + + async answerQuestions(generationId: string, userId: string, dto: AnswerQuestionsDto) { + const generation = await this.prisma.courseGeneration.findUnique({ + where: { id: generationId }, + }); + + if (!generation) { + throw new NotFoundException('Generation not found'); + } + + if (generation.userId !== userId) { + throw new ForbiddenException('Access denied'); + } + + if (generation.status !== GenerationStatus.WAITING_FOR_ANSWERS) { + throw new BadRequestException('Generation is not waiting for answers'); + } + + // Save answers and continue generation + await this.prisma.courseGeneration.update({ + where: { id: generationId }, + data: { + answers: dto.answers as any, + status: GenerationStatus.RESEARCHING, + }, + }); + + // Continue the job + await this.generationQueue.add( + 'continue-generation', + { + generationId, + stage: 'after-questions', + }, + { + attempts: 3, + backoff: { + type: 'exponential', + delay: 1000, + }, + } + ); + + return { success: true }; + } + + async cancelGeneration(generationId: string, userId: string) { + const generation = await this.prisma.courseGeneration.findUnique({ + where: { id: generationId }, + }); + + if (!generation) { + throw new NotFoundException('Generation not found'); + } + + if (generation.userId !== userId) { + throw new ForbiddenException('Access denied'); + } + + // Cancel the job if possible + if (generation.jobId) { + const job = await this.generationQueue.getJob(generation.jobId); + if (job) { + await job.remove(); + } + } + + await this.prisma.courseGeneration.update({ + where: { id: generationId }, + data: { + status: GenerationStatus.CANCELLED, + }, + }); + + return { success: true }; + } + + async updateProgress( + generationId: string, + status: GenerationStatus, + progress: number, + currentStep?: string, + additionalData?: { + questions?: unknown; + generatedOutline?: unknown; + errorMessage?: string; + } + ): Promise { + const updateData: Record = { + status, + progress, + currentStep, + }; + + if (additionalData?.questions) { + updateData.questions = additionalData.questions; + } + if (additionalData?.generatedOutline) { + updateData.generatedOutline = additionalData.generatedOutline; + } + if (additionalData?.errorMessage) { + updateData.errorMessage = additionalData.errorMessage; + } + if (status === GenerationStatus.COMPLETED) { + updateData.completedAt = new Date(); + } + + return this.prisma.courseGeneration.update({ + where: { id: generationId }, + data: updateData, + }); + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts new file mode 100644 index 0000000..480a44d --- /dev/null +++ b/apps/api/src/main.ts @@ -0,0 +1,60 @@ +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import helmet from 'helmet'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + const configService = app.get(ConfigService); + + // Security + app.use(helmet()); + + // CORS (веб на порту 3125) + const allowedOrigins = [ + configService.get('NEXT_PUBLIC_APP_URL'), + 'http://localhost:3125', + 'http://localhost:3000', + ].filter(Boolean) as string[]; + app.enableCors({ + origin: allowedOrigins.length ? allowedOrigins : 'http://localhost:3125', + credentials: true, + }); + + // Global prefix + app.setGlobalPrefix('api'); + + // Validation + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { + enableImplicitConversion: true, + }, + }) + ); + + // Swagger + if (configService.get('NODE_ENV') !== 'production') { + const config = new DocumentBuilder() + .setTitle('CourseCraft API') + .setDescription('AI-powered course creation platform API') + .setVersion('1.0') + .addBearerAuth() + .build(); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('docs', app, document); + } + + const port = configService.get('PORT') || 3001; + await app.listen(port); + + console.log(`🚀 API is running on: http://localhost:${port}/api`); + console.log(`📚 Swagger docs: http://localhost:${port}/docs`); +} + +bootstrap(); diff --git a/apps/api/src/payments/payments.controller.ts b/apps/api/src/payments/payments.controller.ts new file mode 100644 index 0000000..c7636cb --- /dev/null +++ b/apps/api/src/payments/payments.controller.ts @@ -0,0 +1,41 @@ +import { + Controller, + Get, + Post, + Body, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { PaymentsService } from './payments.service'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { Public } from '../auth/decorators/public.decorator'; +import { User } from '@coursecraft/database'; + +@ApiTags('subscriptions') +@Controller('subscriptions') +export class PaymentsController { + constructor(private paymentsService: PaymentsService) {} + + @Get('plans') + @Public() + @ApiOperation({ summary: 'Get available subscription plans' }) + async getPlans() { + return this.paymentsService.getPlans(); + } + + @Post('checkout') + @ApiBearerAuth() + @ApiOperation({ summary: 'Create Stripe checkout session' }) + async createCheckoutSession( + @CurrentUser() user: User, + @Body('tier') tier: 'PREMIUM' | 'PRO' + ) { + return this.paymentsService.createCheckoutSession(user.id, tier); + } + + @Post('portal') + @ApiBearerAuth() + @ApiOperation({ summary: 'Create Stripe customer portal session' }) + async createPortalSession(@CurrentUser() user: User) { + return this.paymentsService.createPortalSession(user.id); + } +} diff --git a/apps/api/src/payments/payments.module.ts b/apps/api/src/payments/payments.module.ts new file mode 100644 index 0000000..75f5416 --- /dev/null +++ b/apps/api/src/payments/payments.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { PaymentsController } from './payments.controller'; +import { PaymentsService } from './payments.service'; +import { StripeService } from './stripe.service'; +import { WebhooksController } from './webhooks.controller'; + +@Module({ + controllers: [PaymentsController, WebhooksController], + providers: [PaymentsService, StripeService], + exports: [PaymentsService, StripeService], +}) +export class PaymentsModule {} diff --git a/apps/api/src/payments/payments.service.ts b/apps/api/src/payments/payments.service.ts new file mode 100644 index 0000000..6296415 --- /dev/null +++ b/apps/api/src/payments/payments.service.ts @@ -0,0 +1,231 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PrismaService } from '../common/prisma/prisma.service'; +import { StripeService } from './stripe.service'; +import { SubscriptionTier } from '@coursecraft/database'; +import { SUBSCRIPTION_PLANS } from '@coursecraft/shared'; + +@Injectable() +export class PaymentsService { + constructor( + private prisma: PrismaService, + private stripeService: StripeService, + private configService: ConfigService + ) {} + + async getPlans() { + return SUBSCRIPTION_PLANS.map((plan) => ({ + tier: plan.tier, + name: plan.name, + nameRu: plan.nameRu, + description: plan.description, + descriptionRu: plan.descriptionRu, + price: plan.price, + currency: plan.currency, + features: plan.features, + featuresRu: plan.featuresRu, + })); + } + + async createCheckoutSession(userId: string, tier: 'PREMIUM' | 'PRO') { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + include: { subscription: true }, + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + // Get or create Stripe customer + let stripeCustomerId = user.subscription?.stripeCustomerId; + + if (!stripeCustomerId) { + const customer = await this.stripeService.createCustomer(user.email, user.name || undefined); + stripeCustomerId = customer.id; + + await this.prisma.subscription.upsert({ + where: { userId }, + create: { + userId, + tier: SubscriptionTier.FREE, + stripeCustomerId: customer.id, + }, + update: { + stripeCustomerId: customer.id, + }, + }); + } + + // Get price ID for tier + const priceId = + tier === 'PREMIUM' + ? this.configService.get('STRIPE_PRICE_PREMIUM') + : this.configService.get('STRIPE_PRICE_PRO'); + + if (!priceId) { + throw new Error(`Price ID not configured for tier: ${tier}`); + } + + const appUrl = this.configService.get('NEXT_PUBLIC_APP_URL') || 'http://localhost:3000'; + + const session = await this.stripeService.createCheckoutSession({ + customerId: stripeCustomerId, + priceId, + successUrl: `${appUrl}/dashboard/billing?success=true`, + cancelUrl: `${appUrl}/dashboard/billing?canceled=true`, + metadata: { + userId, + tier, + }, + }); + + return { url: session.url }; + } + + async createPortalSession(userId: string) { + const subscription = await this.prisma.subscription.findUnique({ + where: { userId }, + }); + + if (!subscription?.stripeCustomerId) { + throw new NotFoundException('No subscription found'); + } + + const appUrl = this.configService.get('NEXT_PUBLIC_APP_URL') || 'http://localhost:3000'; + + const session = await this.stripeService.createPortalSession( + subscription.stripeCustomerId, + `${appUrl}/dashboard/billing` + ); + + return { url: session.url }; + } + + async handleWebhookEvent(event: { type: string; data: { object: unknown } }) { + switch (event.type) { + case 'checkout.session.completed': + await this.handleCheckoutCompleted(event.data.object as { + customer: string; + subscription: string; + metadata: { userId: string; tier: string }; + }); + break; + + case 'customer.subscription.updated': + await this.handleSubscriptionUpdated(event.data.object as { + id: string; + customer: string; + status: string; + current_period_end: number; + cancel_at_period_end: boolean; + items: { data: Array<{ price: { id: string } }> }; + }); + break; + + case 'customer.subscription.deleted': + await this.handleSubscriptionDeleted(event.data.object as { + customer: string; + }); + break; + } + } + + private async handleCheckoutCompleted(session: { + customer: string; + subscription: string; + metadata: { userId: string; tier: string }; + }) { + const { customer, subscription: subscriptionId, metadata } = session; + const tier = metadata.tier as SubscriptionTier; + + const stripeSubscription = await this.stripeService.getSubscription(subscriptionId); + + await this.prisma.subscription.update({ + where: { stripeCustomerId: customer }, + data: { + tier, + stripeSubscriptionId: subscriptionId, + stripePriceId: stripeSubscription.items.data[0]?.price.id, + status: 'active', + currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000), + currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000), + coursesCreatedThisMonth: 0, // Reset on new subscription + }, + }); + + // Update user's subscription tier + await this.prisma.user.update({ + where: { id: metadata.userId }, + data: { subscriptionTier: tier }, + }); + } + + private async handleSubscriptionUpdated(subscription: { + id: string; + customer: string; + status: string; + current_period_end: number; + cancel_at_period_end: boolean; + items: { data: Array<{ price: { id: string } }> }; + }) { + const priceId = subscription.items.data[0]?.price.id; + + // Determine tier from price ID + const premiumPriceId = this.configService.get('STRIPE_PRICE_PREMIUM'); + const proPriceId = this.configService.get('STRIPE_PRICE_PRO'); + + let tier: SubscriptionTier = SubscriptionTier.FREE; + if (priceId === premiumPriceId) tier = SubscriptionTier.PREMIUM; + else if (priceId === proPriceId) tier = SubscriptionTier.PRO; + + await this.prisma.subscription.update({ + where: { stripeCustomerId: subscription.customer as string }, + data: { + tier, + status: subscription.status, + currentPeriodEnd: new Date(subscription.current_period_end * 1000), + cancelAtPeriodEnd: subscription.cancel_at_period_end, + stripePriceId: priceId, + }, + }); + + // Update user's tier + const sub = await this.prisma.subscription.findUnique({ + where: { stripeCustomerId: subscription.customer as string }, + }); + + if (sub) { + await this.prisma.user.update({ + where: { id: sub.userId }, + data: { subscriptionTier: tier }, + }); + } + } + + private async handleSubscriptionDeleted(subscription: { customer: string }) { + await this.prisma.subscription.update({ + where: { stripeCustomerId: subscription.customer }, + data: { + tier: SubscriptionTier.FREE, + status: 'canceled', + stripeSubscriptionId: null, + stripePriceId: null, + currentPeriodEnd: null, + cancelAtPeriodEnd: false, + }, + }); + + // Update user's tier + const sub = await this.prisma.subscription.findUnique({ + where: { stripeCustomerId: subscription.customer }, + }); + + if (sub) { + await this.prisma.user.update({ + where: { id: sub.userId }, + data: { subscriptionTier: SubscriptionTier.FREE }, + }); + } + } +} diff --git a/apps/api/src/payments/stripe.service.ts b/apps/api/src/payments/stripe.service.ts new file mode 100644 index 0000000..fbe56b1 --- /dev/null +++ b/apps/api/src/payments/stripe.service.ts @@ -0,0 +1,69 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Stripe from 'stripe'; + +@Injectable() +export class StripeService { + private stripe: Stripe; + + constructor(private configService: ConfigService) { + this.stripe = new Stripe(this.configService.get('STRIPE_SECRET_KEY')!, { + apiVersion: '2023-10-16', + }); + } + + getClient(): Stripe { + return this.stripe; + } + + async createCustomer(email: string, name?: string): Promise { + return this.stripe.customers.create({ + email, + name: name || undefined, + }); + } + + async createCheckoutSession(params: { + customerId: string; + priceId: string; + successUrl: string; + cancelUrl: string; + metadata?: Record; + }): Promise { + return this.stripe.checkout.sessions.create({ + customer: params.customerId, + mode: 'subscription', + line_items: [ + { + price: params.priceId, + quantity: 1, + }, + ], + success_url: params.successUrl, + cancel_url: params.cancelUrl, + metadata: params.metadata, + }); + } + + async createPortalSession(customerId: string, returnUrl: string): Promise { + return this.stripe.billingPortal.sessions.create({ + customer: customerId, + return_url: returnUrl, + }); + } + + async getSubscription(subscriptionId: string): Promise { + return this.stripe.subscriptions.retrieve(subscriptionId); + } + + async cancelSubscription(subscriptionId: string): Promise { + return this.stripe.subscriptions.update(subscriptionId, { + cancel_at_period_end: true, + }); + } + + constructWebhookEvent(payload: Buffer, signature: string): Stripe.Event { + const webhookSecret = this.configService.get('STRIPE_WEBHOOK_SECRET')!; + return this.stripe.webhooks.constructEvent(payload, signature, webhookSecret); + } +} diff --git a/apps/api/src/payments/webhooks.controller.ts b/apps/api/src/payments/webhooks.controller.ts new file mode 100644 index 0000000..1535132 --- /dev/null +++ b/apps/api/src/payments/webhooks.controller.ts @@ -0,0 +1,52 @@ +import { + Controller, + Post, + Headers, + Req, + HttpCode, + HttpStatus, + BadRequestException, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiExcludeEndpoint } from '@nestjs/swagger'; +import { Request } from 'express'; +import { PaymentsService } from './payments.service'; +import { StripeService } from './stripe.service'; + +@ApiTags('webhooks') +@Controller('webhooks') +export class WebhooksController { + constructor( + private paymentsService: PaymentsService, + private stripeService: StripeService + ) {} + + @Post('stripe') + @HttpCode(HttpStatus.OK) + @ApiExcludeEndpoint() + @ApiOperation({ summary: 'Handle Stripe webhooks' }) + async handleStripeWebhook( + @Req() req: Request, + @Headers('stripe-signature') signature: string + ) { + if (!signature) { + throw new BadRequestException('Missing stripe-signature header'); + } + + let event; + + try { + // req.body should be raw buffer for webhook verification + const rawBody = (req as Request & { rawBody?: Buffer }).rawBody; + if (!rawBody) { + throw new BadRequestException('Missing raw body'); + } + event = this.stripeService.constructWebhookEvent(rawBody, signature); + } catch (err) { + throw new BadRequestException(`Webhook signature verification failed: ${err}`); + } + + await this.paymentsService.handleWebhookEvent(event); + + return { received: true }; + } +} diff --git a/apps/api/src/search/meilisearch.service.ts b/apps/api/src/search/meilisearch.service.ts new file mode 100644 index 0000000..b3e4b99 --- /dev/null +++ b/apps/api/src/search/meilisearch.service.ts @@ -0,0 +1,156 @@ +import { Injectable, OnModuleInit, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { MeiliSearch, Index } from 'meilisearch'; + +export interface CourseDocument { + id: string; + title: string; + description: string | null; + slug: string; + authorId: string; + authorName: string | null; + status: string; + categoryId: string | null; + categoryName: string | null; + tags: string[]; + difficulty: string | null; + price: number | null; + isPublished: boolean; + createdAt: number; + updatedAt: number; +} + +@Injectable() +export class MeilisearchService implements OnModuleInit { + private readonly logger = new Logger(MeilisearchService.name); + private client: MeiliSearch; + private coursesIndex: Index | null = null; + private isAvailable = false; + + constructor(private configService: ConfigService) { + this.client = new MeiliSearch({ + host: this.configService.get('MEILISEARCH_HOST') || 'http://localhost:7700', + apiKey: this.configService.get('MEILISEARCH_API_KEY'), + }); + } + + async onModuleInit() { + try { + await this.setupIndexes(); + this.isAvailable = true; + this.logger.log('Meilisearch connected successfully'); + } catch (error) { + this.logger.warn('Meilisearch is not available. Search functionality will be disabled.'); + this.isAvailable = false; + } + } + + private async setupIndexes() { + // Create courses index + try { + await this.client.createIndex('courses', { primaryKey: 'id' }); + } catch { + // Index might already exist + } + + this.coursesIndex = this.client.index('courses'); + + // Configure searchable attributes + await this.coursesIndex.updateSearchableAttributes([ + 'title', + 'description', + 'tags', + 'authorName', + 'categoryName', + ]); + + // Configure filterable attributes + await this.coursesIndex.updateFilterableAttributes([ + 'authorId', + 'status', + 'categoryId', + 'tags', + 'difficulty', + 'isPublished', + 'price', + ]); + + // Configure sortable attributes + await this.coursesIndex.updateSortableAttributes([ + 'createdAt', + 'updatedAt', + 'price', + 'title', + ]); + + // Configure ranking rules + await this.coursesIndex.updateRankingRules([ + 'words', + 'typo', + 'proximity', + 'attribute', + 'sort', + 'exactness', + ]); + } + + async indexCourse(course: CourseDocument): Promise { + if (!this.isAvailable || !this.coursesIndex) return; + await this.coursesIndex.addDocuments([course]); + } + + async updateCourse(course: CourseDocument): Promise { + if (!this.isAvailable || !this.coursesIndex) return; + await this.coursesIndex.updateDocuments([course]); + } + + async deleteCourse(courseId: string): Promise { + if (!this.isAvailable || !this.coursesIndex) return; + await this.coursesIndex.deleteDocument(courseId); + } + + async searchCourses( + query: string, + options?: { + filter?: string; + sort?: string[]; + limit?: number; + offset?: number; + } + ) { + if (!this.isAvailable || !this.coursesIndex) { + return { + hits: [], + query, + processingTimeMs: 0, + total: 0, + }; + } + + const results = await this.coursesIndex.search(query, { + filter: options?.filter, + sort: options?.sort, + limit: options?.limit || 20, + offset: options?.offset || 0, + attributesToHighlight: ['title', 'description'], + }); + + return { + hits: results.hits, + query: results.query, + processingTimeMs: results.processingTimeMs, + total: results.estimatedTotalHits, + }; + } + + async indexAllCourses(courses: CourseDocument[]): Promise { + if (!this.isAvailable || !this.coursesIndex || courses.length === 0) return; + + // Batch index in chunks of 1000 + const chunkSize = 1000; + for (let i = 0; i < courses.length; i += chunkSize) { + const chunk = courses.slice(i, i + chunkSize); + await this.coursesIndex.addDocuments(chunk); + } + } +} diff --git a/apps/api/src/search/search.controller.ts b/apps/api/src/search/search.controller.ts new file mode 100644 index 0000000..8171a13 --- /dev/null +++ b/apps/api/src/search/search.controller.ts @@ -0,0 +1,58 @@ +import { + Controller, + Get, + Post, + Query, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { SearchService } from './search.service'; +import { Public } from '../auth/decorators/public.decorator'; + +@ApiTags('search') +@Controller('search') +export class SearchController { + constructor(private searchService: SearchService) {} + + @Get('courses') + @Public() + @ApiOperation({ summary: 'Search courses' }) + @ApiQuery({ name: 'q', required: true, description: 'Search query' }) + @ApiQuery({ name: 'categoryId', required: false }) + @ApiQuery({ name: 'difficulty', required: false, enum: ['beginner', 'intermediate', 'advanced'] }) + @ApiQuery({ name: 'tags', required: false, type: [String] }) + @ApiQuery({ name: 'priceMin', required: false, type: Number }) + @ApiQuery({ name: 'priceMax', required: false, type: Number }) + @ApiQuery({ name: 'sort', required: false, enum: ['newest', 'oldest', 'price_asc', 'price_desc', 'title_asc', 'title_desc'] }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + async searchCourses( + @Query('q') query: string, + @Query('categoryId') categoryId?: string, + @Query('difficulty') difficulty?: string, + @Query('tags') tags?: string[], + @Query('priceMin') priceMin?: number, + @Query('priceMax') priceMax?: number, + @Query('sort') sort?: string, + @Query('page') page?: number, + @Query('limit') limit?: number + ) { + return this.searchService.searchCourses(query, { + categoryId, + difficulty, + tags: tags ? (Array.isArray(tags) ? tags : [tags]) : undefined, + priceMin, + priceMax, + sort, + page, + limit, + publishedOnly: true, + }); + } + + @Post('reindex') + @ApiBearerAuth() + @ApiOperation({ summary: 'Reindex all courses (admin only)' }) + async reindexAll() { + return this.searchService.reindexAllCourses(); + } +} diff --git a/apps/api/src/search/search.module.ts b/apps/api/src/search/search.module.ts new file mode 100644 index 0000000..dfb8e68 --- /dev/null +++ b/apps/api/src/search/search.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { SearchController } from './search.controller'; +import { SearchService } from './search.service'; +import { MeilisearchService } from './meilisearch.service'; + +@Module({ + controllers: [SearchController], + providers: [SearchService, MeilisearchService], + exports: [SearchService, MeilisearchService], +}) +export class SearchModule {} diff --git a/apps/api/src/search/search.service.ts b/apps/api/src/search/search.service.ts new file mode 100644 index 0000000..2a3be03 --- /dev/null +++ b/apps/api/src/search/search.service.ts @@ -0,0 +1,181 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../common/prisma/prisma.service'; +import { MeilisearchService, CourseDocument } from './meilisearch.service'; + +@Injectable() +export class SearchService { + constructor( + private prisma: PrismaService, + private meilisearch: MeilisearchService + ) {} + + async searchCourses( + query: string, + options?: { + categoryId?: string; + difficulty?: string; + tags?: string[]; + priceMin?: number; + priceMax?: number; + authorId?: string; + publishedOnly?: boolean; + sort?: string; + page?: number; + limit?: number; + } + ) { + const filters: string[] = []; + + if (options?.publishedOnly !== false) { + filters.push('isPublished = true'); + } + + if (options?.categoryId) { + filters.push(`categoryId = "${options.categoryId}"`); + } + + if (options?.difficulty) { + filters.push(`difficulty = "${options.difficulty}"`); + } + + if (options?.tags && options.tags.length > 0) { + const tagFilters = options.tags.map((tag) => `tags = "${tag}"`).join(' OR '); + filters.push(`(${tagFilters})`); + } + + if (options?.priceMin !== undefined) { + filters.push(`price >= ${options.priceMin}`); + } + + if (options?.priceMax !== undefined) { + filters.push(`price <= ${options.priceMax}`); + } + + if (options?.authorId) { + filters.push(`authorId = "${options.authorId}"`); + } + + // Parse sort option + let sort: string[] | undefined; + if (options?.sort) { + switch (options.sort) { + case 'newest': + sort = ['createdAt:desc']; + break; + case 'oldest': + sort = ['createdAt:asc']; + break; + case 'price_asc': + sort = ['price:asc']; + break; + case 'price_desc': + sort = ['price:desc']; + break; + case 'title_asc': + sort = ['title:asc']; + break; + case 'title_desc': + sort = ['title:desc']; + break; + } + } + + const page = options?.page || 1; + const limit = options?.limit || 20; + const offset = (page - 1) * limit; + + const results = await this.meilisearch.searchCourses(query, { + filter: filters.length > 0 ? filters.join(' AND ') : undefined, + sort, + limit, + offset, + }); + + return { + data: results.hits, + meta: { + query: results.query, + processingTimeMs: results.processingTimeMs, + total: results.total, + page, + limit, + totalPages: Math.ceil((results.total || 0) / limit), + }, + }; + } + + async indexCourse(courseId: string) { + const course = await this.prisma.course.findUnique({ + where: { id: courseId }, + include: { + author: { + select: { id: true, name: true }, + }, + category: { + select: { id: true, name: true }, + }, + }, + }); + + if (!course) return; + + const document: CourseDocument = { + id: course.id, + title: course.title, + description: course.description, + slug: course.slug, + authorId: course.authorId, + authorName: course.author.name, + status: course.status, + categoryId: course.categoryId, + categoryName: course.category?.name || null, + tags: course.tags, + difficulty: course.difficulty, + price: course.price ? Number(course.price) : null, + isPublished: course.isPublished, + createdAt: course.createdAt.getTime(), + updatedAt: course.updatedAt.getTime(), + }; + + await this.meilisearch.indexCourse(document); + } + + async deleteCourseFromIndex(courseId: string) { + await this.meilisearch.deleteCourse(courseId); + } + + async reindexAllCourses() { + const courses = await this.prisma.course.findMany({ + include: { + author: { + select: { id: true, name: true }, + }, + category: { + select: { id: true, name: true }, + }, + }, + }); + + const documents: CourseDocument[] = courses.map((course) => ({ + id: course.id, + title: course.title, + description: course.description, + slug: course.slug, + authorId: course.authorId, + authorName: course.author.name, + status: course.status, + categoryId: course.categoryId, + categoryName: course.category?.name || null, + tags: course.tags, + difficulty: course.difficulty, + price: course.price ? Number(course.price) : null, + isPublished: course.isPublished, + createdAt: course.createdAt.getTime(), + updatedAt: course.updatedAt.getTime(), + })); + + await this.meilisearch.indexAllCourses(documents); + + return { indexed: documents.length }; + } +} diff --git a/apps/api/src/users/dto/create-user.dto.ts b/apps/api/src/users/dto/create-user.dto.ts new file mode 100644 index 0000000..4d6f4cc --- /dev/null +++ b/apps/api/src/users/dto/create-user.dto.ts @@ -0,0 +1,17 @@ +import { IsString, IsEmail, IsOptional } from 'class-validator'; + +export class CreateUserDto { + @IsString() + supabaseId: string; + + @IsEmail() + email: string; + + @IsOptional() + @IsString() + name?: string | null; + + @IsOptional() + @IsString() + avatarUrl?: string | null; +} diff --git a/apps/api/src/users/dto/update-settings.dto.ts b/apps/api/src/users/dto/update-settings.dto.ts new file mode 100644 index 0000000..d88c33a --- /dev/null +++ b/apps/api/src/users/dto/update-settings.dto.ts @@ -0,0 +1,35 @@ +import { IsString, IsOptional, IsBoolean, IsIn } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpdateSettingsDto { + @ApiPropertyOptional({ + description: 'Custom AI model override (e.g., "qwen/qwen3-coder-next")', + example: 'anthropic/claude-3.5-sonnet', + }) + @IsOptional() + @IsString() + customAiModel?: string; + + @ApiPropertyOptional({ description: 'Enable email notifications' }) + @IsOptional() + @IsBoolean() + emailNotifications?: boolean; + + @ApiPropertyOptional({ description: 'Enable marketing emails' }) + @IsOptional() + @IsBoolean() + marketingEmails?: boolean; + + @ApiPropertyOptional({ + description: 'UI theme', + enum: ['light', 'dark', 'system'], + }) + @IsOptional() + @IsIn(['light', 'dark', 'system']) + theme?: 'light' | 'dark' | 'system'; + + @ApiPropertyOptional({ description: 'Preferred language' }) + @IsOptional() + @IsString() + language?: string; +} diff --git a/apps/api/src/users/dto/update-user.dto.ts b/apps/api/src/users/dto/update-user.dto.ts new file mode 100644 index 0000000..be65fbc --- /dev/null +++ b/apps/api/src/users/dto/update-user.dto.ts @@ -0,0 +1,14 @@ +import { IsString, IsOptional, IsUrl } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpdateUserDto { + @ApiPropertyOptional({ description: 'User display name' }) + @IsOptional() + @IsString() + name?: string; + + @ApiPropertyOptional({ description: 'User avatar URL' }) + @IsOptional() + @IsUrl() + avatarUrl?: string; +} diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts new file mode 100644 index 0000000..dba1e29 --- /dev/null +++ b/apps/api/src/users/users.controller.ts @@ -0,0 +1,60 @@ +import { + Controller, + Get, + Patch, + Body, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { UsersService } from './users.service'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { User } from '@coursecraft/database'; +import { UpdateUserDto } from './dto/update-user.dto'; +import { UpdateSettingsDto } from './dto/update-settings.dto'; + +@ApiTags('users') +@Controller('users') +@ApiBearerAuth() +export class UsersController { + constructor(private usersService: UsersService) {} + + @Get('profile') + @ApiOperation({ summary: 'Get user profile' }) + async getProfile(@CurrentUser() user: User) { + const fullUser = await this.usersService.findById(user.id); + const subscription = await this.usersService.getSubscriptionInfo(user.id); + + return { + id: fullUser!.id, + email: fullUser!.email, + name: fullUser!.name, + avatarUrl: fullUser!.avatarUrl, + subscriptionTier: fullUser!.subscriptionTier, + subscription, + createdAt: fullUser!.createdAt, + }; + } + + @Patch('profile') + @ApiOperation({ summary: 'Update user profile' }) + async updateProfile( + @CurrentUser() user: User, + @Body() dto: UpdateUserDto + ) { + return this.usersService.update(user.id, dto); + } + + @Get('settings') + @ApiOperation({ summary: 'Get user settings' }) + async getSettings(@CurrentUser() user: User) { + return this.usersService.getSettings(user.id); + } + + @Patch('settings') + @ApiOperation({ summary: 'Update user settings' }) + async updateSettings( + @CurrentUser() user: User, + @Body() dto: UpdateSettingsDto + ) { + return this.usersService.updateSettings(user.id, dto); + } +} diff --git a/apps/api/src/users/users.module.ts b/apps/api/src/users/users.module.ts new file mode 100644 index 0000000..513776d --- /dev/null +++ b/apps/api/src/users/users.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; + +@Module({ + controllers: [UsersController], + providers: [UsersService], + exports: [UsersService], +}) +export class UsersModule {} diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts new file mode 100644 index 0000000..6d0ee7a --- /dev/null +++ b/apps/api/src/users/users.service.ts @@ -0,0 +1,150 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../common/prisma/prisma.service'; +import { User, UserSettings, SubscriptionTier } from '@coursecraft/database'; +import { CreateUserDto } from './dto/create-user.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; +import { UpdateSettingsDto } from './dto/update-settings.dto'; + +@Injectable() +export class UsersService { + constructor(private prisma: PrismaService) {} + + async create(data: CreateUserDto): Promise { + return this.prisma.user.create({ + data: { + supabaseId: data.supabaseId, + email: data.email, + name: data.name, + avatarUrl: data.avatarUrl, + settings: { + create: {}, // Create default settings + }, + subscription: { + create: { + tier: SubscriptionTier.FREE, + }, + }, + }, + include: { + settings: true, + subscription: true, + }, + }); + } + + async findById(id: string): Promise { + return this.prisma.user.findUnique({ + where: { id }, + include: { + settings: true, + subscription: true, + }, + }); + } + + async findBySupabaseId(supabaseId: string): Promise { + return this.prisma.user.findUnique({ + where: { supabaseId }, + include: { + settings: true, + subscription: true, + }, + }); + } + + async findByEmail(email: string): Promise { + return this.prisma.user.findUnique({ + where: { email }, + }); + } + + async update(id: string, data: UpdateUserDto): Promise { + const user = await this.findById(id); + if (!user) { + throw new NotFoundException('User not found'); + } + + return this.prisma.user.update({ + where: { id }, + data: { + name: data.name, + avatarUrl: data.avatarUrl, + }, + }); + } + + async getSettings(userId: string): Promise { + const settings = await this.prisma.userSettings.findUnique({ + where: { userId }, + }); + + if (!settings) { + // Create default settings if not found + return this.prisma.userSettings.create({ + data: { userId }, + }); + } + + return settings; + } + + async updateSettings(userId: string, data: UpdateSettingsDto): Promise { + return this.prisma.userSettings.upsert({ + where: { userId }, + create: { + userId, + ...data, + }, + update: data, + }); + } + + async getSubscriptionInfo(userId: string) { + const subscription = await this.prisma.subscription.findUnique({ + where: { userId }, + }); + + if (!subscription) { + // Return default FREE subscription info + return { + tier: SubscriptionTier.FREE, + status: 'active', + currentPeriodEnd: null, + cancelAtPeriodEnd: false, + coursesCreatedThisMonth: 0, + coursesLimit: 2, + }; + } + + const limits = { + [SubscriptionTier.FREE]: 2, + [SubscriptionTier.PREMIUM]: 5, + [SubscriptionTier.PRO]: 15, + }; + + return { + tier: subscription.tier, + status: subscription.status, + currentPeriodEnd: subscription.currentPeriodEnd, + cancelAtPeriodEnd: subscription.cancelAtPeriodEnd, + coursesCreatedThisMonth: subscription.coursesCreatedThisMonth, + coursesLimit: limits[subscription.tier], + }; + } + + async canCreateCourse(userId: string): Promise { + const subscription = await this.getSubscriptionInfo(userId); + return subscription.coursesCreatedThisMonth < subscription.coursesLimit; + } + + async incrementCoursesCreated(userId: string): Promise { + await this.prisma.subscription.update({ + where: { userId }, + data: { + coursesCreatedThisMonth: { + increment: 1, + }, + }, + }); + } +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..9013387 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "baseUrl": "./", + "module": "CommonJS", + "moduleResolution": "Node", + "isolatedModules": false, + "strictPropertyInitialization": false, + "noUnusedLocals": false, + "noUnusedParameters": false, + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/apps/web/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/apps/web/next.config.js b/apps/web/next.config.js new file mode 100644 index 0000000..ecc6352 --- /dev/null +++ b/apps/web/next.config.js @@ -0,0 +1,19 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + transpilePackages: ['@coursecraft/shared'], + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: '**.supabase.co', + }, + { + protocol: 'https', + hostname: '**.r2.cloudflarestorage.com', + }, + ], + }, +}; + +module.exports = nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..196a2c9 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,68 @@ +{ + "name": "@coursecraft/web", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev -p 3125", + "build": "next build", + "start": "next start -p 3125", + "lint": "next lint", + "clean": "rm -rf .next .turbo node_modules" + }, + "dependencies": { + "@coursecraft/shared": "workspace:*", + "@hookform/resolvers": "^3.3.4", + "@radix-ui/react-accordion": "^1.1.2", + "@radix-ui/react-alert-dialog": "^1.0.5", + "@radix-ui/react-avatar": "^1.0.4", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-navigation-menu": "^1.1.4", + "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-progress": "^1.0.3", + "@radix-ui/react-radio-group": "^1.1.3", + "@radix-ui/react-scroll-area": "^1.0.5", + "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-separator": "^1.0.3", + "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-tabs": "^1.0.4", + "@radix-ui/react-toast": "^1.1.5", + "@radix-ui/react-tooltip": "^1.0.7", + "@supabase/ssr": "^0.1.0", + "@supabase/supabase-js": "^2.39.0", + "@tailwindcss/typography": "^0.5.19", + "@tiptap/extension-image": "^2.2.0", + "@tiptap/extension-link": "^2.2.0", + "@tiptap/extension-placeholder": "^2.2.0", + "@tiptap/extension-underline": "^2.2.0", + "@tiptap/pm": "^2.2.0", + "@tiptap/react": "^2.2.0", + "@tiptap/starter-kit": "^2.2.0", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "framer-motion": "^11.0.0", + "lucide-react": "^0.334.0", + "mermaid": "^11.12.2", + "next": "14.1.0", + "next-themes": "^0.2.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-hook-form": "^7.50.0", + "tailwind-merge": "^2.2.1", + "tailwindcss-animate": "^1.0.7", + "zod": "^3.22.4", + "zustand": "^4.5.0" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "autoprefixer": "^10.4.17", + "eslint": "^8.57.0", + "eslint-config-next": "14.1.0", + "postcss": "^8.4.35", + "tailwindcss": "^3.4.1", + "typescript": "^5.4.0" + } +} diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/apps/web/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/web/src/app/(auth)/layout.tsx b/apps/web/src/app/(auth)/layout.tsx new file mode 100644 index 0000000..36d623a --- /dev/null +++ b/apps/web/src/app/(auth)/layout.tsx @@ -0,0 +1,13 @@ +export default function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+ {children} +
+
+ ); +} diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..59d0da1 --- /dev/null +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -0,0 +1,133 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { Sparkles, Mail, Lock, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { useToast } from '@/components/ui/use-toast'; +import { useAuth } from '@/contexts/auth-context'; + +export default function LoginPage() { + const router = useRouter(); + const { toast } = useToast(); + const { signIn } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + + try { + const { error } = await signIn(email, password); + + if (error) { + toast({ + title: 'Ошибка входа', + description: error.message || 'Неверный email или пароль.', + variant: 'destructive', + }); + return; + } + + toast({ + title: 'Добро пожаловать!', + description: 'Вы успешно вошли в систему.', + }); + + router.push('/dashboard'); + } catch (error) { + toast({ + title: 'Ошибка', + description: 'Произошла непредвиденная ошибка.', + variant: 'destructive', + }); + } finally { + setIsLoading(false); + } + }; + + return ( + + + +
+ +
+ CourseCraft + + Вход в аккаунт + + Введите email и пароль для входа + +
+
+ +
+ +
+ + setEmail(e.target.value)} + required + disabled={isLoading} + /> +
+
+
+
+ + + Забыли пароль? + +
+
+ + setPassword(e.target.value)} + required + disabled={isLoading} + /> +
+
+
+ + +

+ Нет аккаунта?{' '} + + Зарегистрироваться + +

+
+
+
+ ); +} diff --git a/apps/web/src/app/(auth)/register/page.tsx b/apps/web/src/app/(auth)/register/page.tsx new file mode 100644 index 0000000..2f878d2 --- /dev/null +++ b/apps/web/src/app/(auth)/register/page.tsx @@ -0,0 +1,179 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { Sparkles, Mail, Lock, User, Loader2, Check } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { useToast } from '@/components/ui/use-toast'; +import { useAuth } from '@/contexts/auth-context'; + +export default function RegisterPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { toast } = useToast(); + const { signUp } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + + const plan = searchParams.get('plan'); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + + try { + const { error } = await signUp(email, password, name); + + if (error) { + toast({ + title: 'Ошибка регистрации', + description: error.message || 'Не удалось создать аккаунт.', + variant: 'destructive', + }); + return; + } + + toast({ + title: 'Аккаунт создан!', + description: 'Проверьте email для подтверждения (если включено) или войдите в систему.', + }); + + router.push('/dashboard'); + } catch (error) { + toast({ + title: 'Ошибка', + description: 'Произошла непредвиденная ошибка.', + variant: 'destructive', + }); + } finally { + setIsLoading(false); + } + }; + + return ( + + + +
+ +
+ CourseCraft + + Создать аккаунт + + {plan ? ( + <>Регистрация с планом {plan} + ) : ( + 'Начните создавать курсы с AI бесплатно' + )} + +
+
+ +
+ +
+ + setName(e.target.value)} + required + disabled={isLoading} + /> +
+
+
+ +
+ + setEmail(e.target.value)} + required + disabled={isLoading} + /> +
+
+
+ +
+ + setPassword(e.target.value)} + minLength={6} + required + disabled={isLoading} + /> +
+
+ + {/* Benefits */} +
+

Что вы получите:

+
    +
  • + + 2 бесплатных курса в месяц +
  • +
  • + + AI-генерация контента +
  • +
  • + + WYSIWYG редактор +
  • +
+
+
+ + +

+ Уже есть аккаунт?{' '} + + Войти + +

+

+ Регистрируясь, вы соглашаетесь с{' '} + + условиями использования + {' '} + и{' '} + + политикой конфиденциальности + +

+
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/dashboard/billing/page.tsx b/apps/web/src/app/(dashboard)/dashboard/billing/page.tsx new file mode 100644 index 0000000..9207120 --- /dev/null +++ b/apps/web/src/app/(dashboard)/dashboard/billing/page.tsx @@ -0,0 +1,139 @@ +'use client'; + +import { Check, Zap } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { Progress } from '@/components/ui/progress'; +import { cn } from '@/lib/utils'; +import { SUBSCRIPTION_PLANS, formatPlanPrice } from '@coursecraft/shared'; + +const locale = 'ru'; + +const plans = SUBSCRIPTION_PLANS.map((plan) => ({ + tier: plan.tier, + name: plan.nameRu, + priceFormatted: formatPlanPrice(plan, locale).formatted, + features: plan.featuresRu, +})); + +const currentPlan = { + tier: 'FREE' as const, + coursesUsed: 1, + coursesLimit: 2, + renewalDate: null as string | null, +}; + +export default function BillingPage() { + const usagePercent = (currentPlan.coursesUsed / currentPlan.coursesLimit) * 100; + + return ( +
+
+

Подписка

+

+ Управляйте вашей подпиской и лимитами +

+
+ + {/* Current usage */} + + + Текущее использование + + Ваш тарифный план: {plans.find((p) => p.tier === currentPlan.tier)?.name} + + + +
+
+ Курсы в этом месяце + + {currentPlan.coursesUsed} / {currentPlan.coursesLimit} + +
+ +
+ {currentPlan.renewalDate && ( +

+ Следующее обновление: {currentPlan.renewalDate} +

+ )} +
+
+ + {/* Plans */} +
+ {plans.map((plan) => { + const isCurrent = plan.tier === currentPlan.tier; + const isUpgrade = + (currentPlan.tier === 'FREE' && plan.tier !== 'FREE') || + (currentPlan.tier === 'PREMIUM' && plan.tier === 'PRO'); + + return ( + + + + {plan.name} + {isCurrent && ( + + Текущий + + )} + + + {plan.priceFormatted} + {plan.priceFormatted !== 'Бесплатно' && ( + /месяц + )} + + + +
    + {plan.features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+
+ + {isCurrent ? ( + + ) : isUpgrade ? ( + + ) : ( + + )} + +
+ ); + })} +
+ + {currentPlan.tier !== 'FREE' && ( + + + Управление подпиской + + + + + + + )} +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/dashboard/courses/[id]/edit/page.tsx b/apps/web/src/app/(dashboard)/dashboard/courses/[id]/edit/page.tsx new file mode 100644 index 0000000..18ff9ea --- /dev/null +++ b/apps/web/src/app/(dashboard)/dashboard/courses/[id]/edit/page.tsx @@ -0,0 +1,218 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import { ChevronLeft, ChevronRight, Save, Wand2, Eye, Pencil } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { CourseEditor } from '@/components/editor/course-editor'; +import { LessonContentViewer } from '@/components/editor/lesson-content-viewer'; +import { LessonSidebar } from '@/components/editor/lesson-sidebar'; +import { cn } from '@/lib/utils'; +import { api } from '@/lib/api'; +import { useAuth } from '@/contexts/auth-context'; + +type Lesson = { id: string; title: string }; +type Chapter = { id: string; title: string; lessons: Lesson[] }; +type CourseData = { id: string; title: string; chapters: Chapter[] }; + +const emptyDoc = { type: 'doc', content: [] }; + +export default function CourseEditPage() { + const params = useParams(); + const { loading: authLoading } = useAuth(); + const courseId = params?.id as string; + + const [course, setCourse] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [sidebarOpen, setSidebarOpen] = useState(true); + const [activeLesson, setActiveLesson] = useState<{ chapterId: string; lessonId: string } | null>(null); + const [content, setContent] = useState>(emptyDoc); + const [contentLoading, setContentLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [readOnly, setReadOnly] = useState(false); + + useEffect(() => { + if (!courseId || authLoading) return; + let cancelled = false; + (async () => { + setLoading(true); + setError(null); + try { + const data = await api.getCourse(courseId); + if (!cancelled) { + setCourse(data); + const firstChapter = data.chapters?.[0]; + const firstLesson = firstChapter?.lessons?.[0]; + if (firstChapter && firstLesson) { + setActiveLesson({ chapterId: firstChapter.id, lessonId: firstLesson.id }); + } + } + } catch (e: any) { + if (!cancelled) setError(e?.message || 'Не удалось загрузить курс'); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [courseId, authLoading]); + + // Load lesson content when active lesson changes + useEffect(() => { + if (!courseId || !activeLesson) { + setContent(emptyDoc); + return; + } + let cancelled = false; + setContentLoading(true); + (async () => { + try { + const lessonData = await api.getLesson(courseId, activeLesson.lessonId); + if (!cancelled && lessonData?.content) { + setContent( + typeof lessonData.content === 'object' && lessonData.content !== null + ? (lessonData.content as Record) + : emptyDoc + ); + } else if (!cancelled) { + setContent(emptyDoc); + } + } catch { + if (!cancelled) setContent(emptyDoc); + } finally { + if (!cancelled) setContentLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [courseId, activeLesson?.lessonId]); + + const handleSelectLesson = (lessonId: string) => { + if (!course) return; + for (const ch of course.chapters) { + const lesson = ch.lessons.find((l) => l.id === lessonId); + if (lesson) { + setActiveLesson({ chapterId: ch.id, lessonId: lesson.id }); + return; + } + } + }; + + const handleSave = async () => { + if (!courseId || !activeLesson || saving) return; + setSaving(true); + try { + await api.updateLesson(courseId, activeLesson.lessonId, { content }); + } catch (e: any) { + console.error('Save failed:', e); + } finally { + setSaving(false); + } + }; + + if (authLoading || loading) { + return ( +
+

Загрузка курса...

+
+ ); + } + + if (error || !course) { + return ( +
+

{error || 'Курс не найден'}

+
+ ); + } + + const flatLessons = course.chapters.flatMap((ch) => + ch.lessons.map((l) => ({ ...l, chapterId: ch.id })) + ); + const activeLessonMeta = activeLesson + ? flatLessons.find((l) => l.id === activeLesson.lessonId) + : null; + + return ( +
+
+ {sidebarOpen && ( + + )} +
+ + + +
+
+

+ {activeLessonMeta?.title ?? 'Выберите урок'} +

+
+ {readOnly ? ( + <> + + + + ) : ( + <> + + + + + )} +
+
+ +
+
+ {contentLoading ? ( +

Загрузка контента...

+ ) : readOnly ? ( + + ) : ( + + )} +
+
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/dashboard/courses/[id]/page.tsx b/apps/web/src/app/(dashboard)/dashboard/courses/[id]/page.tsx new file mode 100644 index 0000000..b73e1be --- /dev/null +++ b/apps/web/src/app/(dashboard)/dashboard/courses/[id]/page.tsx @@ -0,0 +1,235 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { useParams, useRouter } from 'next/navigation'; +import { ArrowLeft, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; +import { api } from '@/lib/api'; +import { useAuth } from '@/contexts/auth-context'; +import { LessonContentViewer } from '@/components/editor/lesson-content-viewer'; +import { LessonSidebar } from '@/components/editor/lesson-sidebar'; +import { cn } from '@/lib/utils'; + +type Lesson = { id: string; title: string; durationMinutes?: number | null; order: number }; +type Chapter = { id: string; title: string; description?: string | null; order: number; lessons: Lesson[] }; +type CourseData = { + id: string; + title: string; + description?: string | null; + status: string; + chapters: Chapter[]; +}; + +export default function CoursePage() { + const params = useParams(); + const router = useRouter(); + const { loading: authLoading } = useAuth(); + const id = params?.id as string; + const [course, setCourse] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [deleting, setDeleting] = useState(false); + const [sidebarOpen, setSidebarOpen] = useState(true); + const [selectedLessonId, setSelectedLessonId] = useState(null); + const [lessonContent, setLessonContent] = useState | null>(null); + const [lessonContentLoading, setLessonContentLoading] = useState(false); + + useEffect(() => { + if (!id || authLoading) return; + let cancelled = false; + (async () => { + setLoading(true); + setError(null); + try { + const data = await api.getCourse(id); + if (!cancelled) { + setCourse(data); + const first = data.chapters?.[0]?.lessons?.[0]; + if (first) setSelectedLessonId(first.id); + } + } catch (e: any) { + if (!cancelled) setError(e?.message || 'Не удалось загрузить курс'); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [id, authLoading]); + + useEffect(() => { + if (!id || !selectedLessonId) { + setLessonContent(null); + return; + } + let cancelled = false; + setLessonContentLoading(true); + (async () => { + try { + const data = await api.getLesson(id, selectedLessonId); + const content = data?.content; + if (!cancelled) + setLessonContent( + typeof content === 'object' && content !== null ? (content as Record) : null + ); + } catch { + if (!cancelled) setLessonContent(null); + } finally { + if (!cancelled) setLessonContentLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [id, selectedLessonId]); + + const handleDelete = async () => { + if (!course?.id || deleting) return; + setDeleting(true); + try { + await api.deleteCourse(course.id); + router.push('/dashboard'); + router.refresh(); + } catch (e: any) { + setError(e?.message || 'Не удалось удалить курс'); + } finally { + setDeleting(false); + } + }; + + if (authLoading || loading) { + return ( +
+

Загрузка курса...

+
+ ); + } + + if (error || !course) { + return ( +
+ +

{error || 'Курс не найден'}

+
+ ); + } + + const activeLessonTitle = selectedLessonId + ? (() => { + for (const ch of course.chapters) { + const lesson = ch.lessons.find((l) => l.id === selectedLessonId); + if (lesson) return lesson.title; + } + return null; + })() + : null; + + return ( +
+ {/* Top bar */} +
+
+ + / + {course.title} +
+
+ + + + + + + + Удалить курс? + + Курс «{course.title}» будет удалён безвозвратно. + + + + Отмена + { e.preventDefault(); handleDelete(); }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + disabled={deleting} + > + {deleting ? 'Удаление...' : 'Удалить'} + + + + +
+
+ +
+ {/* Left: list of lessons (paragraphs) */} +
+ {sidebarOpen && ( + + )} +
+ + + + {/* Center: lesson content (read-only) */} +
+
+ {activeLessonTitle && ( +

{activeLessonTitle}

+ )} + {lessonContentLoading ? ( +

Загрузка...

+ ) : selectedLessonId ? ( + + ) : ( +

Выберите урок в списке слева.

+ )} +
+
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/dashboard/courses/new/page.tsx b/apps/web/src/app/(dashboard)/dashboard/courses/new/page.tsx new file mode 100644 index 0000000..95704d0 --- /dev/null +++ b/apps/web/src/app/(dashboard)/dashboard/courses/new/page.tsx @@ -0,0 +1,512 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Send, Sparkles, Loader2, Check, ArrowRight, X, AlertCircle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { Progress } from '@/components/ui/progress'; +import { cn } from '@/lib/utils'; +import { api } from '@/lib/api'; +import { useToast } from '@/components/ui/use-toast'; + +type Step = 'prompt' | 'questions' | 'generating' | 'complete' | 'error'; + +interface ClarifyingQuestion { + id: string; + question: string; + type: 'single_choice' | 'multiple_choice' | 'text'; + options?: string[]; + required: boolean; +} + +export default function NewCoursePage() { + const router = useRouter(); + const { toast } = useToast(); + const [step, setStep] = useState('prompt'); + const [prompt, setPrompt] = useState(''); + const [generationId, setGenerationId] = useState(null); + const [questions, setQuestions] = useState([]); + const [answers, setAnswers] = useState>({}); + const [progress, setProgress] = useState(0); + const [currentStepText, setCurrentStepText] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + const [courseId, setCourseId] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Poll for generation status + const pollStatus = useCallback(async () => { + if (!generationId) return; + + try { + const status = await api.getGenerationStatus(generationId); + + setProgress(status.progress); + setCurrentStepText(status.currentStep || ''); + + // Normalize status to uppercase for comparison + const normalizedStatus = status.status?.toUpperCase(); + + switch (normalizedStatus) { + case 'WAITING_FOR_ANSWERS': + if (status.questions) { + // questions can be array or object with questions array + const questionsArray = Array.isArray(status.questions) + ? status.questions + : (status.questions as any)?.questions || []; + if (questionsArray.length > 0) { + setQuestions(questionsArray as ClarifyingQuestion[]); + setStep('questions'); + } + } + break; + case 'COMPLETED': + setStep('complete'); + if (status.course) { + setCourseId(status.course.id); + } + break; + case 'FAILED': + case 'CANCELLED': + setStep('error'); + setErrorMessage(status.errorMessage || 'Генерация не удалась'); + break; + default: + // Continue polling for other statuses (PENDING, ANALYZING, ASKING_QUESTIONS, RESEARCHING, GENERATING_OUTLINE, GENERATING_CONTENT) + if (step !== 'questions') { + setStep('generating'); + } + } + } catch (error) { + console.error('Failed to get status:', error); + } + }, [generationId, step]); + + // Start polling when we have a generation ID + useEffect(() => { + if (!generationId || step === 'complete' || step === 'error' || step === 'questions') { + return; + } + + const interval = setInterval(pollStatus, 2000); + return () => clearInterval(interval); + }, [generationId, step, pollStatus]); + + const handleSubmitPrompt = async () => { + if (!prompt.trim() || isSubmitting) return; + + setIsSubmitting(true); + try { + const result = await api.startGeneration(prompt); + setGenerationId(result.id); + setStep('generating'); + setProgress(result.progress); + + // Start polling immediately + setTimeout(pollStatus, 1000); + } catch (error: any) { + toast({ + title: 'Ошибка', + description: error.message || 'Не удалось начать генерацию', + variant: 'destructive', + }); + } finally { + setIsSubmitting(false); + } + }; + + const handleAnswerQuestion = (questionId: string, answer: string) => { + setAnswers((prev) => ({ ...prev, [questionId]: answer })); + }; + + const handleAnswerMultiple = (questionId: string, option: string) => { + setAnswers((prev) => { + const current = prev[questionId] as string[] || []; + const updated = current.includes(option) + ? current.filter((o) => o !== option) + : [...current, option]; + return { ...prev, [questionId]: updated }; + }); + }; + + const handleSubmitAnswers = async () => { + if (!generationId || isSubmitting) return; + + setIsSubmitting(true); + try { + await api.answerQuestions(generationId, answers); + setStep('generating'); + + // Resume polling + setTimeout(pollStatus, 1000); + } catch (error: any) { + toast({ + title: 'Ошибка', + description: error.message || 'Не удалось отправить ответы', + variant: 'destructive', + }); + } finally { + setIsSubmitting(false); + } + }; + + const handleCancel = async () => { + if (!generationId) return; + + try { + await api.cancelGeneration(generationId); + router.push('/dashboard'); + } catch (error) { + router.push('/dashboard'); + } + }; + + const handleRetry = () => { + setStep('prompt'); + setGenerationId(null); + setQuestions([]); + setAnswers({}); + setProgress(0); + setCurrentStepText(''); + setErrorMessage(''); + setCourseId(null); + }; + + const allRequiredAnswered = questions + .filter((q) => q.required) + .every((q) => { + const answer = answers[q.id]; + if (Array.isArray(answer)) return answer.length > 0; + return Boolean(answer); + }); + + return ( +
+
+

Создать новый курс

+

+ Опишите тему курса, и AI создаст его за вас +

+
+ + + {/* Step 1: Prompt */} + {step === 'prompt' && ( + + + +
+
+ +
+
+

+ Привет! Я помогу вам создать курс. Просто опишите, о чём + должен быть ваш курс. +

+