diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d825edb --- /dev/null +++ b/.env.example @@ -0,0 +1,66 @@ +# ===== Server ===== +NODE_ENV=production +PORT=3001 +APP_BASE_URL=http://localhost:3001 + +# ===== DB ===== +DATABASE_URL=postgresql://blog:blog@postgres:5432/linkshare?schema=public + +# ===== Redis (BullMQ) ===== +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_PASSWORD= + +# ===== Session (后端登录态 cookie) ===== +SESSION_COOKIE_SECRET=change_me +SESSION_COOKIE_NAME=linkshare_session +SESSION_COOKIE_SECURE=false +SESSION_COOKIE_HTTPONLY=true + +# ===== OAuth2 Provider ===== +OAUTH2_ISSUER=http://localhost:3001 + +OAUTH2_ACCESS_TOKEN_TTL_SECONDS=3600 +OAUTH2_REFRESH_TOKEN_TTL_SECONDS=2592000 + +OAUTH2_TOKEN_SIGNING_PRIVATE_KEY=-----BEGIN PRIVATE KEY----- +... +-----END PRIVATE KEY----- +OAUTH2_TOKEN_SIGNING_PUBLIC_KEY=-----BEGIN PUBLIC KEY----- +... +-----END PUBLIC KEY----- + +# Provider default client(给 web/admin 使用) +OAUTH2_DEFAULT_CLIENT_ID=web-client +OAUTH2_DEFAULT_CLIENT_SECRET=change_me +OAUTH2_DEFAULT_REDIRECT_URI=http://localhost:3000/auth/callback + +# ===== AI (BYOK via OpenRouter/AiMixHub) ===== +AI_PROVIDER=openrouter +AI_API_KEY=change_me +AI_BASE_URL=https://openrouter.ai/api/v1 +AI_MODEL_NAME=meta-llama/llama-3.1-70b-instruct + +AI_REVIEW_ENABLED=true +AI_REVIEW_TEMPERATURE=0.2 + +# ===== SMTP ===== +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=user@example.com +SMTP_PASS=change_me +SMTP_FROM="LinkShare Blog " + +# ===== Visitor analytics cookie ===== +VISITOR_COOKIE_NAME=linkshare_visitor +VISITOR_COOKIE_MAX_AGE_DAYS=365 + +# ===== Link access ===== +LINK_URL_MODE_DEFAULT=slug_token + +# ===== Security ===== +PASSWORD_SALT_ROUNDS=12 + +# ===== Rate Limiting ===== +THROTTLE_TTL=60000 +THROTTLE_LIMIT=100 diff --git a/.opencode/plans/PLAN.md b/.opencode/plans/PLAN.md new file mode 100644 index 0000000..ca7b134 --- /dev/null +++ b/.opencode/plans/PLAN.md @@ -0,0 +1,837 @@ +# Nuxt4 + Nuxt-UI 博客前端开发提示词 + +## 项目概述 + +```markdown +# 任务:构建 LinkShare Blog 前端应用 + +## 技术栈 + +- **框架**: Nuxt 4 (Vue 3 + Vite) +- **UI 库**: Nuxt UI (基于 Tailwind CSS + Headless UI) +- **状态管理**: Pinia +- **HTTP 客户端**: fetch / ofetch +- **Markdown 渲染**: @nuxt/content 或 markdown-it +- **代码规范**: ESLint + Prettier + +## 后端 API 信息 + +- **地址**: http://localhost:3001 (开发) / https://api.example.com (生产) +- **认证**: Session Cookie (HttpOnly) +- **文档**: http://localhost:3001/api-docs +``` + +--- + +## 1. 项目初始化 + +```markdown +请创建一个 Nuxt 4 项目,要求: + +1. 安装 Nuxt UI: `npx nuxi@latest module add ui` +2. 配置 Tailwind CSS +3. 配置 Pinia 状态管理 +4. 设置 API 请求拦截器(携带 Cookie) +5. 配置路由中间件(认证守卫) +6. 创建布局文件:default.vue、admin.vue + +目录结构: +``` + +├── components/ +│ ├── ui/ # Nuxt UI 组件封装 +│ ├── layout/ # 布局组件 +│ ├── article/ # 文章相关组件 +│ ├── comment/ # 评论相关组件 +│ └── admin/ # 管理后台组件 +├── composables/ +│ ├── useAuth.ts # 认证逻辑 +│ ├── useApi.ts # API 请求封装 +│ └── useArticles.ts # 文章数据获取 +├── layouts/ +│ ├── default.vue # 默认布局 +│ └── admin.vue # 管理后台布局 +├── middleware/ +│ ├── auth.global.ts # 全局认证检查 +│ └── admin.ts # 管理员权限检查 +├── pages/ +│ ├── index.vue # 首页(文章列表) +│ ├── article/ +│ │ └── [slug].vue # 文章详情 +│ ├── login.vue # 登录页 +│ ├── create.vue # 创建文章 +│ └── admin/ +│ ├── dashboard.vue # 管理后台首页 +│ ├── articles.vue # 文章管理 +│ ├── comments.vue # 评论管理 +│ └── users.vue # 用户管理 +├── stores/ +│ ├── auth.ts # 认证状态 +│ └── article.ts # 文章状态 +├── types/ +│ ├── api.ts # API 响应类型 +│ └── models.ts # 数据模型 +└── utils/ +├── constants.ts # 常量配置 +└── validators.ts # 表单验证 + +``` + +``` + +--- + +## 2. API 类型定义 + +```markdown +请根据后端 API 创建 TypeScript 类型定义: + +### 用户模型 (User) + +- id: number +- email: string +- username: string +- displayName: string | null +- avatarUrl: string | null +- role: 'admin' | 'moderator' | 'user' +- isActive: boolean +- createdAt: DateTime +- updatedAt: DateTime + +### 文章模型 (Article) + +- id: number +- slug: string +- title: string +- content: string | null (Markdown) +- excerpt: string | null +- coverImageUrl: string | null +- status: 'draft' | 'published' | 'archived' +- visibility: 'public' | 'unlisted' | 'private' +- viewCount: number +- publishedAt: DateTime | null +- createdAt: DateTime +- updatedAt: DateTime +- author: User (仅 id, username, displayName, avatarUrl) +- comments: Comment[] (已审核) +- reactions: Reaction[] + +### 评论模型 (Comment) + +- id: number +- articleId: number +- parentId: number | null +- content: string +- status: 'pending' | 'approved' | 'rejected' | 'suspicious' +- authorName: string | null +- authorEmail: string | null +- authorId: number | null +- createdAt: DateTime +- updatedAt: DateTime +- author: User | null +- replies: Comment[] + +### 分页响应 + +{ +items: T[] +total: number +page: number +limit: number +totalPages: number +} + +### API 端点 + +#### 认证 + +- POST /login - { emailOrUsername, password } → { user } +- POST /logout - → { ok: true } +- GET /me - → { user } + +#### 文章 + +- GET /articles?page=1&limit=20&status=&visibility=&authorId= +- GET /articles/:id +- GET /articles/slug/:slug +- POST /articles (需登录) - { title, content, excerpt?, coverImageUrl?, status, visibility } +- PUT /articles/:id (作者/管理员) +- DELETE /articles/:id (作者/管理员) + +#### 评论 + +- GET /articles/:id/comments?status=approved +- POST /articles/:id/comments (需登录) - { content } + +#### 管理后台 (需 admin 角色) + +- GET /admin/comments?page=&limit=&articleId=&status=&authorId= +- PUT /admin/comments/:id/status?status=approved|rejected +- DELETE /admin/comments/:id +- GET /admin/comments/:id/audits +- GET /admin/articles?page=&limit=&status= +- GET /analytics-summary?days=30 +``` + +--- + +## 3. 认证 Composable + +```markdown +创建 useAuth composable,实现: + +### 状态管理 + +- user: Ref +- isAuthenticated: ComputedRef +- isAdmin: ComputedRef +- isLoading: Ref + +### 方法 + +- login(credentials): Promise +- logout(): Promise +- fetchUser(): Promise +- checkAuth(): Promise + +### 要求 + +- 自动携带 Cookie (credentials: 'include') +- 错误处理(401 清除用户状态) +- SSR 兼容(useFetch + useNuxtApp) +- 登录成功后自动跳转回上一页 +``` + +--- + +## 4. 文章列表页 + +```markdown +创建首页文章列表,要求: + +### 布局 + +- 顶部导航栏(Logo、菜单、登录/用户头像) +- 响应式网格布局(移动端 1 列,桌面 3 列) +- 分页组件(使用 Nuxt UI 的 UPagination) + +### 文章卡片 (UCard 组件) + +- 封面图(可选,使用 UImage) +- 标题(UButton 样式,链接到详情页) +- 摘要(最多 150 字) +- 作者信息(头像 + 名称) +- 发布时间(相对时间,如"3 天前") +- 查看数(UIcon + 数字) +- 状态标签(UBadge:已发布/草稿/归档) + +### 筛选功能 + +- 搜索框(按标题) +- 状态下拉框(全部/已发布/草稿/归档) +- 作者筛选(可选) +- URL 同步筛选参数(使用 useRouteQuery) + +### 加载状态 + +- 骨架屏(USkeleton) +- 空状态(UEmpty:无文章时显示) +- 错误状态(UAlert:加载失败时) + +### SEO + +- useSeoMeta 设置 title、description +- Open Graph 标签 +``` + +--- + +## 5. 文章详情页 + +```markdown +创建文章详情页(/article/[slug]),要求: + +### 页面结构 + +- 文章头部:标题、作者信息、发布时间、查看数 +- 封面图(如果有) +- Markdown 内容渲染区 +- 文章底部:标签、分享按钮 + +### Markdown 渲染 + +- 使用 @nuxt/content 或 markdown-it +- 支持代码高亮(Prism.js / Shiki) +- 支持表格、引用、列表 +- 响应式图片(懒加载) + +### 评论系统 + +- 评论列表(嵌套回复,最多 2 层) +- 评论表单(登录后可评论) +- 评论状态提示(待审核/已通过/已拒绝) +- 实时刷新(提交后刷新评论列表) + +### 侧边栏(桌面端) + +- 目录(TOC,基于文章标题自动生成) +- 相关文章推荐 +- 作者信息卡片 + +### 功能 + +- 文章版本历史(如果有多版本) +- 密码保护文章(输入密码后查看) +- 私密文章验证(通过 token 访问) +- 阅读进度条(顶部固定) + +### 交互 + +- 点赞/表情反应(ULikeButton 风格) +- 分享功能(复制链接、Twitter、微信) +- 返回目录(滚动监听) +``` + +--- + +## 6. 创建/编辑文章页 + +```markdown +创建文章编辑器(/create 和 /article/[id]/edit),要求: + +### 编辑器布局 + +- 左右分栏(左侧编辑,右侧预览) +- 可切换全屏编辑模式 +- 实时预览(防抖,500ms) + +### 表单字段 (使用 UForm + UInput) + +- 标题(UInput,最大 200 字,必填) +- Slug(自动生成,可手动修改) +- 内容(UTextarea,支持 Markdown 语法提示) +- 摘要(UTextarea,最大 500 字,可选) +- 封面图 URL(UInput,可选) +- 状态(URadio:草稿/已发布/归档) +- 可见性(URadio:公开/未列出/私密) +- 访问令牌(私密文章时显示) + +### 验证规则 + +- 标题:必填,1-200 字 +- 内容:必填 +- 摘要:可选,0-500 字 +- 自动保存草稿(每 60 秒) + +### 功能 + +- Markdown 工具栏(粗体、斜体、链接、图片、代码块) +- 图片上传(拖拽上传,返回 URL) +- 发布确认对话框 +- 版本历史(显示编辑记录) + +### 权限 + +- 仅作者和管理员可编辑 +- 未授权时显示 403 页面 +``` + +--- + +## 7. 登录页 + +```markdown +创建登录页(/login),要求: + +### 表单设计 (UCard 居中布局) + +- 邮箱/用户名输入框(UInput) +- 密码输入框(UInput type="password") +- 记住我复选框(UCheckbox) +- 登录按钮(UButton,带加载状态) +- 忘记密码链接(可选) + +### 验证 + +- 邮箱/用户名:必填 +- 密码:必填,最少 6 位 +- 错误提示(UAlert 显示在表单顶部) + +### 功能 + +- 限流处理(5 次/分钟,显示重试时间) +- 登录成功后跳转到来源页面 +- 已登录用户自动跳转到首页 +- Social 登录按钮(预留位置) + +### 安全 + +- CSRF Token(如后端需要) +- HTTPS 强制(生产环境) +``` + +--- + +## 8. 管理后台 + +```markdown +创建管理后台(/admin/\*),要求: + +### 布局 (admin.vue) + +- 侧边栏导航(文章管理、评论管理、用户管理、数据分析) +- 顶部栏(返回首页、用户信息、退出登录) +- 权限检查(仅 admin 角色可访问) + +### 评论管理页 (/admin/comments) + +- 表格展示(UTable) + - 评论内容(截断) + - 文章标题(链接) + - 作者(名称/邮箱) + - 状态(UBadge:待审核/通过/拒绝/可疑) + - 创建时间 + - 操作(审核通过/拒绝/删除/查看日志) +- 筛选:文章 ID、状态、作者、分页 +- 批量操作(批量通过/拒绝/删除) +- 审核日志弹窗(显示 AI 审核记录) + +### 文章管理页 (/admin/articles) + +- 表格展示 + - 标题 + 封面图缩略图 + - 作者 + - 状态(UBadge) + - 可见性(UIcon) + - 查看数 + - 发布时间 + - 操作(编辑/删除/强制发布/归档) +- 筛选:状态、可见性、作者、分页 +- 强制操作(管理员可编辑/删除任何文章) + +### 用户管理页 (/admin/users) + +- 表格展示 + - 用户信息(头像 + 名称 + 邮箱) + - 角色(UBadge:admin/moderator/user) + - 状态(激活/禁用) + - 创建时间 + - 操作(编辑角色/禁用/删除) +- 创建用户按钮(弹窗表单) + +### 数据分析页 (/admin/analytics) + +- 数据卡片(UStat) + - 总文章数 + - 总评论数 + - 总用户数 + - 近 30 天查看数 +- 图表(使用 Chart.js 或 ECharts) + - 每日查看趋势 + - 热门文章 Top 10 + - 评论审核统计 +``` + +--- + +## 9. 评论组件 + +```markdown +创建评论系统组件,要求: + +### 评论列表 (CommentList.vue) + +- 嵌套结构(父评论 + 回复列表) +- 头像 + 用户名 + 时间 +- 内容(支持简单 Markdown) +- 状态标签(待审核/已审核) +- 回复按钮(登录后可用) +- 点赞数(可选) + +### 评论表单 (CommentForm.vue) + +- 文本域(最小 3 行,支持 Markdown) +- 字符计数器(最大 2000 字) +- 提交按钮(带加载状态) +- 登录提示(未登录时显示) +- 预览功能(可选) + +### 回复表单 (ReplyForm.vue) + +- 简化版评论表单 +- 内联显示(点击回复后展开) +- 取消按钮 + +### 审核提示 + +- 提交成功:显示"评论待审核" +- 审核通过:自动刷新显示评论 +- 审核拒绝:显示拒绝原因(如果公开) + +### 权限 + +- 仅登录用户可评论 +- 作者/管理员可删除评论 +- 管理员可在后台审核 +``` + +--- + +## 10. 全局组件 + +```markdown +创建以下全局通用组件: + +### 导航栏 (AppHeader.vue) + +- Logo(左侧) +- 菜单(首页、关于、管理后台) +- 搜索框(可选) +- 用户菜单(头像下拉:个人中心、退出登录) +- 登录/注册按钮(未登录时) +- 移动端汉堡菜单 + +### 页脚 (AppFooter.vue) + +- 版权信息 +- 友情链接 +- Social 图标 +- ICP 备案(如果需要) + +### 加载器 (AppLoading.vue) + +- 全局加载中(USpinner) +- 页面切换进度条(NuxtPageTransition) + +### 错误页 (AppError.vue) + +- 404 页面(UEmpty + 返回首页按钮) +- 403 页面(无权限提示) +- 500 页面(服务器错误) + +### SEO 组件 (SeoMeta.vue) + +- 动态设置 title、description +- Open Graph 标签 +- Twitter Card 标签 +- JSON-LD 结构化数据 +``` + +--- + +## 11. Pinia Store + +```markdown +创建以下 Pinia stores: + +### auth store (stores/auth.ts) + +- State: user, isLoading, error +- Getters: isAuthenticated, isAdmin, userRole +- Actions: login, logout, fetchUser, checkAuth, refreshUser + +### article store (stores/article.ts) + +- State: articles, currentArticle, loading, pagination +- Getters: publishedArticles, draftArticles, totalArticles +- Actions: fetchArticles, fetchArticle, createArticle, updateArticle, deleteArticle + +### comment store (stores/comment.ts) + +- State: comments, loading +- Getters: approvedComments, pendingComments +- Actions: fetchComments, createComment, approveComment, rejectComment, deleteComment + +### admin store (stores/admin.ts) + +- State: stats, loading +- Getters: commentStats, articleStats, userStats +- Actions: fetchStats, updateCommentStatus, deleteUser +``` + +--- + +## 12. 样式与主题 + +````markdown +配置 Nuxt UI 主题: + +### 颜色方案 (nuxt.config.ts) + +```ts +export default defineNuxtConfig({ + ui: { + primary: 'indigo', + gray: 'slate', + theme: { + dark: true, // 支持暗色模式 + }, + }, +}); +``` +```` + +### 自定义样式 (assets/css/main.css) + +- 全局字体(Inter / Noto Sans SC) +- 文章排版样式(prose 类) +- 代码块主题(One Dark / GitHub) +- 响应式断点调整 + +### 暗色模式切换 + +- 使用 useDark() 和 useToggle() +- 切换按钮(UIcon:sun/moon) +- 持久化到 localStorage +- 系统偏好自动检测 + +```` + +--- + +## 13. SEO 优化 + +```markdown +实现 SEO 优化: + +### 动态 Meta 标签 (useSeoMeta) +- title / titleTemplate +- description +- canonical URL +- robots + +### Open Graph +- og:title +- og:description +- og:image(文章封面) +- og:url +- og:type + +### Twitter Card +- twitter:card +- twitter:title +- twitter:description +- twitter:image + +### 结构化数据 (JSON-LD) +- Article Schema(文章页) +- BreadcrumbList(面包屑) +- Organization(网站信息) + +### Sitemap +- @nuxtjs/sitemap 模块 +- 自动生成文章路由 +- 静态路由配置 +```` + +--- + +## 14. 性能优化 + +```markdown +实现性能优化: + +### 图片优化 + +- Nuxt Image 模块(自动 WebP) +- 懒加载(loading="lazy") +- 响应式图片(srcset) +- LQIP(低质量占位图) + +### 代码分割 + +- 路由懒加载(默认启用) +- 组件异步加载(defineAsyncComponent) +- 第三方库按需引入 + +### 缓存策略 + +- API 响应缓存(useFetch + cache) +- 静态资源 CDN +- Service Worker(可选) + +### 渲染优化 + +- 骨架屏(Skeleton) +- 虚拟滚动(长列表) +- 防抖/节流(搜索、滚动) +- 预加载(hover 时预加载文章) +``` + +--- + +## 15. 测试 + +```markdown +编写测试: + +### 单元测试 (Vitest) + +- Composables 测试 +- Utils 函数测试 +- Stores 测试 + +### 组件测试 (Vue Test Utils) + +- 按钮点击 +- 表单验证 +- 条件渲染 + +### E2E 测试 (Playwright) + +- 登录流程 +- 文章创建/编辑 +- 评论提交 +- 管理后台操作 + +### 测试覆盖率 + +- 目标:80%+ +- 关键路径:认证、文章 CRUD、评论审核 +``` + +--- + +## 16. 环境变量配置 + +````markdown +配置环境变量: + +### .env 文件 + +```env +NUXT_PUBLIC_API_BASE=http://localhost:3001 +NUXT_PUBLIC_SITE_URL=http://localhost:3000 +NUXT_PUBLIC_SITE_NAME=LinkShare Blog +``` +```` + +### nuxt.config.ts + +```ts +runtimeConfig: { + public: { + apiBase: process.env.NUXT_PUBLIC_API_BASE, + siteUrl: process.env.NUXT_PUBLIC_SITE_URL, + } +} +``` + +### 类型安全 + +```ts +declare module '@nuxt/schema' { + interface RuntimeConfig { + public: { + apiBase: string; + siteUrl: string; + }; + } +} +``` + +```` + +--- + +## 17. 部署配置 + +```markdown +部署配置: + +### Dockerfile +```dockerfile +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +RUN npm run build +EXPOSE 3000 +CMD ["node", ".output/server/index.mjs"] +```` + +### Docker Compose + +```yaml +services: + frontend: + build: . + ports: + - '3000:3000' + environment: + - NUXT_PUBLIC_API_BASE=http://api:3001 +``` + +### Nginx 配置 + +- 反向代理到 Nuxt +- 静态资源缓存 +- Gzip 压缩 +- HTTPS 配置 + +``` + +--- + +## 开发优先级 + +1. **第一阶段 - 核心功能** + - 项目初始化 + - 认证系统(登录/登出) + - 文章列表页 + - 文章详情页 + - 评论系统 + +2. **第二阶段 - 内容创作** + - 文章创建/编辑页 + - Markdown 编辑器 + - 图片上传 + +3. **第三阶段 - 管理后台** + - 管理后台布局 + - 评论管理 + - 文章管理 + - 用户管理 + +4. **第四阶段 - 优化与完善** + - SEO 优化 + - 性能优化 + - 测试覆盖 + - 部署配置 + +--- + +## 后端 API 参考 + +### 数据库模型 (Prisma Schema) + +详见 `prisma/schema.prisma`,主要模型: +- User(用户) +- Article(文章) +- Comment(评论) +- ArticleVersion(文章版本) +- CommentAudit(评论审核日志) +- OAuth2Token(OAuth2 令牌) +- AnalyticsEvent(分析事件) + +### 认证机制 + +- **Web 端**: Session Cookie(HttpOnly,Secure) +- **API 端**: OAuth2 Bearer Token +- **角色**: admin、moderator、user +- **限流**: /login 和 /oauth2/token 接口 5 次/分钟 + +### 内容审核 + +- **规则审核**: 敏感词、长度限制 +- **AI 审核**: OpenRouter API(异步) +- **状态**: pending → approved/rejected/suspicious +- **审计**: 所有审核操作记录到 CommentAudit + +--- + +**创建日期**: 2026-03-28 +**后端版本**: 1.0.0 +**适用框架**: Nuxt 4 + Nuxt UI +``` diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c2d64a7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,234 @@ +# AGENTS.md - 智能编码助手指南 + +## 偏好设置 + +- 使用中文回答问题 +- 代码变更前先阅读相关文件 +- 遵循项目现有架构和模式 + +--- + +## 命令速查 + +### 基础命令 + +```bash +pnpm install # 安装依赖 +pnpm build # 构建应用 +pnpm start:dev # 开发服务器(热重载) +pnpm lint # 代码检查(自动修复) +pnpm format # 代码格式化 +``` + +### 测试命令 + +```bash +pnpm test # 所有测试 +pnpm test:watch # 监听模式 +pnpm test:cov # 覆盖率报告 +pnpm test -- path/to/testFile.spec.ts # 单个测试文件 +pnpm test -- -t "pattern" # 匹配名称的测试 +pnpm test:e2e # E2E 测试 +``` + +### 数据库 + +```bash +pnpm prisma generate # 生成 Prisma Client +pnpm prisma migrate dev # 开发环境迁移 +pnpm prisma:deploy # 生产环境迁移 +pnpm prisma:seed # 运行种子数据 +``` + +### 部署运维 + +```bash +pnpm run verify # 验证环境配置 +pnpm run deploy # 生产环境一键部署 +pnpm run deploy:dev # 开发环境部署 +pnpm run health # 健康检查 +pnpm run init:status # 检查初始化状态(需 token) +pnpm run backup # 备份数据库 +pnpm run logs:api # 查看 API 日志 +pnpm run shell # 进入 API 容器 +``` + +--- + +## 代码规范 + +### TypeScript + +- 严格模式已启用(`strict: true`) +- 关键选项:`noImplicitAny`、`strictNullChecks` +- 目标:ES2023,模块:NodeNext,装饰器已启用 + +### 导入顺序 + +1. Node 内置模块 +2. 第三方库 +3. 内部模块(相对路径) + +### 文件结构(NestJS 模块) + +``` +src/modules// +├── .module.ts +├── .service.ts +├── .controller.ts +├── dto/ +│ ├── .dto.ts +│ └── ... +└── guards/、decorators/、processors/(可选) +``` + +### 命名约定 + +- **文件**:kebab-case(如 `admin-comments.controller.ts`) +- **类**:PascalCase(如 `ArticlesService`) +- **方法/变量**:camelCase +- **枚举**:PascalCase 名称,UPPER_SNAKE 值 +- **常量**:UPPER_SNAKE +- **接口**:PascalCase + +### DTO 与验证 + +- 必须使用 `class-validator` 装饰器 +- 必填字段必填,可选字段加 `@IsOptional()` +- 使用 `@IsString()`、`@IsInt()`、`@IsEnum()`、`@IsEmail()` 等 +- 添加限制:`@Length()`、`@Min()`、`@Max()` +- Create DTO:必填字段;Update DTO:所有字段可选 + +### 服务层 + +- `@Injectable()`,通过构造函数注入依赖 +- 异步操作使用 `async/await` +- 记录日志但不吞噬异常 +- 返回 Plain Objects,避免循环引用 + +### 控制器层 + +- 使用 NestJS 装饰器:`@Controller()`、`@Get()`、`@Post()`、`@Put()`、`@Delete()` +- 验证输入:`@Body()`、`@Query()`、`@Param()` 配合 DTO +- 访问请求:`@Req()` 获取 session、用户、IP 等 +- 应用守卫:`@UseGuards(RequireSessionUser)`、`@RequireRole('admin')` +- 错误处理:未找到用 `HttpStatus.NOT_FOUND`,禁止访问用 `HttpStatus.FORBIDDEN` + +### 守卫与装饰器 + +- `RequireSessionUser`:从 session 加载用户到 `req.user` +- `RequireRole`:RBAC 角色验证,配合 `RequireRoleGuard` 使用 +- `@Throttler()`:对 `/login` 和 `/oauth2/token` 限流 + +### 错误处理 + +- 使用 `HttpException` 配合适当的 HTTP 状态码 +- 服务层用 `Logger.error()` 记录异常 +- 外部 API 调用必须用 try-catch 包裹,提供降级行为 +- 不暴露内部错误详情给客户端 + +### 日志规范 + +- 使用 `Logger` from `@nestjs/common` +- 实例化:`new Logger(ClassName.name)` +- 日志级别:`log`、`debug`、`warn`、`error` +- 禁止记录敏感数据,记录操作审计 + +### 数据库(Prisma) + +- Schema:`prisma/schema.prisma` +- 通过 `PrismaService` 访问,生产用真实客户端,开发用内存 mock +- Mock 必须实现完整 Prisma API:`findUnique`、`findMany`、`create`、`update`、`delete`、`count`、`aggregate` +- `findMany` 支持:`where`、`include`、`orderBy`、`skip`、`take` +- `update` 支持嵌套操作:`{ viewCount: { increment: 1 } }` + +### AI 内容审核(OpenRouter) + +- 使用 OpenAI 兼容的 chat completions 端点 +- 配置:`AI_REVIEW_PROMPT_TEMPLATE` 环境变量 +- 响应必须是 JSON:`{ decision: 'approved'|'rejected'|'suspicious', reason, score: 0-1 }` +- API 错误时降级到基于规则的审核 + +### 邮件服务(Nodemailer + BullMQ) + +- 仅使用 HTML 模板(可选纯文本备用),内联 CSS +- 邮件任务通过 BullMQ 队列 `email` 排队 +- `EmailProcessor` 处理 `send-email` 任务 +- 所有邮件记录到 `EmailMessage` 表(状态:pending、sent、failed) +- 重试机制:3 次尝试,指数退避 + +### 安全规范 + +- Helmet 中间件已启用(安全头) +- CORS:生产环境仅允许 `APP_BASE_URL` +- Session Cookie:生产环境 `SESSION_COOKIE_SECURE=true`、`HttpOnly=true` +- 限流:`@Throttler()` 应用于 `/login` 和 `/oauth2/token` +- 全局验证管道:自动转换、白名单、开发环境拒绝额外属性 +- 绝不记录敏感数据,所有凭据通过环境变量管理 + +### 管理后台 API + +- 所有 `/admin/*` 路由需要 `@RequireRole('admin')` +- 参考 `AdminCommentsController` 模式 +- 分页:查询参数 `page`、`limit`,响应包含 `{ items, total, page, limit, totalPages }` + +### Swagger 文档 + +- 非生产环境可访问 `/api-docs` +- 使用 `@nestjs/swagger` 装饰器:`@ApiProperty()`、`@ApiBody()`、`@ApiParam()`、`@ApiQuery()`、`@ApiBearerAuth()` +- 保持 DTO 简单,示例避免复杂嵌套对象 + +### Docker 与部署 + +- 镜像使用 Bun 运行时(`oven/bun:1.1`) +- 多阶段构建:`builder` + `runner` +- Docker Compose 服务:`postgres`、`redis`、`api` +- 健康检查端点验证数据库和 Redis 连接 +- 启动时自动运行:`prisma migrate deploy` 和 `prisma db seed` + +### 环境变量 + +必需配置(见 `.env.example`): + +```env +DATABASE_URL # PostgreSQL 连接 +SESSION_COOKIE_SECRET # Session 签名密钥(至少 32 位) +AI_API_KEY # AI 服务 API Key +SMTP_HOST、SMTP_PORT、SMTP_USER、SMTP_PASS # 邮件服务 +REDIS_HOST # Redis 地址 +OAUTH2_TOKEN_SIGNING_PRIVATE_KEY、OAUTH2_TOKEN_SIGNING_PUBLIC_KEY # RSA 密钥对(生产必填) +``` + +### 测试策略 + +- **单元测试**:隔离测试服务层,mock PrismaService +- **E2E 测试**:使用 `supertest` 测试 HTTP 端点 +- **文件命名**:`*.spec.ts` 或 `*.test.ts` +- **位置**:与源文件并列或 `test/` 目录 +- **运行**:`pnpm test`、`pnpm test:watch`、`pnpm test:cov` + +### Git 与提交 + +- 无强制提交规范,但请写清晰的提交信息 +- 禁止提交密钥和 `.env` 文件 +- 提交前运行 `pnpm lint` 和 `pnpm test` + +### 已知模式 + +- 基于 session 的 Web 认证 + OAuth2 API 客户端 +- 评论审核:规则优先,可疑内容走 AI 异步审核 +- 文章查看数:`{ viewCount: { increment: 1 } }` +- RBAC 角色:`admin`、`moderator`、`user` +- PrismaService mock:确保所有模型实现完整 CRUD 方法 + +--- + +## Cursor / Copilot 说明 + +本项目无特殊的 Cursor 或 Copilot 配置,请遵循上述指南。所有编码助手应: + +1. 遵守 TypeScript 严格模式 +2. 遵循 NestJS 最佳实践 +3. 使用 DTO 进行输入验证 +4. 正确使用 PrismaService +5. 记录日志但不泄露敏感信息 diff --git a/Dockerfile.api b/Dockerfile.api new file mode 100644 index 0000000..b6f8dfe --- /dev/null +++ b/Dockerfile.api @@ -0,0 +1,55 @@ +# Build stage +FROM oven/bun:1.1 AS builder + +WORKDIR /app + +# 复制依赖文件 +COPY apps/api/package.json . +COPY pnpm-workspace.yaml . +COPY package.json . + +# 安装 pnpm(如果未预装) +RUN npm install -g pnpm + +# 安装依赖 +RUN pnpm install --frozen-lockfile + +# 复制源码 +COPY apps/api/src ./src +COPY apps/api/prisma ./prisma +COPY apps/api/.env.example .env.example + +# 生成 Prisma Client +RUN pnpm prisma generate + +# 构建 +RUN pnpm build + +# Production stage +FROM oven/bun:1.1-alpine AS runner + +WORKDIR /app + +# 安装依赖(仅生产) +COPY apps/api/package.json . +COPY pnpm-workspace.yaml . +COPY package.json . + +RUN npm install -g pnpm +RUN pnpm install --prod --frozen-lockfile + +# 复制构建结果 +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/.env.example .env.example + +# 暴露端口 +EXPOSE 3001 + +# 健康检查(使用 Bun 运行 node 兼容代码) +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD bun -e "require('http').get('http://localhost:3001/health', (r) => {if(r.statusCode!==200)throw new Error(r.statusCode)})" + +# 启动命令(使用 Bun) +CMD ["bun", "dist/main"] diff --git a/Dockerfile.web b/Dockerfile.web new file mode 100644 index 0000000..a83d51f --- /dev/null +++ b/Dockerfile.web @@ -0,0 +1,50 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# 复制依赖文件 +COPY apps/web/package.json . +COPY apps/web/pnpm-lock.yaml . # 如果存在 +COPY pnpm-workspace.yaml . +COPY package.json . + +# 安装 pnpm +RUN npm install -g pnpm + +# 安装依赖 +RUN pnpm install --frozen-lockfile + +# 复制源码 +COPY apps/web . + +# 构建 +RUN pnpm build + +# Production stage +FROM node:20-alpine AS runner + +WORKDIR /app + +# 安装依赖(仅生产) +COPY apps/web/package.json . +COPY pnpm-workspace.yaml . +COPY package.json . + +RUN npm install -g pnpm +RUN pnpm install --prod --frozen-lockfile + +# 复制构建结果 +COPY --from=builder /app/.output ./.output +COPY --from=builder /app/nuxt.config.ts ./ +COPY --from=builder /app/.env.example ./.env.example + +# 暴露端口 +EXPOSE 3000 + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3000', (r) => {if(r.statusCode!==200)throw new Error(r.statusCode)})" + +# 启动命令 +CMD ["node", ".output/server/index.mjs"] diff --git a/README.md b/README.md index d30c946..74057a2 100644 --- a/README.md +++ b/README.md @@ -1,98 +1,671 @@ -

- Nest Logo -

+# LinkShare Blog - 后端 API -[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 -[circleci-url]: https://circleci.com/gh/nestjs/nest +基于 **NestJS + Bun + PostgreSQL + Redis** 的现代化博客后端系统。 -

A progressive Node.js framework for building efficient and scalable server-side applications.

-

-NPM Version -Package License -NPM Downloads -CircleCI -Discord -Backers on Open Collective -Sponsors on Open Collective - Donate us - Support us - Follow us on Twitter -

- +[![NestJS](https://img.shields.io/badge/NestJS-11.x-red)](https://nestjs.com) +[![Bun](https://img.shields.io/badge/Bun-1.1+-blue)](https://bun.sh) +[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16+-blue)](https://www.postgresql.org) +[![Redis](https://img.shields.io/badge/Redis-7+-red)](https://redis.io) +[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) -## Description +--- -[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. +## 🚀 快速开始 -## Project setup +### 一键部署(推荐) ```bash -$ pnpm install +# 1. 克隆代码 +git clone +cd BingLogyBlog-Backend + +# 2. 验证环境 +pnpm run verify + +# 3. 一键部署 +pnpm run deploy + +# 4. 验证服务 +pnpm run health + +# 5. 检查初始化状态(可选) +pnpm run init:status ``` -## Compile and run the project +### 开发环境快速启动 ```bash -# development -$ pnpm run start +# 1. 安装依赖 +pnpm install -# watch mode -$ pnpm run start:dev +# 2. 启动依赖服务(PostgreSQL + Redis) +docker-compose up -d postgres redis -# production mode -$ pnpm run start:prod +# 3. 环境验证 +cp .env.example .env +# 编辑 .env,至少设置 DATABASE_URL 和 SESSION_COOKIE_SECRET + +# 4. 数据库迁移与种子 +pnpm run prisma:deploy +pnpm run prisma:seed + +# 5. 启动开发服务器 +pnpm run start:dev ``` -## Run tests +--- + +## ✨ 功能特性 + +- ✅ **文章管理** - 完整的 CRUD + 分页 + 权限验证 + 查看计数 + 分析追踪 +- ✅ **用户系统** - 用户增删改查(仅管理员)+ RBAC 角色权限 + 版主种子用户 +- ✅ **评论系统** - 规则审核 + AI 智能审核 + 完整的审计日志 +- ✅ **邮件服务** - Nodemailer + HTML 模板 + BullMQ 队列 + 重试机制 +- ✅ **AI 审核** - OpenRouter 集成 + 自定义提示词 + 容错机制 +- ✅ **访客分析** - 事件追踪 + 文章查看统计 + 管理员汇总接口 +- ✅ **OAuth2** - 完整的 OAuth2 授权服务器实现 +- ✅ **管理后台** - 评论管理 + 文章管理 + 用户管理 +- ✅ **Swagger 文档** - 自动生成 API 文档(开发环境) +- ✅ **Docker 部署** - 生产就绪的容器化部署方案 + +--- + +## 🏗️ 技术架构 + +``` +┌─────────────────┐ ┌──────────────┐ ┌─────────────┐ +│ Client App │────│ API (Nest) │────│ PostgreSQL │ +│ (Web/Mobile) │ │ :3001 │ │ :5432 │ +└─────────────────┘ └──────────────┘ └─────────────┘ + │ + ├─────────► Redis :6379 (Cache + Queue) + │ + ▼ + ┌──────────────┐ + │ BullMQ │ + │ Workers │ + └──────────────┘ +``` + +**核心技术栈:** + +- **框架**: NestJS 11.x + TypeScript 5.x +- **运行时**: Bun 1.1+ (生产优化) / Node.js 20+ +- **数据库**: PostgreSQL 16+ + Prisma ORM 6.x +- **缓存/队列**: Redis 7+ + BullMQ 11.x +- **文档**: Swagger/OpenAPI 3.0 +- **安全**: Helmet, CORS, Rate Limiting, Session + OAuth2 +- **邮件**: Nodemailer + HTML 模板 +- **AI**: OpenRouter 集成 (BYOK) + +--- + +## 📦 项目结构 + +``` +. +├── src/ # 源代码 +│ ├── app.module.ts # 根模块 +│ ├── main.ts # 入口文件 +│ ├── config/ # 配置模块 +│ │ ├── config.module.ts +│ │ └── env.ts +│ ├── modules/ # 功能模块 +│ │ ├── init/ # 系统初始化 +│ │ ├── admin/ # 管理后台 +│ │ │ ├── admin-articles/ +│ │ │ └── admin-comments/ +│ │ ├── articles/ # 文章模块 +│ │ ├── users/ # 用户模块 +│ │ ├── comments/ # 评论模块 +│ │ ├── session/ # 会话管理 +│ │ ├── oauth2/ # OAuth2 授权 +│ │ ├── email/ # 邮件服务 +│ │ ├── moderation/ # 内容审核 +│ │ ├── analytics/ # 数据分析 +│ │ ├── jobs/ # 任务队列 +│ │ ├── prisma/ # 数据库服务 +│ │ └── health/ # 健康检查 +│ └── types/ # 类型声明 +├── scripts/ # 部署脚本 +│ ├── verify-env.sh # 环境验证 +│ ├── deploy.sh # 一键部署 +│ ├── backup.sh # 数据库备份 +│ ├── restore.sh # 数据库恢复 +│ ├── healthcheck.sh # 健康检查 +│ └── init-db.sql # 数据库初始化 +├── docker-compose.yml # Docker Compose 配置 +├── Dockerfile.api # API 镜像构建 +├── prisma/ # Prisma Schema +│ ├── schema.prisma +│ ├── seed.ts +│ └── migrations/ +├── AGENTS.md # 开发规范 +└── .env.example # 环境变量模板 +``` + +--- + +## 🔧 快速命令 + +### 使用 Package.json Scripts (推荐) ```bash -# unit tests -$ pnpm run test +# 查看所有可用命令 +pnpm run --help -# e2e tests -$ pnpm run test:e2e +# 部署相关 +pnpm run verify # 验证环境配置 +pnpm run deploy # 完整生产部署 +pnpm run deploy:dev # 开发环境部署 +pnpm run deploy:quick # 快速部署(跳过构建) +pnpm run deploy:check # 部署并检查初始化状态 +pnpm run migrate # 仅运行数据库迁移 +pnpm run seed # 仅运行数据库种子 -# test coverage -$ pnpm run test:cov +# 服务管理 +pnpm run start:all # 启动所有服务 +pnpm run stop # 停止所有服务 +pnpm run restart # 重启所有服务 +pnpm run recreate # 重新创建服务(会丢失数据!) +pnpm run status # 查看服务状态 +pnpm run stats # 查看资源使用情况 + +# 日志查看 +pnpm run logs # 查看所有服务日志 +pnpm run logs:api # 查看 API 日志 +pnpm run logs:db # 查看数据库日志 +pnpm run logs:redis # 查看 Redis 日志 + +# 健康与初始化 +pnpm run health # 运行健康检查 +pnpm run health:init # 完整健康检查(含初始化) +pnpm run init:status # 检查初始化状态(需要管理员 Token) +pnpm run init:run # 手动触发初始化检查 +pnpm run init:generate-keys # 生成 OAuth2 RSA 密钥对 + +# 数据库管理 +pnpm run prisma:generate # 生成 Prisma Client +pnpm run prisma:deploy # 部署迁移到数据库 +pnpm run prisma:seed # 运行数据库种子 +pnpm run prisma:studio # 打开 Prisma Studio +pnpm run backup # 备份数据库 +pnpm run restore # 恢复数据库(需要 BACKUP_FILE 参数) + +# 开发相关 +pnpm run start:dev # 开发模式(热重载) +pnpm run start:debug # 调试模式 +pnpm run dev # 同 start:dev +pnpm run debug # 同 start:debug + +# 代码质量 +pnpm run build # 构建应用 +pnpm run lint # 代码检查 +pnpm run format # 代码格式化 +pnpm run test # 运行测试 +pnpm run test:watch # 测试(watch 模式) +pnpm run test:cov # 测试(覆盖率) + +# 安全与配置 +pnpm run secrets # 生成随机密钥 +pnpm run cert # 生成自签名证书(仅开发) + +# 容器访问 +pnpm run shell # 进入 API 容器 +pnpm run shell:db # 进入数据库容器 +pnpm run shell:redis # 进入 Redis 容器 + +# 清理 +pnpm run clean # 清理(停止服务并删除未使用的资源) +pnpm run clean:all # 彻底清理(包括镜像) + +# 文档 +pnpm run docs # 显示文档链接 ``` -## Deployment - -When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information. - -If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps: +### 使用脚本(底层) ```bash -$ pnpm install -g @nestjs/mau -$ mau deploy +# 环境验证 +./scripts/verify-env.sh + +# 一键部署(内部调用 deploy.sh) +./scripts/deploy.sh --production +./scripts/deploy.sh --development + +# 健康检查(包含初始化检查) +./scripts/healthcheck.sh +./scripts/healthcheck.sh --admin-token + +# 数据库操作 +./scripts/backup.sh [name] +./scripts/restore.sh ``` -With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure. +### Docker Compose(直接操作) -## Resources +```bash +# 构建镜像 +docker-compose build -Check out a few resources that may come in handy when working with NestJS: +# 启动所有服务 +docker-compose up -d -- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework. -- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy). -- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/). -- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks. -- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com). -- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com). -- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs). -- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com). +# 查看日志 +docker-compose logs -f api -## Support +# 重启服务 +docker-compose restart -Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). +# 停止服务 +docker-compose down -## Stay in touch +# 查看状态 +docker-compose ps -- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec) -- Website - [https://nestjs.com](https://nestjs.com/) -- Twitter - [@nestframework](https://twitter.com/nestframework) +# 进入容器 +docker-compose exec api sh +``` -## License +--- -Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE). +## 📊 默认账户 + +首次部署后,种子脚本自动创建: + +| 角色 | 邮箱 | 密码 | 权限 | +| ------ | --------------------- | ------------ | -------- | +| 管理员 | admin@example.com | password123 | 完全权限 | +| 版主 | moderator@example.com | moderator123 | 评论审核 | + +**⚠️ 重要**: 首次登录后请立即修改密码! + +--- + +## 🔐 安全配置 + +### 1. 生成随机密钥 + +```bash +pnpm run secrets +# 或手动 +openssl rand -base64 32 # SESSION_COOKIE_SECRET +openssl rand -hex 32 # OAUTH2_CLIENT_SECRET +``` + +### 2. 生成 OAuth2 RSA 密钥对 + +```bash +pnpm run init:generate-keys +# 或手动 +openssl genrsa -out oauth2-private.pem 2048 +openssl rsa -pubout -in oauth2-private.pem -out oauth2-public.pem +``` + +将 PEM 内容复制到 `.env` 文件。 + +### 3. 生产环境必需配置 + +```env +NODE_ENV=production +SESSION_COOKIE_SECURE=true +APP_BASE_URL=https://api.yourdomain.com +OAUTH2_TOKEN_SIGNING_PRIVATE_KEY= +OAUTH2_TOKEN_SIGNING_PUBLIC_KEY= +``` + +--- + +## 🌐 API 端点概览 + +### 认证 + +- `POST /login` - JSON 登录 +- `POST /logout` - 登出 +- `GET /oauth2/authorize` - OAuth2 授权 +- `POST /oauth2/token` - 获取 Token +- `GET /me` - 当前用户信息 + +### 文章 + +- `GET /articles` - 列表(支持分页、筛选) +- `POST /articles` - 创建(需登录) +- `GET /articles/:id` - 详情 +- `GET /articles/slug/:slug` - 根据 slug 获取 +- `PUT /articles/:id` - 更新(作者/管理员) +- `DELETE /articles/:id` - 删除(作者/管理员) + +### 评论 + +- `POST /articles/:id/comments` - 提交评论 +- `GET /articles/:id/comments` - 获取评论 + +### 管理后台 (需管理员) + +- `GET /admin/articles` - 文章管理(强制操作) +- `GET /admin/comments` - 评论管理 +- `PUT /admin/comments/:id/status` - 更新评论状态 +- `DELETE /admin/comments/:id` - 删除评论 +- `GET /admin/comments/:id/audits` - 查看审核日志 + +### 系统 + +- `GET /health` - 健康检查 +- `GET /init/status` - 初始化状态(管理员) +- `POST /init/run` - 运行初始化检查(管理员) + +完整 API 文档访问 `/api-docs` (开发环境)。 + +--- + +## 🧪 测试 + +```bash +# 所有测试 +pnpm test + +# 覆盖率 +pnpm run test:cov + +# Watch 模式 +pnpm run test:watch + +# E2E 测试 +pnpm run test:e2e +``` + +--- + +## 📝 开发指南 + +### 环境要求 + +- Node.js 20+ 或 Bun 1.1+ +- PostgreSQL 16+ +- Redis 7+ +- pnpm 8+ + +### 开发流程 + +```bash +# 1. 安装依赖 +pnpm install + +# 2. 复制环境配置 +cp .env.example .env +# 编辑 .env 文件 + +# 3. 启动依赖服务 +pnpm run start:all + +# 4. 运行迁移 +pnpm run prisma:deploy + +# 5. 启动开发服务器 +pnpm run start:dev +``` + +### 常用命令速查 + +```bash +pnpm run build # 构建 +pnpm run lint # 代码检查 +pnpm run format # 格式化 +pnpm run test # 测试 +pnpm run verify # 环境验证 +pnpm run health # 健康检查 +pnpm run logs:api # 查看 API 日志 +pnpm run shell # 进入容器 +``` + +--- + +## 🐳 Docker 部署 + +### 构建和启动 + +```bash +# 构建镜像 +docker-compose build + +# 启动所有服务 +docker-compose up -d + +# 查看日志 +docker-compose logs -f api + +# 停止服务 +docker-compose down +``` + +### 资源限制 + +Docker Compose 已配置: + +- CPU: 限制 2 核心,预留 0.5 核心 +- 内存: 限制 2GB,预留 512MB +- 日志: 最大 10MB × 3 文件,自动压缩 + +--- + +## 📈 监控和维护 + +### 健康检查 + +```bash +# API 健康检查 +curl http://localhost:3001/health + +# 综合健康检查脚本 +./scripts/healthcheck.sh +``` + +### 日志管理 + +```bash +# 实时日志 +pnpm run logs:api + +# 导出日志 (自动归档) +docker-compose logs api > logs/$(date +%Y%m%d_%H%M%S).log + +# 清理旧日志 +find logs/ -name "*.log" -mtime +30 -delete +``` + +### 备份数据库 + +```bash +# 手动备份 +pnpm run backup + +# 自动备份 (添加到 crontab) +0 2 * * * cd /path/to/project && pnpm run backup +``` + +### 恢复数据库 + +```bash +# 查看备份列表 +ls -lh backups/ + +# 恢复指定备份 +BACKUP_FILE=backups/daily_20260328.sql.gz pnpm run restore +# 或 +./scripts/restore.sh backups/daily_20260328.sql.gz +``` + +--- + +## 🚨 故障排查 + +### 常见问题 + +1. **容器无法启动** + + ```bash + docker-compose logs api # 查看错误日志 + docker-compose down && docker-compose up -d # 重启 + ``` + +2. **数据库连接失败** + + ```bash + docker-compose ps postgres # 检查数据库状态 + docker-compose logs postgres # 查看数据库日志 + ``` + +3. **Redis 连接超时** + + ```bash + docker-compose restart redis + docker-compose logs redis + ``` + +4. **邮件发送失败** + + ```bash + # 检查 SMTP 配置 + docker-compose exec api env | grep SMTP + # 查看邮件队列 + docker-compose exec api pnpm prisma emailMessage.findMany() + ``` + +5. **AI 审核不工作** + ```bash + # 检查 API Key + docker-compose exec api env | grep AI_API_KEY + # 测试 OpenRouter + curl -H "Authorization: Bearer $AI_API_KEY" https://openrouter.ai/api/v1/models + ``` + +详细故障排查见 README 的"监控和维护"部分。 + +--- + +## 🔒 安全建议 + +1. ✅ **修改所有默认密码** (admin/moderator) +2. ✅ **使用强随机密钥** (至少 32 位): `pnpm run secrets` +3. ✅ **启用 HTTPS** (Nginx/Traefik + SSL) +4. ✅ **配置防火墙** (仅开放 80, 443, 3001) +5. ✅ **定期更新依赖** (`pnpm update`) +6. ✅ **设置自动备份** (每日备份 + 异地存储) +7. ✅ **启用审计日志** (监控 CommentAudit 表) +8. ✅ **限制数据库访问** (仅允许 API 容器访问) + +--- + +## 📚 相关文档 + +- **部署指南**: 见本文档的"快速开始"和"快速命令"部分 +- [AGENTS.md](./AGENTS.md) - 开发规范 +- [prisma/schema.prisma](./prisma/schema.prisma) - 数据库 Schema +- [.env.example](./.env.example) - 环境变量说明 +- [scripts/](./scripts/) - 部署脚本集 + +--- + +## 🤝 贡献 + +欢迎提交 Issue 和 Pull Request! + +请确保: + +- 代码通过 `pnpm run lint` +- 添加了适当的测试 +- 更新了相关文档 + +--- + +## 📄 License + +MIT + +--- + +**最后更新**: 2026-03-28 +**版本**: 1.0.0 +**维护者**: [Your Team] + +--- + +## 🚀 完整部署指南 + +本文档的"快速开始"部分提供了基本的部署流程。如需详细的环境配置说明、故障排查、性能优化建议等,请参考以下扩展内容。 + +### 环境要求 + +| 组件 | 版本要求 | 说明 | +| ----------------------- | ---------- | ---------------------------------- | +| Node.js | ≥ 20.0.0 | 建议使用 Bun 1.0+ 作为替代(更快) | +| pnpm | ≥ 8.0.0 | 包管理 | +| PostgreSQL | ≥ 15.0 | 数据库 | +| Redis | ≥ 7.0 | 队列与缓存 | +| Docker & Docker Compose | 最新稳定版 | 可选,用于容器化部署 | +| OpenSSL | 任意 | 生成 RSA 密钥对(OAuth2) | + +### 环境变量配置详解 + +核心变量(`.env` 文件): + +| 变量名 | 必填 | 默认值 | 说明 | +| ---------------------------------- | ------------ | --------------------- | ----------------------------------------------------- | +| `NODE_ENV` | 否 | production | 运行环境 | +| `PORT` | 否 | 3001 | API 监听端口 | +| `DATABASE_URL` | **是** | - | PostgreSQL 连接字符串 | +| `REDIS_HOST` | 否 | redis | Redis 主机名 | +| `REDIS_PORT` | 否 | 6379 | Redis 端口 | +| `SESSION_COOKIE_SECRET` | **是** | change_me | Session cookie 签名密钥,生产环境必须改为随机长字符串 | +| `SESSION_COOKIE_NAME` | 否 | linkshare_session | Cookie 名称 | +| `SESSION_COOKIE_SECURE` | 否 | false | HTTPS 时设为 true | +| `SESSION_COOKIE_HTTPONLY` | 否 | true | HttpOnly 防止 XSS | +| `OAUTH2_ISSUER` | 否 | http://localhost:3001 | OAuth2 签发者 | +| `OAUTH2_ACCESS_TOKEN_TTL_SECONDS` | 否 | 3600 | Access token 有效期(秒) | +| `OAUTH2_REFRESH_TOKEN_TTL_SECONDS` | 否 | 2592000 | Refresh token 有效期(秒) | +| `OAUTH2_TOKEN_SIGNING_PRIVATE_KEY` | **生产必填** | - | RSA 私钥(PEM 格式) | +| `OAUTH2_TOKEN_SIGNING_PUBLIC_KEY` | **生产必填** | - | RSA 公钥(PEM 格式) | +| `AI_PROVIDER` | 否 | openrouter | AI 提供商 | +| `AI_API_KEY` | 否 | - | AI API 密钥(BYOK) | +| `SMTP_HOST` | 否 | - | SMTP 服务器 | +| `SMTP_PORT` | 否 | 587 | SMTP 端口 | +| `SMTP_USER` | 否 | - | SMTP 用户名 | +| `SMTP_PASS` | 否 | - | SMTP 密码 | + +完整列表见 [.env.example](./.env.example)。 + +### 生产环境注意事项 + +1. **修改所有默认密码** (admin/moderator) +2. **使用强随机密钥** (至少 32 位): `pnpm run secrets` +3. **启用 HTTPS** (Nginx/Traefik + SSL) +4. **配置防火墙** (仅开放 80, 443, 3001) +5. **定期更新依赖** (`pnpm update`) +6. **设置自动备份** (每日备份 + 异地存储) +7. **启用审计日志** (监控 CommentAudit 表) +8. **限制数据库访问** (仅允许 API 容器访问) + +### 故障排查 + +**API 无法启动** + +```bash +docker-compose logs api # 查看错误日志 +docker-compose down && docker-compose up -d # 重启 +``` + +**数据库连接失败** + +```bash +docker-compose ps postgres # 检查数据库状态 +docker-compose logs postgres # 查看数据库日志 +``` + +**初始化检查失败** + +```bash +# 获取管理员 token 后运行 +pnpm run init:status +# 或 +./scripts/healthcheck.sh --admin-token +``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7000a1b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,120 @@ +version: '3.8' + +services: + postgres: + image: postgres:16-alpine + container_name: blog-postgres + restart: always + environment: + POSTGRES_USER: blog + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-blog} + POSTGRES_DB: linkshare + POSTGRES_INITDB_ARGS: '--encoding=UTF-8' + volumes: + - ./volumes/postgres:/var/lib/postgresql/data + - ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init.sql:ro + ports: + - '${POSTGRES_PORT:-5432}:5432' + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U blog -d linkshare'] + interval: 10s + timeout: 5s + retries: 5 + start_period: 40s + networks: + - blog-network + + redis: + image: redis:7-alpine + container_name: blog-redis + restart: always + ports: + - '${REDIS_PORT:-6379}:6379' + volumes: + - ./volumes/redis:/data + command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 10s + timeout: 5s + retries: 5 + start_period: 40s + networks: + - blog-network + + api: + build: + context: . + dockerfile: Dockerfile.api + args: + NODE_ENV: production + container_name: blog-api + restart: always + ports: + - '${API_PORT:-3001}:3001' + environment: + NODE_ENV: production + DATABASE_URL: postgresql://blog:${POSTGRES_PASSWORD:-blog}@postgres:5432/linkshare?schema=public + REDIS_HOST: redis + REDIS_PORT: 6379 + SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-true} + APP_BASE_URL: ${APP_BASE_URL:-https://api.yourdomain.com} + env_file: + - .env + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + command: > + sh -c " + echo '⏳ Waiting for database...' && + sleep 15 && + echo '🔧 Running database migrations...' && + bunx prisma migrate deploy && + echo '🌱 Seeding database...' && + bunx prisma db seed || + echo '⚠️ Seed skipped (data may already exist)' && + echo '🚀 Starting application...' && + node dist/main + " + healthcheck: + test: + [ + 'CMD', + 'node', + '-e', + "require('http').get('http://localhost:3001/health', (r) => {if(r.statusCode!==200)process.exit(1)})", + ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + networks: + - blog-network + # 资源限制 + deploy: + resources: + limits: + cpus: '2' + memory: 2G + reservations: + cpus: '0.5' + memory: 512M + # 日志配置 + logging: + driver: 'json-file' + options: + max-size: '10m' + max-file: '3' + compress: 'true' + +volumes: + postgres: + driver: local + redis: + driver: local + +networks: + blog-network: + driver: bridge diff --git a/ecosystem.config.js b/ecosystem.config.js new file mode 100644 index 0000000..0c53d61 --- /dev/null +++ b/ecosystem.config.js @@ -0,0 +1,20 @@ +module.exports = { + apps: [ + { + name: 'binglogyblog-api', + cwd: './apps/api', + script: 'dist/main', + interpreter: 'bun', // Use Bun runtime + instances: 1, + autorestart: true, + watch: false, + max_memory_restart: '1G', + env: { + NODE_ENV: 'production', + }, + env_production: { + NODE_ENV: 'production', + }, + }, + ], +}; diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..5aff9b6 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,13 @@ +module.exports = { + moduleFileExtensions: ['js', 'json', 'ts'], + rootDir: '.', + testRegex: '.*\\.spec\\.ts$', + transform: { + '^.+\\.(t|j)s$': 'ts-jest', + }, + collectCoverageFrom: ['**/*.(t|j)s', '!**/*.spec.ts', '!**/node_modules/**'], + testEnvironment: 'node', + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + }, +}; diff --git a/package.json b/package.json index 6045c3f..2a89746 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,7 @@ { - "name": "bing-logy-blog-backend", + "name": "api", "version": "0.0.1", - "description": "", - "author": "", "private": true, - "license": "UNLICENSED", "scripts": { "build": "nest build", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", @@ -17,14 +14,65 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "jest --config ./test/jest-e2e.json", + "prisma": "prisma", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev", + "prisma:deploy": "prisma migrate deploy", + "prisma:seed": "ts-node prisma/seed.ts", + "postinstall": "prisma generate", + "verify": "bash scripts/verify-env.sh", + "deploy": "bash scripts/deploy.sh --production", + "deploy:dev": "bash scripts/deploy.sh --development", + "deploy:quick": "bash scripts/deploy.sh --production --skip-build", + "deploy:check": "bash scripts/deploy.sh --production --init-check", + "migrate": "bash scripts/deploy.sh --production --skip-build --skip-seed", + "seed": "bash scripts/deploy.sh --production --skip-build --skip-migrate", + "health": "bash scripts/healthcheck.sh", + "health:init": "bash -c 'read -p \"管理员 Token: \" token; ADMIN_TOKEN=$$token bash scripts/healthcheck.sh'", + "init:status": "bash -c 'read -p \"管理员 Token: \" token; curl -s -H \"Authorization: Bearer $$token\" http://localhost:3001/init/status | python3 -m json.tool 2>/dev/null || curl -s -H \"Authorization: Bearer $$token\" http://localhost:3001/init/status'", + "init:run": "bash -c 'read -p \"管理员 Token: \" token; curl -s -X POST -H \"Authorization: Bearer $$token\" http://localhost:3001/init/run | python3 -m json.tool 2>/dev/null || curl -s -X POST -H \"Authorization: Bearer $$token\" http://localhost:3001/init/run'", + "init:generate-keys": "bash -c 'openssl genrsa -out oauth2-private.pem 2048 && openssl rsa -pubout -in oauth2-private.pem -out oauth2-public.pem && echo \"✅ 密钥已生成:\" && echo \" 私钥: oauth2-private.pem\" && echo \" 公钥: oauth2-public.pem\" && echo \"\" && echo \"请将 PEM 内容复制到 .env 文件:\" && echo \"OAUTH2_TOKEN_SIGNING_PRIVATE_KEY=\\\"$$(cat oauth2-private.pem)\\\"\" && echo \"OAUTH2_TOKEN_SIGNING_PUBLIC_KEY=\\\"$$(cat oauth2-public.pem)\\\"\"'", + "logs": "docker-compose logs -f", + "logs:api": "docker-compose logs -f api", + "logs:db": "docker-compose logs -f postgres", + "logs:redis": "docker-compose logs -f redis", + "backup": "bash scripts/backup.sh manual_$(date +%Y%m%d_%H%M%S)", + "restore": "bash -c 'if [ -z \"$(BACKUP_FILE)\" ]; then echo \"❌ 请指定备份文件: pnpm run restore --BACKUP_FILE=backups/xxx.sql.gz\"; exit 1; fi; bash scripts/restore.sh \"$(BACKUP_FILE)\"'", + "status": "docker-compose ps", + "stats": "docker stats --no-stream", + "shell": "docker-compose exec api sh", + "shell:db": "docker-compose exec postgres psql -U blog linkshare", + "shell:redis": "docker-compose exec redis redis-cli", + "start:all": "docker-compose up -d", + "stop": "docker-compose down", + "restart": "docker-compose restart", + "recreate": "docker-compose down -v && docker-compose up -d", + "clean": "docker-compose down -v && docker system prune -f", + "clean:all": "docker-compose down -v && docker rmi binglogyblog-api -f 2>/dev/null || true && docker system prune -af --volumes", + "secrets": "bash -c 'echo \"SESSION_COOKIE_SECRET=$(openssl rand -base64 32)\" && echo \"OAUTH2_CLIENT_SECRET=$(openssl rand -hex 32)\" && echo \"\" && echo \"请将上述值添加到 .env 文件\"'", + "cert": "bash -c 'mkdir -p ssl && openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout ssl/selfsigned.key -out ssl/selfsigned.crt -subj \"/C=US/ST=State/L=City/O=Org/CN=localhost\" && echo \"✅ 证书已生成: ssl/selfsigned.crt / ssl/selfsigned.key\"'", + "docs": "bash -c 'echo \"📚 文档:\" && echo \" README: ./README.md\" && echo \" 开发规范: ./AGENTS.md\" && echo \" 数据库 Schema: ./prisma/schema.prisma\" && echo \" 环境变量: ./.env.example\" && echo \" 脚本集: ./scripts/\" && echo \" API 文档: http://localhost:3001/api-docs (开发环境)\"'" }, "dependencies": { + "@nestjs/bullmq": "^11.0.4", "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.3", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", + "@nestjs/swagger": "^11.2.6", + "@nestjs/throttler": "^6.5.0", + "bcrypt": "^5.1.1", + "class-validator": "^0.14.1", + "class-transformer": "^0.5.1", + "express-session": "^1.17.3", + "jsonwebtoken": "^9.0.2", + "nodemailer": "^6.9.16", + "prisma": "^6.1.0", + "@prisma/client": "^6.1.0", "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "helmet": "^7.1.0" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -32,7 +80,10 @@ "@nestjs/cli": "^11.0.0", "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.1", + "@types/bcrypt": "^5.0.2", "@types/express": "^5.0.0", + "@types/express-session": "^1.18.0", + "@types/jsonwebtoken": "^9.0.7", "@types/jest": "^30.0.0", "@types/node": "^22.10.7", "@types/supertest": "^6.0.2", @@ -50,22 +101,5 @@ "tsconfig-paths": "^4.2.0", "typescript": "^5.7.3", "typescript-eslint": "^8.20.0" - }, - "jest": { - "moduleFileExtensions": [ - "js", - "json", - "ts" - ], - "rootDir": "src", - "testRegex": ".*\\.spec\\.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - }, - "collectCoverageFrom": [ - "**/*.(t|j)s" - ], - "coverageDirectory": "../coverage", - "testEnvironment": "node" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9deac9..d109a51 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,15 +8,54 @@ importers: .: dependencies: + '@nestjs/bullmq': + specifier: ^11.0.4 + version: 11.0.4(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(bullmq@5.71.1) '@nestjs/common': specifier: ^11.0.1 - version: 11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/config': + specifier: ^4.0.3 + version: 4.0.3(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) '@nestjs/core': specifier: ^11.0.1 - version: 11.1.17(@nestjs/common@11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': specifier: ^11.0.1 - version: 11.1.17(@nestjs/common@11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17) + version: 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17) + '@nestjs/swagger': + specifier: ^11.2.6 + version: 11.2.6(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + '@nestjs/throttler': + specifier: ^6.5.0 + version: 6.5.0(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(reflect-metadata@0.2.2) + '@prisma/client': + specifier: ^6.1.0 + version: 6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3) + bcrypt: + specifier: ^5.1.1 + version: 5.1.1 + class-transformer: + specifier: ^0.5.1 + version: 0.5.1 + class-validator: + specifier: ^0.14.1 + version: 0.14.4 + express-session: + specifier: ^1.17.3 + version: 1.19.0 + helmet: + specifier: ^7.1.0 + version: 7.2.0 + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.3 + nodemailer: + specifier: ^6.9.16 + version: 6.10.1 + prisma: + specifier: ^6.1.0 + version: 6.19.2(typescript@5.9.3) reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -38,13 +77,22 @@ importers: version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3) '@nestjs/testing': specifier: ^11.0.1 - version: 11.1.17(@nestjs/common@11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(@nestjs/platform-express@11.1.17) + version: 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(@nestjs/platform-express@11.1.17) + '@types/bcrypt': + specifier: ^5.0.2 + version: 5.0.2 '@types/express': specifier: ^5.0.0 version: 5.0.6 + '@types/express-session': + specifier: ^1.18.0 + version: 1.18.2 '@types/jest': specifier: ^30.0.0 version: 30.0.0 + '@types/jsonwebtoken': + specifier: ^9.0.7 + version: 9.0.10 '@types/node': specifier: ^22.10.7 version: 22.19.15 @@ -53,13 +101,13 @@ importers: version: 6.0.3 eslint: specifier: ^9.18.0 - version: 9.39.4 + version: 9.39.4(jiti@2.6.1) eslint-config-prettier: specifier: ^10.0.1 - version: 10.1.8(eslint@9.39.4) + version: 10.1.8(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-prettier: specifier: ^5.2.2 - version: 5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.4))(eslint@9.39.4)(prettier@3.8.1) + version: 5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.1) globals: specifier: ^16.0.0 version: 16.5.0 @@ -92,7 +140,95 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.20.0 - version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) + version: 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + + apps/api: + dependencies: + '@nestjs/common': + specifier: ^11.0.1 + version: 11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': + specifier: ^11.0.1 + version: 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/platform-express': + specifier: ^11.0.1 + version: 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17) + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.1 + version: 7.8.2 + devDependencies: + '@eslint/eslintrc': + specifier: ^3.2.0 + version: 3.3.5 + '@eslint/js': + specifier: ^9.18.0 + version: 9.39.4 + '@nestjs/cli': + specifier: ^11.0.0 + version: 11.0.16(@types/node@22.19.15) + '@nestjs/schematics': + specifier: ^11.0.0 + version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3) + '@nestjs/testing': + specifier: ^11.0.1 + version: 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(@nestjs/platform-express@11.1.17) + '@types/express': + specifier: ^5.0.0 + version: 5.0.6 + '@types/jest': + specifier: ^30.0.0 + version: 30.0.0 + '@types/node': + specifier: ^22.10.7 + version: 22.19.15 + '@types/supertest': + specifier: ^6.0.2 + version: 6.0.3 + eslint: + specifier: ^9.18.0 + version: 9.39.4(jiti@2.6.1) + eslint-config-prettier: + specifier: ^10.0.1 + version: 10.1.8(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-prettier: + specifier: ^5.2.2 + version: 5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.1) + globals: + specifier: ^16.0.0 + version: 16.5.0 + jest: + specifier: ^30.0.0 + version: 30.3.0(@types/node@22.19.15)(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.9.3)) + prettier: + specifier: ^3.4.2 + version: 3.8.1 + source-map-support: + specifier: ^0.5.21 + version: 0.5.21 + supertest: + specifier: ^7.0.0 + version: 7.2.2 + ts-jest: + specifier: ^29.2.5 + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@30.3.0(@types/node@22.19.15)(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.9.3)))(typescript@5.9.3) + ts-loader: + specifier: ^9.5.2 + version: 9.5.4(typescript@5.9.3)(webpack@5.104.1) + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@22.19.15)(typescript@5.9.3) + tsconfig-paths: + specifier: ^4.2.0 + version: 4.2.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.20.0 + version: 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) packages: @@ -509,6 +645,9 @@ packages: '@types/node': optional: true + '@ioredis/commands@1.5.1': + resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -629,9 +768,59 @@ packages: resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} + '@mapbox/node-pre-gyp@1.0.11': + resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} + hasBin: true + + '@microsoft/tsdoc@0.16.0': + resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==} + cpu: [x64] + os: [win32] + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@nestjs/bull-shared@11.0.4': + resolution: {integrity: sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + + '@nestjs/bullmq@11.0.4': + resolution: {integrity: sha512-wBzK9raAVG0/6NTMdvLGM4/FQ1lsB35/pYS8L6a0SDgkTiLpd7mAjQ8R692oMx5s7IjvgntaZOuTUrKYLNfIkA==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + bullmq: ^3.0.0 || ^4.0.0 || ^5.0.0 + '@nestjs/cli@11.0.16': resolution: {integrity: sha512-P0H+Vcjki6P5160E5QnMt3Q0X5FTg4PZkP99Ig4lm/4JWqfw32j3EXv3YBTJ2DmxLwOQ/IS9F7dzKpMAgzKTGg==} engines: {node: '>= 20.11'} @@ -658,6 +847,12 @@ packages: class-validator: optional: true + '@nestjs/config@4.0.3': + resolution: {integrity: sha512-FQ3M3Ohqfl+nHAn5tp7++wUQw0f2nAk+SFKe8EpNRnIifPqvfJP6JQxPKtFLMOHbyer4X646prFG4zSRYEssQQ==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + rxjs: ^7.1.0 + '@nestjs/core@11.1.17': resolution: {integrity: sha512-lD5mAYekTTurF3vDaa8C2OKPnjiz4tsfxIc5XlcSUzOhkwWf6Ay3HKvt6FmvuWQam6uIIHX52Clg+e6tAvf/cg==} engines: {node: '>= 20'} @@ -676,6 +871,19 @@ packages: '@nestjs/websockets': optional: true + '@nestjs/mapped-types@2.1.0': + resolution: {integrity: sha512-W+n+rM69XsFdwORF11UqJahn4J3xi4g/ZEOlJNL6KoW5ygWSmBB2p0S2BZ4FQeS/NDH72e6xIcu35SfJnE8bXw==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + class-transformer: ^0.4.0 || ^0.5.0 + class-validator: ^0.13.0 || ^0.14.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + '@nestjs/platform-express@11.1.17': resolution: {integrity: sha512-mAf4eOsSBsTOn/VbrUO1gsjW6dVh91qqXPMXun4dN8SnNjf7PTQagM9o8d6ab8ZBpNe6UdZftdrZoDetU+n4Qg==} peerDependencies: @@ -687,6 +895,23 @@ packages: peerDependencies: typescript: '>=4.8.2' + '@nestjs/swagger@11.2.6': + resolution: {integrity: sha512-oiXOxMQqDFyv1AKAqFzSo6JPvMEs4uA36Eyz/s2aloZLxUjcLfUMELSLSNQunr61xCPTpwEOShfmO7NIufKXdA==} + peerDependencies: + '@fastify/static': ^8.0.0 || ^9.0.0 + '@nestjs/common': ^11.0.1 + '@nestjs/core': ^11.0.1 + class-transformer: '*' + class-validator: '*' + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + '@fastify/static': + optional: true + class-transformer: + optional: true + class-validator: + optional: true + '@nestjs/testing@11.1.17': resolution: {integrity: sha512-lNffw+z+2USewmw4W0tsK+Rq94A2N4PiHbcqoRUu5y8fnqxQeIWGHhjo5BFCqj7eivqJBhT7WdRydxVq4rAHzg==} peerDependencies: @@ -700,6 +925,13 @@ packages: '@nestjs/platform-express': optional: true + '@nestjs/throttler@6.5.0': + resolution: {integrity: sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==} + peerDependencies: + '@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + '@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + reflect-metadata: ^0.1.13 || ^0.2.0 + '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} @@ -720,8 +952,41 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@sinclair/typebox@0.34.48': - resolution: {integrity: sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==} + '@prisma/client@6.19.2': + resolution: {integrity: sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg==} + engines: {node: '>=18.18'} + peerDependencies: + prisma: '*' + typescript: '>=5.1.0' + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + + '@prisma/config@6.19.2': + resolution: {integrity: sha512-kadBGDl+aUswv/zZMk9Mx0C8UZs1kjao8H9/JpI4Wh4SHZaM7zkTwiKn/iFLfRg+XtOAo/Z/c6pAYhijKl0nzQ==} + + '@prisma/debug@6.19.2': + resolution: {integrity: sha512-lFnEZsLdFLmEVCVNdskLDCL8Uup41GDfU0LUfquw+ercJC8ODTuL0WNKgOKmYxCJVvFwf0OuZBzW99DuWmoH2A==} + + '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': + resolution: {integrity: sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==} + + '@prisma/engines@6.19.2': + resolution: {integrity: sha512-TTkJ8r+uk/uqczX40wb+ODG0E0icVsMgwCTyTHXehaEfb0uo80M9g1aW1tEJrxmFHeOZFXdI2sTA1j1AgcHi4A==} + + '@prisma/fetch-engine@6.19.2': + resolution: {integrity: sha512-h4Ff4Pho+SR1S8XerMCC12X//oY2bG3Iug/fUnudfcXEUnIeRiBdXHFdGlGOgQ3HqKgosTEhkZMvGM9tWtYC+Q==} + + '@prisma/get-platform@6.19.2': + resolution: {integrity: sha512-PGLr06JUSTqIvztJtAzIxOwtWKtJm5WwOG6xpsgD37Rc84FpfUBGLKz65YpJBGtkRQGXTYEFie7pYALocC3MtA==} + + '@scarf/scarf@1.4.0': + resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + + '@sinclair/typebox@0.34.49': + resolution: {integrity: sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==} '@sinonjs/commons@3.0.1': resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} @@ -729,6 +994,9 @@ packages: '@sinonjs/fake-timers@15.1.1': resolution: {integrity: sha512-cO5W33JgAPbOh07tvZjUOJ7oWhtaqGHiZw+11DPbyqh2kHTBc3eF/CjJDeQ4205RLQsX6rxCuYOroFQwl7JDRw==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tokenizer/inflate@0.4.1': resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} engines: {node: '>=18'} @@ -763,6 +1031,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/bcrypt@5.0.2': + resolution: {integrity: sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==} + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -784,6 +1055,9 @@ packages: '@types/express-serve-static-core@5.1.1': resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} + '@types/express-session@1.18.2': + resolution: {integrity: sha512-k+I0BxwVXsnEU2hV77cCobC08kIsn4y44C3gC0b46uxZVMaXA04lSPgRLR/bSL2w0t0ShJiG8o4jPzRG/nscFg==} + '@types/express@5.0.6': resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} @@ -805,9 +1079,15 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/methods@1.1.4': resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@22.19.15': resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} @@ -832,6 +1112,9 @@ packages: '@types/supertest@6.0.3': resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@types/validator@13.15.10': + resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -1054,6 +1337,9 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + abbrev@1.1.1: + resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -1078,6 +1364,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -1152,6 +1442,14 @@ packages: append-field@1.0.0: resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + aproba@2.1.0: + resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==} + + are-we-there-yet@2.0.0: + resolution: {integrity: sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==} + engines: {node: '>=10'} + deprecated: This package is no longer supported. + arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} @@ -1210,6 +1508,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bcrypt@5.1.1: + resolution: {integrity: sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==} + engines: {node: '>= 10.0.0'} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -1243,12 +1545,18 @@ packages: bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + bullmq@5.71.1: + resolution: {integrity: sha512-kOBfdcsHmO6wwmIjpersoVdYQ7jkjTgky4Yop0loc7QwSdgxliSzD69U9ijZuRrkyCJwz5p5eqxeGeQkJ0YGZQ==} + busboy@1.6.0: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} @@ -1257,6 +1565,14 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + c12@3.1.0: + resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} + peerDependencies: + magicast: ^0.3.5 + peerDependenciesMeta: + magicast: + optional: true + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1295,6 +1611,10 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + chrome-trace-event@1.0.4: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} @@ -1303,9 +1623,21 @@ packages: resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} engines: {node: '>=8'} + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + + citty@0.2.1: + resolution: {integrity: sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==} + cjs-module-lexer@2.2.0: resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + class-transformer@0.5.1: + resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} + + class-validator@0.14.4: + resolution: {integrity: sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==} + cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -1330,6 +1662,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + co@4.6.0: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -1344,6 +1680,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-support@1.1.3: + resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} + hasBin: true + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -1369,10 +1709,16 @@ packages: resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} engines: {'0': node >= 6.0} + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + consola@3.4.2: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + console-control-strings@1.1.0: + resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + content-disposition@1.0.1: resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} engines: {node: '>=18'} @@ -1384,6 +1730,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -1414,10 +1763,22 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cron-parser@4.9.0: + resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} + engines: {node: '>=12.0.0'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1438,6 +1799,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -1445,14 +1810,31 @@ packages: defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + delegates@1.0.0: + resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + detect-newline@3.1.0: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} @@ -1464,6 +1846,18 @@ packages: resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} engines: {node: '>=0.3.1'} + dotenv-expand@12.0.3: + resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==} + engines: {node: '>=12'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dotenv@17.2.3: + resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1471,9 +1865,15 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + effect@3.18.4: + resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==} + electron-to-chromium@1.5.328: resolution: {integrity: sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==} @@ -1487,6 +1887,10 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + empathic@2.0.0: + resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} + engines: {node: '>=14'} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -1631,10 +2035,21 @@ packages: resolution: {integrity: sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + express-session@1.19.0: + resolution: {integrity: sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==} + engines: {node: '>= 0.8.0'} + express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1727,6 +2142,10 @@ packages: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} + fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + fs-monkey@1.1.0: resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==} @@ -1741,6 +2160,11 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + gauge@3.0.2: + resolution: {integrity: sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==} + engines: {node: '>=10'} + deprecated: This package is no longer supported. + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -1765,6 +2189,10 @@ packages: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} + giget@2.0.0: + resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} + hasBin: true + glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -1817,10 +2245,17 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} + has-unicode@2.0.1: + resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + helmet@7.2.0: + resolution: {integrity: sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==} + engines: {node: '>=16.0.0'} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -1828,6 +2263,10 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -1867,6 +2306,10 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ioredis@5.10.1: + resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} + engines: {node: '>=12.22.0'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -2071,6 +2514,10 @@ packages: node-notifier: optional: true + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2113,6 +2560,16 @@ packages: jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2124,6 +2581,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + libphonenumber-js@1.12.40: + resolution: {integrity: sha512-HKGs7GowShNls3Zh+7DTr6wYpPk5jC78l508yQQY3e8ZgJChM3A9JZghmMJZuK+5bogSfuTafpjksGSR3aMIEg==} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -2143,12 +2603,39 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} @@ -2166,9 +2653,17 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + make-dir@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -2249,13 +2744,40 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@5.0.0: + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.3: + resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} + hasBin: true + + msgpackr@1.11.5: + resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==} + multer@2.1.1: resolution: {integrity: sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==} engines: {node: '>= 10.16.0'} @@ -2282,15 +2804,43 @@ packages: node-abort-controller@3.1.1: resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + node-addon-api@5.1.0: + resolution: {integrity: sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==} + node-emoji@1.11.0: resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} node-releases@2.0.36: resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + nodemailer@6.10.1: + resolution: {integrity: sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==} + engines: {node: '>=6.0.0'} + + nopt@5.0.0: + resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} + engines: {node: '>=6'} + hasBin: true + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -2299,6 +2849,15 @@ packages: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} + npmlog@5.0.1: + resolution: {integrity: sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==} + deprecated: This package is no longer supported. + + nypm@0.6.5: + resolution: {integrity: sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==} + engines: {node: '>=18'} + hasBin: true + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -2307,10 +2866,17 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -2388,6 +2954,12 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2411,6 +2983,9 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -2432,6 +3007,16 @@ packages: resolution: {integrity: sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + prisma@6.19.2: + resolution: {integrity: sha512-XTKeKxtQElcq3U9/jHyxSPgiRgeYDKxWTPOf6NkXA0dNj5j40MfEsZkMbyNpwDWCUv7YBFUl7I2VK/6ALbmhEg==} + engines: {node: '>=18.18'} + hasBin: true + peerDependencies: + typescript: '>=5.1.0' + peerDependenciesMeta: + typescript: + optional: true + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -2440,6 +3025,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + pure-rand@7.0.1: resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} @@ -2447,6 +3035,10 @@ packages: resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} engines: {node: '>=0.6'} + random-bytes@1.0.0: + resolution: {integrity: sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==} + engines: {node: '>= 0.8'} + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -2455,6 +3047,9 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + rc9@2.1.2: + resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} @@ -2466,6 +3061,14 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -2493,6 +3096,11 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -2534,6 +3142,9 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -2597,6 +3208,9 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -2664,6 +3278,9 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} + swagger-ui-dist@5.31.0: + resolution: {integrity: sha512-zSUTIck02fSga6rc0RZP3b7J7wgHXwLea8ZjgLA3Vgnb8QeOl3Wou2/j5QkzSGeoz6HusP/coYuJl33aQxQZpg==} + symbol-observable@4.0.0: resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} engines: {node: '>=0.10'} @@ -2676,6 +3293,10 @@ packages: resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} engines: {node: '>=6'} + tar@6.2.1: + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} + terser-webpack-plugin@5.4.0: resolution: {integrity: sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==} engines: {node: '>= 10.13.0'} @@ -2701,6 +3322,10 @@ packages: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -2720,6 +3345,9 @@ packages: resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} engines: {node: '>=14.16'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -2829,6 +3457,10 @@ packages: engines: {node: '>=0.8.0'} hasBin: true + uid-safe@2.1.5: + resolution: {integrity: sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==} + engines: {node: '>= 0.8'} + uid@2.0.2: resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} engines: {node: '>=8'} @@ -2863,6 +3495,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -2870,6 +3506,10 @@ packages: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} + validator@13.15.26: + resolution: {integrity: sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==} + engines: {node: '>= 0.10'} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -2884,6 +3524,9 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webpack-node-externals@3.0.0: resolution: {integrity: sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==} engines: {node: '>=6'} @@ -2902,11 +3545,17 @@ packages: webpack-cli: optional: true + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + wide-align@1.1.5: + resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -2940,6 +3589,9 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -3230,9 +3882,9 @@ snapshots: tslib: 2.8.1 optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': dependencies: - eslint: 9.39.4 + eslint: 9.39.4(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -3427,6 +4079,8 @@ snapshots: optionalDependencies: '@types/node': 22.19.15 + '@ioredis/commands@1.5.1': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -3565,7 +4219,7 @@ snapshots: '@jest/schemas@30.0.5': dependencies: - '@sinclair/typebox': 0.34.48 + '@sinclair/typebox': 0.34.49 '@jest/snapshot-utils@30.3.0': dependencies: @@ -3654,6 +4308,41 @@ snapshots: '@lukeed/csprng@1.1.0': {} + '@mapbox/node-pre-gyp@1.0.11': + dependencies: + detect-libc: 2.1.2 + https-proxy-agent: 5.0.1 + make-dir: 3.1.0 + node-fetch: 2.7.0 + nopt: 5.0.0 + npmlog: 5.0.1 + rimraf: 3.0.2 + semver: 7.7.4 + tar: 6.2.1 + transitivePeerDependencies: + - encoding + - supports-color + + '@microsoft/tsdoc@0.16.0': {} + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + optional: true + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.9.1 @@ -3661,6 +4350,20 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)': + dependencies: + '@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) + tslib: 2.8.1 + + '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(bullmq@5.71.1)': + dependencies: + '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17) + '@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) + bullmq: 5.71.1 + tslib: 2.8.1 + '@nestjs/cli@11.0.16(@types/node@22.19.15)': dependencies: '@angular-devkit/core': 19.2.19(chokidar@4.0.3) @@ -3687,7 +4390,7 @@ snapshots: - uglify-js - webpack-cli - '@nestjs/common@11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: file-type: 21.3.2 iterare: 1.2.1 @@ -3696,12 +4399,23 @@ snapshots: rxjs: 7.8.2 tslib: 2.8.1 uid: 2.0.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.4 transitivePeerDependencies: - supports-color - '@nestjs/core@11.1.17(@nestjs/common@11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/config@4.0.3(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + dotenv: 17.2.3 + dotenv-expand: 12.0.3 + lodash: 4.17.23 + rxjs: 7.8.2 + + '@nestjs/core@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nuxt/opencollective': 0.4.1 fast-safe-stringify: 2.1.1 iterare: 1.2.1 @@ -3711,12 +4425,20 @@ snapshots: tslib: 2.8.1 uid: 2.0.2 optionalDependencies: - '@nestjs/platform-express': 11.1.17(@nestjs/common@11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17) + '@nestjs/platform-express': 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17) - '@nestjs/platform-express@11.1.17(@nestjs/common@11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)': + '@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)': dependencies: - '@nestjs/common': 11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.17(@nestjs/common@11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.4 + + '@nestjs/platform-express@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)': + dependencies: + '@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) cors: 2.8.6 express: 5.2.1 multer: 2.1.1 @@ -3736,13 +4458,34 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/testing@11.1.17(@nestjs/common@11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(@nestjs/platform-express@11.1.17)': + '@nestjs/swagger@11.2.6(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)': dependencies: - '@nestjs/common': 11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.17(@nestjs/common@11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@microsoft/tsdoc': 0.16.0 + '@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.1.0(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + js-yaml: 4.1.1 + lodash: 4.17.23 + path-to-regexp: 8.3.0 + reflect-metadata: 0.2.2 + swagger-ui-dist: 5.31.0 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.4 + + '@nestjs/testing@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(@nestjs/platform-express@11.1.17)': + dependencies: + '@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 optionalDependencies: - '@nestjs/platform-express': 11.1.17(@nestjs/common@11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17) + '@nestjs/platform-express': 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17) + + '@nestjs/throttler@6.5.0(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 '@noble/hashes@1.8.0': {} @@ -3759,7 +4502,44 @@ snapshots: '@pkgr/core@0.2.9': {} - '@sinclair/typebox@0.34.48': {} + '@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3)': + optionalDependencies: + prisma: 6.19.2(typescript@5.9.3) + typescript: 5.9.3 + + '@prisma/config@6.19.2': + dependencies: + c12: 3.1.0 + deepmerge-ts: 7.1.5 + effect: 3.18.4 + empathic: 2.0.0 + transitivePeerDependencies: + - magicast + + '@prisma/debug@6.19.2': {} + + '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': {} + + '@prisma/engines@6.19.2': + dependencies: + '@prisma/debug': 6.19.2 + '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 + '@prisma/fetch-engine': 6.19.2 + '@prisma/get-platform': 6.19.2 + + '@prisma/fetch-engine@6.19.2': + dependencies: + '@prisma/debug': 6.19.2 + '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 + '@prisma/get-platform': 6.19.2 + + '@prisma/get-platform@6.19.2': + dependencies: + '@prisma/debug': 6.19.2 + + '@scarf/scarf@1.4.0': {} + + '@sinclair/typebox@0.34.49': {} '@sinonjs/commons@3.0.1': dependencies: @@ -3769,6 +4549,8 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@standard-schema/spec@1.1.0': {} + '@tokenizer/inflate@0.4.1': dependencies: debug: 4.4.3 @@ -3812,6 +4594,10 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/bcrypt@5.0.2': + dependencies: + '@types/node': 22.19.15 + '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 @@ -3842,6 +4628,10 @@ snapshots: '@types/range-parser': 1.2.7 '@types/send': 1.2.1 + '@types/express-session@1.18.2': + dependencies: + '@types/express': 5.0.6 + '@types/express@5.0.6': dependencies: '@types/body-parser': 1.19.6 @@ -3867,8 +4657,15 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 22.19.15 + '@types/methods@1.1.4': {} + '@types/ms@2.1.0': {} + '@types/node@22.19.15': dependencies: undici-types: 6.21.0 @@ -3900,21 +4697,23 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.9 + '@types/validator@13.15.10': {} + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.35': dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.57.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.57.2 - '@typescript-eslint/type-utils': 8.57.2(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.57.2 - eslint: 9.39.4 + eslint: 9.39.4(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -3922,14 +4721,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.57.2 '@typescript-eslint/types': 8.57.2 '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.57.2 debug: 4.4.3 - eslint: 9.39.4 + eslint: 9.39.4(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -3952,13 +4751,13 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.57.2(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.57.2 '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 - eslint: 9.39.4 + eslint: 9.39.4(jiti@2.6.1) ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -3981,13 +4780,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.57.2(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/utils@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) '@typescript-eslint/scope-manager': 8.57.2 '@typescript-eslint/types': 8.57.2 '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) - eslint: 9.39.4 + eslint: 9.39.4(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -4138,6 +4937,8 @@ snapshots: '@xtuc/long@4.2.2': {} + abbrev@1.1.1: {} + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -4157,6 +4958,12 @@ snapshots: acorn@8.16.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + ajv-formats@2.1.1(ajv@8.18.0): optionalDependencies: ajv: 8.18.0 @@ -4222,6 +5029,13 @@ snapshots: append-field@1.0.0: {} + aproba@2.1.0: {} + + are-we-there-yet@2.0.0: + dependencies: + delegates: 1.0.0 + readable-stream: 3.6.2 + arg@4.1.3: {} argparse@1.0.10: @@ -4296,6 +5110,14 @@ snapshots: baseline-browser-mapping@2.10.11: {} + bcrypt@5.1.1: + dependencies: + '@mapbox/node-pre-gyp': 1.0.11 + node-addon-api: 5.1.0 + transitivePeerDependencies: + - encoding + - supports-color + bl@4.1.0: dependencies: buffer: 5.7.1 @@ -4349,6 +5171,8 @@ snapshots: dependencies: node-int64: 0.4.0 + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -4356,12 +5180,39 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + bullmq@5.71.1: + dependencies: + cron-parser: 4.9.0 + ioredis: 5.10.1 + msgpackr: 1.11.5 + node-abort-controller: 3.1.1 + semver: 7.7.4 + tslib: 2.8.1 + uuid: 11.1.0 + transitivePeerDependencies: + - supports-color + busboy@1.6.0: dependencies: streamsearch: 1.1.0 bytes@3.1.2: {} + c12@3.1.0: + dependencies: + chokidar: 4.0.3 + confbox: 0.2.4 + defu: 6.1.4 + dotenv: 16.6.1 + exsolve: 1.0.8 + giget: 2.0.0 + jiti: 2.6.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 1.0.0 + pkg-types: 2.3.0 + rc9: 2.1.2 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -4393,12 +5244,28 @@ snapshots: dependencies: readdirp: 4.1.2 + chownr@2.0.0: {} + chrome-trace-event@1.0.4: {} ci-info@4.4.0: {} + citty@0.1.6: + dependencies: + consola: 3.4.2 + + citty@0.2.1: {} + cjs-module-lexer@2.2.0: {} + class-transformer@0.5.1: {} + + class-validator@0.14.4: + dependencies: + '@types/validator': 13.15.10 + libphonenumber-js: 1.12.40 + validator: 13.15.26 + cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 @@ -4421,6 +5288,8 @@ snapshots: clone@1.0.4: {} + cluster-key-slot@1.1.2: {} + co@4.6.0: {} collect-v8-coverage@1.0.3: {} @@ -4431,6 +5300,8 @@ snapshots: color-name@1.1.4: {} + color-support@1.1.3: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -4456,14 +5327,20 @@ snapshots: readable-stream: 3.6.2 typedarray: 0.0.6 + confbox@0.2.4: {} + consola@3.4.2: {} + console-control-strings@1.1.0: {} + content-disposition@1.0.1: {} content-type@1.0.5: {} convert-source-map@2.0.0: {} + cookie-signature@1.0.7: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {} @@ -4488,12 +5365,20 @@ snapshots: create-require@1.1.1: {} + cron-parser@4.9.0: + dependencies: + luxon: 3.7.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 + debug@2.6.9: + dependencies: + ms: 2.0.0 + debug@4.4.3: dependencies: ms: 2.1.3 @@ -4502,16 +5387,28 @@ snapshots: deep-is@0.1.4: {} + deepmerge-ts@7.1.5: {} + deepmerge@4.3.1: {} defaults@1.0.4: dependencies: clone: 1.0.4 + defu@6.1.4: {} + delayed-stream@1.0.0: {} + delegates@1.0.0: {} + + denque@2.1.0: {} + depd@2.0.0: {} + destr@2.0.5: {} + + detect-libc@2.1.2: {} + detect-newline@3.1.0: {} dezalgo@1.0.4: @@ -4521,6 +5418,14 @@ snapshots: diff@4.0.4: {} + dotenv-expand@12.0.3: + dependencies: + dotenv: 16.6.1 + + dotenv@16.6.1: {} + + dotenv@17.2.3: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4529,8 +5434,17 @@ snapshots: eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} + effect@3.18.4: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + electron-to-chromium@1.5.328: {} emittery@0.13.1: {} @@ -4539,6 +5453,8 @@ snapshots: emoji-regex@9.2.2: {} + empathic@2.0.0: {} + encodeurl@2.0.0: {} enhanced-resolve@5.20.1: @@ -4575,19 +5491,19 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@9.39.4): + eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)): dependencies: - eslint: 9.39.4 + eslint: 9.39.4(jiti@2.6.1) - eslint-plugin-prettier@5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.4))(eslint@9.39.4)(prettier@3.8.1): + eslint-plugin-prettier@5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.1): dependencies: - eslint: 9.39.4 + eslint: 9.39.4(jiti@2.6.1) prettier: 3.8.1 prettier-linter-helpers: 1.0.1 synckit: 0.11.12 optionalDependencies: '@types/eslint': 9.6.1 - eslint-config-prettier: 10.1.8(eslint@9.39.4) + eslint-config-prettier: 10.1.8(eslint@9.39.4(jiti@2.6.1)) eslint-scope@5.1.1: dependencies: @@ -4605,9 +5521,9 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@9.39.4: + eslint@9.39.4(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.21.2 '@eslint/config-helpers': 0.4.2 @@ -4641,6 +5557,8 @@ snapshots: minimatch: 3.1.5 natural-compare: 1.4.0 optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 transitivePeerDependencies: - supports-color @@ -4693,6 +5611,19 @@ snapshots: jest-mock: 30.3.0 jest-util: 30.3.0 + express-session@1.19.0: + dependencies: + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + on-headers: 1.1.0 + parseurl: 1.3.3 + safe-buffer: 5.2.1 + uid-safe: 2.1.5 + transitivePeerDependencies: + - supports-color + express@5.2.1: dependencies: accepts: 2.0.0 @@ -4726,6 +5657,12 @@ snapshots: transitivePeerDependencies: - supports-color + exsolve@1.0.8: {} + + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + fast-deep-equal@3.1.3: {} fast-diff@1.3.0: {} @@ -4837,6 +5774,10 @@ snapshots: jsonfile: 6.2.0 universalify: 2.0.1 + fs-minipass@2.1.0: + dependencies: + minipass: 3.3.6 + fs-monkey@1.1.0: {} fs.realpath@1.0.0: {} @@ -4846,6 +5787,18 @@ snapshots: function-bind@1.1.2: {} + gauge@3.0.2: + dependencies: + aproba: 2.1.0 + color-support: 1.1.3 + console-control-strings: 1.1.0 + has-unicode: 2.0.1 + object-assign: 4.1.1 + signal-exit: 3.0.7 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wide-align: 1.1.5 + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -4872,6 +5825,15 @@ snapshots: get-stream@6.0.1: {} + giget@2.0.0: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.4 + node-fetch-native: 1.6.7 + nypm: 0.6.5 + pathe: 2.0.3 + glob-parent@6.0.2: dependencies: is-glob: 4.0.3 @@ -4927,10 +5889,14 @@ snapshots: dependencies: has-symbols: 1.1.0 + has-unicode@2.0.1: {} + hasown@2.0.2: dependencies: function-bind: 1.1.2 + helmet@7.2.0: {} + html-escaper@2.0.2: {} http-errors@2.0.1: @@ -4941,6 +5907,13 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + human-signals@2.1.0: {} iconv-lite@0.7.2: @@ -4972,6 +5945,20 @@ snapshots: inherits@2.0.4: {} + ioredis@5.10.1: + dependencies: + '@ioredis/commands': 1.5.1 + cluster-key-slot: 1.1.2 + debug: 4.4.3 + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + ipaddr.js@1.9.1: {} is-arrayish@0.2.1: {} @@ -5354,6 +6341,8 @@ snapshots: - supports-color - ts-node + jiti@2.6.1: {} + js-tokens@4.0.0: {} js-yaml@3.14.2: @@ -5387,6 +6376,30 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.4 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -5398,6 +6411,8 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + libphonenumber-js@1.12.40: {} + lines-and-columns@1.2.4: {} load-esm@1.0.3: {} @@ -5412,10 +6427,28 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.defaults@4.2.0: {} + + lodash.includes@4.3.0: {} + + lodash.isarguments@3.1.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + lodash.memoize@4.1.2: {} lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash@4.17.23: {} log-symbols@4.1.0: @@ -5431,10 +6464,16 @@ snapshots: dependencies: yallist: 3.1.1 + luxon@3.7.2: {} + magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + make-dir@3.1.0: + dependencies: + semver: 6.3.1 + make-dir@4.0.0: dependencies: semver: 7.7.4 @@ -5496,10 +6535,41 @@ snapshots: minimist@1.2.8: {} + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@5.0.0: {} + minipass@7.1.3: {} + minizlib@2.1.2: + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + + mkdirp@1.0.4: {} + + ms@2.0.0: {} + ms@2.1.3: {} + msgpackr-extract@3.0.3: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3 + optional: true + + msgpackr@1.11.5: + optionalDependencies: + msgpackr-extract: 3.0.3 + multer@2.1.1: dependencies: append-field: 1.0.0 @@ -5519,28 +6589,64 @@ snapshots: node-abort-controller@3.1.1: {} + node-addon-api@5.1.0: {} + node-emoji@1.11.0: dependencies: lodash: 4.17.23 + node-fetch-native@1.6.7: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + node-int64@0.4.0: {} node-releases@2.0.36: {} + nodemailer@6.10.1: {} + + nopt@5.0.0: + dependencies: + abbrev: 1.1.1 + normalize-path@3.0.0: {} npm-run-path@4.0.1: dependencies: path-key: 3.1.1 + npmlog@5.0.1: + dependencies: + are-we-there-yet: 2.0.0 + console-control-strings: 1.1.0 + gauge: 3.0.2 + set-blocking: 2.0.0 + + nypm@0.6.5: + dependencies: + citty: 0.2.1 + pathe: 2.0.3 + tinyexec: 1.0.4 + object-assign@4.1.1: {} object-inspect@1.13.4: {} + ohash@2.0.11: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 + on-headers@1.1.0: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -5623,6 +6729,10 @@ snapshots: path-type@4.0.0: {} + pathe@2.0.3: {} + + perfect-debounce@1.0.0: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -5637,6 +6747,12 @@ snapshots: dependencies: find-up: 4.1.0 + pkg-types@2.3.0: + dependencies: + confbox: 0.2.4 + exsolve: 1.0.8 + pathe: 2.0.3 + pluralize@8.0.0: {} prelude-ls@1.2.1: {} @@ -5653,6 +6769,15 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 + prisma@6.19.2(typescript@5.9.3): + dependencies: + '@prisma/config': 6.19.2 + '@prisma/engines': 6.19.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - magicast + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -5660,12 +6785,16 @@ snapshots: punycode@2.3.1: {} + pure-rand@6.1.0: {} + pure-rand@7.0.1: {} qs@6.15.0: dependencies: side-channel: 1.1.0 + random-bytes@1.0.0: {} + range-parser@1.2.1: {} raw-body@3.0.2: @@ -5675,6 +6804,11 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 + rc9@2.1.2: + dependencies: + defu: 6.1.4 + destr: 2.0.5 + react-is@18.3.1: {} readable-stream@3.6.2: @@ -5685,6 +6819,12 @@ snapshots: readdirp@4.1.2: {} + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + reflect-metadata@0.2.2: {} require-directory@2.1.1: {} @@ -5704,6 +6844,10 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + router@2.2.0: dependencies: debug: 4.4.3 @@ -5768,6 +6912,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-blocking@2.0.0: {} + setprototypeof@1.2.0: {} shebang-command@2.0.0: @@ -5832,6 +6978,8 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 + standard-as-callback@2.1.0: {} + statuses@2.0.2: {} streamsearch@1.1.0: {} @@ -5907,6 +7055,10 @@ snapshots: dependencies: has-flag: 4.0.0 + swagger-ui-dist@5.31.0: + dependencies: + '@scarf/scarf': 1.4.0 + symbol-observable@4.0.0: {} synckit@0.11.12: @@ -5915,6 +7067,15 @@ snapshots: tapable@2.3.2: {} + tar@6.2.1: + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + terser-webpack-plugin@5.4.0(webpack@5.104.1): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -5936,6 +7097,8 @@ snapshots: glob: 7.2.3 minimatch: 3.1.5 + tinyexec@1.0.4: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.4) @@ -5955,6 +7118,8 @@ snapshots: '@tokenizer/token': 0.3.0 ieee754: 1.2.1 + tr46@0.0.3: {} + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -6045,13 +7210,13 @@ snapshots: typedarray@0.0.6: {} - typescript-eslint@8.57.2(eslint@9.39.4)(typescript@5.9.3): + typescript-eslint@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/parser': 8.57.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.2(eslint@9.39.4)(typescript@5.9.3) - eslint: 9.39.4 + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -6061,6 +7226,10 @@ snapshots: uglify-js@3.19.3: optional: true + uid-safe@2.1.5: + dependencies: + random-bytes: 1.0.0 + uid@2.0.2: dependencies: '@lukeed/csprng': 1.1.0 @@ -6109,6 +7278,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@11.1.0: {} + v8-compile-cache-lib@3.0.1: {} v8-to-istanbul@9.3.0: @@ -6117,6 +7288,8 @@ snapshots: '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 + validator@13.15.26: {} + vary@1.1.2: {} walker@1.0.8: @@ -6132,6 +7305,8 @@ snapshots: dependencies: defaults: 1.0.4 + webidl-conversions@3.0.1: {} + webpack-node-externals@3.0.0: {} webpack-sources@3.3.4: {} @@ -6168,10 +7343,19 @@ snapshots: - esbuild - uglify-js + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which@2.0.2: dependencies: isexe: 2.0.0 + wide-align@1.1.5: + dependencies: + string-width: 4.2.3 + word-wrap@1.2.5: {} wordwrap@1.0.0: {} @@ -6205,6 +7389,8 @@ snapshots: yallist@3.1.1: {} + yallist@4.0.0: {} + yargs-parser@21.1.1: {} yargs@17.7.2: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..a5dae32 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,11 @@ + +packages: + - 'apps/api' +allowBuilds: + '@nestjs/core': true + '@prisma/client': true + '@prisma/engines': true + bcrypt: true + msgpackr-extract: true + prisma: true + unrs-resolver: true diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..b7b5c1a --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,267 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + username String @unique + passwordHash String + displayName String? + avatarUrl String? + role String @default("user") // admin, user, moderator + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + oauth2Tokens OAuth2Token[] + oauth2AuthCodes OAuth2AuthorizationCode[] + articles Article[] + comments Comment[] + reactions Reaction[] @relation("UserReactions") + follows Follow[] @relation("FollowFollower") + followees Follow[] @relation("FollowFollowee") + subscriptions Subscription[] @relation("SubscriptionSubscriber") + targets Subscription[] @relation("SubscriptionTarget") + notifications Notification[] @relation("UserNotifications") + editedVersions ArticleVersion[] @relation("ArticleVersionEditor") + reviewedCommentAudits CommentAudit[] @relation("CommentAuditReviewer") +} + +model OAuth2Client { + id Int @id @default(autoincrement()) + clientId String @unique + clientSecret String + redirectUris String[] // JSON array of URIs + scopes String @default("read write") + createdAt DateTime @default(now()) + + tokens OAuth2Token[] + authCodes OAuth2AuthorizationCode[] +} + +model OAuth2AuthorizationCode { + id Int @id @default(autoincrement()) + codeHash String @unique + clientId String + userId Int? + redirectUri String + scopes String + expiresAt DateTime + used Boolean @default(false) + createdAt DateTime @default(now()) + + client OAuth2Client @relation(fields: [clientId], references: [clientId]) + user User? @relation(fields: [userId], references: [id]) +} + +model OAuth2Token { + id Int @id @default(autoincrement()) + accessTokenHash String @unique + refreshTokenHash String? @unique + clientId String + userId Int? + scopes String + expiresAt DateTime + revoked Boolean @default(false) + createdAt DateTime @default(now()) + + client OAuth2Client @relation(fields: [clientId], references: [clientId]) + user User? @relation(fields: [userId], references: [id]) + + @@index([userId]) + @@index([clientId]) +} + +model Article { + id Int @id @default(autoincrement()) + slug String @unique + title String + content String? // Markdown content + excerpt String? + coverImageUrl String? + status String @default("draft") // draft, published, archived + visibility String @default("public") // public, unlisted, private + passwordHash String? // for password-protected articles + token String? @unique // for token-based access + viewCount Int @default(0) // number of views + publishedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + authorId Int + author User @relation(fields: [authorId], references: [id]) + versions ArticleVersion[] @relation("ArticleVersions") + comments Comment[] + reactions Reaction[] @relation("ArticleReactions") + linkAccesses LinkAccess[] @relation("ArticleLinkAccess") + + @@index([authorId]) +} + +model ArticleVersion { + id Int @id @default(autoincrement()) + articleId Int + version Int + title String + content String? + excerpt String? + editorId Int? + createdAt DateTime @default(now()) + + article Article @relation("ArticleVersions", fields: [articleId], references: [id]) + editor User? @relation("ArticleVersionEditor", fields: [editorId], references: [id]) + + @@unique([articleId, version]) +} + +model Comment { + id Int @id @default(autoincrement()) + articleId Int + parentId Int? + content String + status String @default("pending") // pending, approved, rejected, suspicious + authorName String? + authorEmail String? + authorId Int? // registered user + ipAddress String? + userAgent String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + article Article @relation(fields: [articleId], references: [id]) + parent Comment? @relation("CommentReplies", fields: [parentId], references: [id]) + replies Comment[] @relation("CommentReplies") + audits CommentAudit[] @relation("CommentAudits") + author User? @relation(fields: [authorId], references: [id]) + + @@index([articleId, status, parentId]) + @@index([authorId]) +} + +model CommentAudit { + id Int @id @default(autoincrement()) + commentId Int + status String // approved, rejected, suspicious + reason String? // rule reason or AI decision + score Float? // AI confidence score + reviewerId Int? // null for AI + reviewedAt DateTime @default(now()) + + comment Comment @relation("CommentAudits", fields: [commentId], references: [id]) + reviewer User? @relation("CommentAuditReviewer", fields: [reviewerId], references: [id]) + + @@unique([commentId]) +} + +model LinkAccess { + id Int @id @default(autoincrement()) + token String @unique + articleId Int + expiresAt DateTime? + maxViews Int? + viewCount Int @default(0) + createdAt DateTime @default(now()) + + article Article @relation("ArticleLinkAccess", fields: [articleId], references: [id]) +} + +model Reaction { + id Int @id @default(autoincrement()) + articleId Int + userId Int + type String // like, love, wow, etc. + createdAt DateTime @default(now()) + + article Article @relation("ArticleReactions", fields: [articleId], references: [id]) + user User @relation("UserReactions", fields: [userId], references: [id]) + + @@unique([userId, articleId, type]) +} + +model Follow { + id Int @id @default(autoincrement()) + followerId Int + followeeId Int + createdAt DateTime @default(now()) + + follower User @relation("FollowFollower", fields: [followerId], references: [id]) + followee User @relation("FollowFollowee", fields: [followeeId], references: [id]) + + @@unique([followerId, followeeId]) +} + +model Subscription { + id Int @id @default(autoincrement()) + subscriberId Int + targetId Int? + type String // article, user, comment + createdAt DateTime @default(now()) + + subscriber User @relation("SubscriptionSubscriber", fields: [subscriberId], references: [id]) + target User? @relation("SubscriptionTarget", fields: [targetId], references: [id]) + + @@unique([subscriberId, targetId, type]) +} + +model Notification { + id Int @id @default(autoincrement()) + userId Int + type String // comment_reply, reaction, follow, system + title String + message String + isRead Boolean @default(false) + createdAt DateTime @default(now()) + + user User @relation("UserNotifications", fields: [userId], references: [id]) + + @@index([userId]) +} + +model EmailMessage { + id Int @id @default(autoincrement()) + to String + subject String + body String + status String @default("pending") // pending, sent, failed + attempts Int @default(0) + lastError String? + scheduledAt DateTime @default(now()) + sentAt DateTime? + createdAt DateTime @default(now()) + + @@index([status, createdAt]) +} + +model AnalyticsEvent { + id String @id @default(cuid()) + type String // page_view, article_view, comment_submit, etc. + userId Int? + sessionId String? + ip String? + userAgent String? + data Json? // additional event data + createdAt DateTime @default(now()) + + @@index([type, createdAt]) + @@index([userId]) + @@index([sessionId]) +} + +model AnalyticsAggregate { + id Int @id @default(autoincrement()) + date DateTime // truncated to day + type String // page_views, unique_visitors, etc. + value Int + metadata Json? + + @@unique([date, type]) +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..590864a --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,116 @@ +import { PrismaClient } from '@prisma/client'; +import * as bcrypt from 'bcrypt'; + +const prisma = new PrismaClient(); + +async function main() { + // 创建默认 OAuth2 客户端 + const defaultClient = await prisma.oAuth2Client.upsert({ + where: { clientId: 'web-client' }, + update: {}, + create: { + clientId: 'web-client', + clientSecret: 'change_me', + redirectUris: ['http://localhost:3000/auth/callback'], + scopes: 'read write', + }, + }); + console.log('Default OAuth2 client:', defaultClient.clientId); + + // 检查是否已有管理员用户 + const existingAdmin = await prisma.user.findFirst({ + where: { email: 'admin@example.com' }, + }); + + let admin; + if (!existingAdmin) { + const passwordHash = await bcrypt.hash('password123', 12); + admin = await prisma.user.create({ + data: { + email: 'admin@example.com', + username: 'admin', + passwordHash, + displayName: 'Admin', + role: 'admin', + }, + }); + console.log('Created admin user:', admin.email); + } else { + admin = existingAdmin; + console.log('Admin user already exists:', admin.email); + } + + // 检查是否已有版主用户 + const existingModerator = await prisma.user.findFirst({ + where: { email: 'moderator@example.com' }, + }); + + if (!existingModerator) { + const modPasswordHash = await bcrypt.hash('moderator123', 12); + await prisma.user.create({ + data: { + email: 'moderator@example.com', + username: 'moderator', + passwordHash: modPasswordHash, + displayName: 'Moderator', + role: 'moderator', + }, + }); + console.log('Created moderator user: moderator@example.com'); + } else { + console.log('Moderator user already exists:', existingModerator.email); + } + + // 创建示例文章 + const existingArticle = await prisma.article.findFirst({ + where: { slug: 'welcome' }, + }); + + if (!existingArticle) { + const article = await prisma.article.create({ + data: { + slug: 'welcome', + title: 'Welcome to LinkShare Blog', + content: 'This is a sample article. You can edit or delete it.', + excerpt: 'Short excerpt', + status: 'published', + visibility: 'public', + publishedAt: new Date(), + authorId: admin.id, + }, + }); + console.log('Created sample article:', article.slug); + } else { + console.log('Sample article already exists:', existingArticle.slug); + } + + // 创建 link-access token 示例 + const article = await prisma.article.findFirst({ + where: { slug: 'welcome' }, + }); + if (article) { + const existingLink = await prisma.linkAccess.findFirst({ + where: { articleId: article.id }, + }); + if (!existingLink) { + await prisma.linkAccess.create({ + data: { + token: 'sample-token-123', + articleId: article.id, + maxViews: 100, + }, + }); + console.log('Created link-access token for article:', article.slug); + } + } +} + +main() + .then(async () => { + await prisma.$disconnect(); + }) + .catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100644 index 0000000..3bddb4e --- /dev/null +++ b/scripts/backup.sh @@ -0,0 +1,76 @@ +#!/bin/bash + +# ============================================ +# LinkShare Blog - 数据库备份脚本 +# ============================================ +# 用途: 自动备份 PostgreSQL 数据库 +# 用法: ./scripts/backup.sh [backup_name] +# 示例: ./scripts/backup.sh daily + +set -e + +# 配置 +BACKUP_DIR="/backups" +DB_CONTAINER="blog-postgres" +DB_NAME="linkshare" +DB_USER="blog" +RETENTION_DAYS=7 + +# 参数处理 +BACKUP_NAME=${1:-"auto_$(date +%Y%m%d_%H%M%S)"} +BACKUP_FILE="$BACKUP_DIR/${BACKUP_NAME}.sql.gz" + +# 颜色定义 +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' + +log_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# 创建备份目录 +mkdir -p "$BACKUP_DIR" + +# 检查数据库容器 +if ! docker ps | grep -q "$DB_CONTAINER"; then + log_error "数据库容器 '$DB_CONTAINER' 未运行" + exit 1 +fi + +# 执行备份 +log_info "开始备份数据库: $DB_NAME" +log_info "备份文件: $BACKUP_FILE" + +if docker exec "$DB_CONTAINER" pg_dump -U "$DB_USER" "$DB_NAME" 2>/dev/null | gzip > "$BACKUP_FILE"; then + BACKUP_SIZE=$(du -h "$BACKUP_FILE" | cut -f1) + log_info "备份成功: $BACKUP_SIZE" + + # 记录备份信息 + echo "Backup: $BACKUP_NAME" >> "$BACKUP_DIR/backup.log" + echo "Date: $(date '+%Y-%m-%d %H:%M:%S')" >> "$BACKUP_DIR/backup.log" + echo "Size: $BACKUP_SIZE" >> "$BACKUP_DIR/backup.log" + echo "File: $BACKUP_FILE" >> "$BACKUP_DIR/backup.log" + echo "---" >> "$BACKUP_DIR/backup.log" + + # 清理旧备份 + log_info "清理 $RETENTION_DAYS 天前的旧备份..." + find "$BACKUP_DIR" -name "*.sql.gz" -mtime +$RETENTION_DAYS -delete 2>/dev/null || true + + log_info "备份完成!" + + # 输出备份文件列表 + echo "" + echo "可用的备份:" + ls -lh "$BACKUP_DIR"/*.sql.gz 2>/dev/null || echo "无备份文件" + + exit 0 +else + log_error "备份失败" + rm -f "$BACKUP_FILE" + exit 1 +fi diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 0000000..14d6261 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,312 @@ +#!/bin/bash + +# ============================================ +# LinkShare Blog - 现代化一键部署脚本 +# ============================================ +# 用途: 自动化部署流程,支持初始化检查 +# 用法: ./scripts/deploy.sh [options] +# 示例: ./scripts/deploy.sh +# ./scripts/deploy.sh --dev +# ./scripts/deploy.sh --init-check + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +cd "$PROJECT_ROOT" + +# 颜色定义 +GREEN='\033[0;32m' +BLUE='\033[0;34m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[✓]${NC} $1"; } +log_step() { echo -e "${BLUE}[→]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[!]${NC} $1"; } +log_error() { echo -e "${RED}[✗]${NC} $1"; } + +print_banner() { + echo "==========================================" + echo " LinkShare Blog 一键部署" + echo "==========================================" + echo "" +} + +show_usage() { + echo "用法: $0 [选项]" + echo "" + echo "选项:" + echo " --dev, --development 开发环境部署" + echo " --prod, --production 生产环境部署 (默认)" + echo " --skip-build 跳过镜像构建" + echo " --skip-migrate 跳过数据库迁移" + echo " --skip-seed 跳过数据库种子" + echo " --init-check 部署后检查初始化状态" + echo " --admin-token TOKEN 管理员 token(用于初始化检查)" + echo " --no-deps 不启动依赖服务" + echo " --backup 部署前自动备份数据库" + echo " -h, --help 显示此帮助" + echo "" + echo "示例:" + echo " $0 # 完整生产部署" + echo " $0 --dev # 开发环境部署" + echo " $0 --skip-build --init-check # 跳过构建并检查初始化" + echo "" + exit 1 +} + +# 参数解析 +ENV="production" +SKIP_BUILD=false +SKIP_MIGRATE=false +SKIP_SEED=false +INIT_CHECK=false +ADMIN_TOKEN="" +INCLUDE_DEPS=true +BACKUP_BEFORE=false + +while [[ $# -gt 0 ]]; do + case $1 in + --dev|--development) + ENV="development" + shift + ;; + --prod|--production) + ENV="production" + shift + ;; + --skip-build) + SKIP_BUILD=true + shift + ;; + --skip-migrate) + SKIP_MIGRATE=true + shift + ;; + --skip-seed) + SKIP_SEED=true + shift + ;; + --init-check) + INIT_CHECK=true + shift + ;; + --admin-token) + ADMIN_TOKEN="$2" + shift 2 + ;; + --no-deps) + INCLUDE_DEPS=false + shift + ;; + --backup) + BACKUP_BEFORE=true + shift + ;; + -h|--help) + show_usage + ;; + *) + log_error "未知选项: $1" + show_usage + ;; + esac +done + +# 工具函数 +wait_for_service() { + local url=$1 + local name=$2 + local max_attempts=30 + local attempt=1 + + log_step "等待 $name 就绪..." + while [ $attempt -le $max_attempts ]; do + if curl -sf "$url" >/dev/null 2>&1; then + log_info "$name 已就绪" + return 0 + fi + echo -n "." + sleep 2 + ((attempt++)) + done + echo "" + log_error "$name 未能在预期时间内就绪" + return 1 +} + +check_deployment_success() { + log_step "验证部署结果..." + + # 基本健康检查 + if ! curl -sf http://localhost:3001/health >/dev/null; then + log_error "API 健康检查失败" + return 1 + fi + + log_info "API 健康检查通过" + + # 检查初始化状态(如果要求) + if [ "$INIT_CHECK" = true ] && [ -n "$ADMIN_TOKEN" ]; then + local init_response=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + http://localhost:3001/init/status) + + if [ "$init_response" = "200" ]; then + log_info "初始化状态检查通过" + else + log_warn "初始化状态检查失败 (HTTP $init_response)" + fi + fi + + return 0 +} + +# 主部署流程 +main() { + print_banner + log_info "目标环境: $ENV" + echo "" + + # 1. 环境验证 + log_step "1/5 验证环境配置..." + if [ ! -f ".env" ]; then + log_warn ".env 文件不存在,从模板创建..." + if [ -f ".env.example" ]; then + cp .env.example .env + log_warn "请编辑 .env 文件设置必要的配置!" + else + log_error "未找到 .env.example 模板" + exit 1 + fi + fi + + if [ -f "scripts/verify-env.sh" ]; then + log_info "运行环境验证..." + if ! bash scripts/verify-env.sh; then + log_error "环境验证失败,请修复上述问题" + exit 1 + fi + fi + echo "" + + # 2. 备份数据库(可选) + if [ "$BACKUP_BEFORE" = true ]; then + log_step "2/5 备份数据库..." + if [ -f "scripts/backup.sh" ]; then + bash scripts/backup.sh "pre-deploy-$(date +%Y%m%d_%H%M%S)" || { + log_warn "备份失败,但继续部署..." + } + fi + echo "" + fi + + # 3. 构建镜像 + if [ "$SKIP_BUILD" = false ]; then + log_step "3/5 构建 Docker 镜像..." + if ! docker-compose build api; then + log_error "镜像构建失败" + exit 1 + fi + log_info "镜像构建成功" + echo "" + fi + + # 4. 启动服务 + log_step "4/5 启动服务..." + if [ "$INCLUDE_DEPS" = false ]; then + docker-compose up -d api || { + log_error "API 服务启动失败" + exit 1 + } + else + docker-compose up -d || { + log_error "服务启动失败" + exit 1 + } + fi + log_info "服务已启动" + echo "" + + # 5. 等待服务就绪 + if ! wait_for_service "http://localhost:3001/health" "API"; then + log_error "API 服务未就绪" + docker-compose logs api --tail=50 + exit 1 + fi + echo "" + + # 6. 数据库迁移和 seed + if [ "$SKIP_MIGRATE" = false ]; then + log_step "6/5 运行数据库迁移..." + if docker-compose exec -T api bunx prisma migrate deploy 2>/dev/null; then + log_info "数据库迁移完成" + else + log_warn "迁移失败或无需迁移" + fi + echo "" + fi + + if [ "$SKIP_SEED" = false ] && [ "$SKIP_MIGRATE" = false ]; then + log_step "7/5 运行数据库种子..." + if docker-compose exec -T api bunx prisma db seed 2>/dev/null; then + log_info "数据库种子完成" + else + log_warn "种子执行失败或数据已存在" + fi + echo "" + fi + + # 7. 验证部署 + if ! check_deployment_success; then + log_error "部署验证失败" + echo "" + log_error "========== 部署失败 ==========" + log_error "请检查以下内容:" + log_error " 1. 查看日志: docker-compose logs -f api" + log_error " 2. 检查配置: cat .env" + log_error " 3. 验证依赖: docker-compose ps" + log_error " 4. 手动健康检查: curl http://localhost:3001/health" + exit 1 + fi + + # 8. 显示部署信息 + echo "" + log_step "部署完成!" + echo "" + echo "==========================================" + echo "服务状态:" + echo "==========================================" + docker-compose ps + echo "" + echo "==========================================" + echo "访问地址:" + echo "==========================================" + echo "API: http://localhost:3001" + echo "健康检查: http://localhost:3001/health" + echo "Swagger: http://localhost:3001/api-docs (开发环境)" + if [ "$INIT_CHECK" = true ]; then + echo "初始化状态: http://localhost:3001/init/status (需要管理员 Token)" + fi + echo "" + echo "==========================================" + echo "常用命令:" + echo "==========================================" + echo "查看日志: docker-compose logs -f api" + echo "重启服务: docker-compose restart" + echo "停止服务: docker-compose down" + echo "备份数据库: ./scripts/backup.sh" + echo "健康检查: ./scripts/healthcheck.sh" + if [ "$INIT_CHECK" = true ]; then + echo "初始化检查: ./scripts/healthcheck.sh --admin-token " + fi + echo "" + + if [ -f ".env" ]; then + log_warn "请确保 .env 文件中的敏感配置已正确设置!" + fi +} + +main diff --git a/scripts/healthcheck.sh b/scripts/healthcheck.sh new file mode 100644 index 0000000..4463b18 --- /dev/null +++ b/scripts/healthcheck.sh @@ -0,0 +1,246 @@ +#!/bin/bash + +# ============================================ +# LinkShare Blog - 增强健康检查脚本 +# ============================================ +# 用途: 全面检查系统状态,包括初始化状态 +# 用法: ./scripts/healthcheck.sh [--admin-token TOKEN] +# 示例: ./scripts/healthcheck.sh +# ./scripts/healthcheck.sh --admin-token xxx + +set -e + +# API 地址 +API_URL=${HEALTH_CHECK_URL:-http://localhost:3001} +ADMIN_TOKEN=${ADMIN_TOKEN:-} + +# 颜色定义 +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_step() { echo -e "${BLUE}[STEP]${NC} $1"; } + +# 检查 API 健康 +check_api() { + log_step "检查 API 健康状态..." + local response=$(curl -s -o /dev/null -w "%{http_code}" "$API_URL/health" 2>/dev/null) + + if [ "$response" = "200" ]; then + log_info "✓ API 健康检查通过 (HTTP $response)" + return 0 + else + log_error "✗ API 健康检查失败 (HTTP $response)" + return 1 + fi +} + +# 检查数据库连接 +check_database() { + log_step "检查数据库连接..." + local health_json=$(curl -s "$API_URL/health" 2>/dev/null || echo "") + + if echo "$health_json" | grep -q '"database":"ok"'; then + log_info "✓ 数据库连接正常" + return 0 + else + log_error "✗ 数据库连接异常" + return 1 + fi +} + +# 检查 Redis 连接 +check_redis() { + log_step "检查 Redis 连接..." + local health_json=$(curl -s "$API_URL/health" 2>/dev/null || echo "") + + if echo "$health_json" | grep -q '"redis":"ok"'; then + log_info "✓ Redis 连接正常" + return 0 + else + log_error "✗ Redis 连接异常" + return 1 + fi +} + +# 检查初始化状态(需要管理员 token) +check_initialization() { + log_step "检查系统初始化状态..." + + if [ -z "$ADMIN_TOKEN" ]; then + # 尝试从环境变量或 .env 文件获取管理员 token(简化版) + log_warn "未提供管理员 token,跳过初始化状态检查" + log_info "提示: 使用 --admin-token 参数或设置 ADMIN_TOKEN 环境变量" + return 0 + fi + + local response=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + "$API_URL/init/status" 2>/dev/null) + + if [ "$response" = "200" ]; then + log_info "✓ 初始化状态检查成功" + # 可选:显示详细状态 + # local status_json=$(curl -s -H "Authorization: Bearer $ADMIN_TOKEN" "$API_URL/init/status") + # echo "$status_json" | python3 -m json.tool 2>/dev/null || echo "$status_json" + return 0 + else + log_error "✗ 初始化状态检查失败 (HTTP $response)" + return 1 + fi +} + +# 检查关键 API 端点 +check_endpoints() { + log_step "检查关键 API 端点..." + local all_ok=true + + # 健康检查端点 + if curl -sf "$API_URL/health" >/dev/null 2>/dev/null; then + log_info " ✓ GET /health" + else + log_error " ✗ GET /health 失败" + all_ok=false + fi + + # 登录端点(不验证,只检查是否存在) + if curl -sf -X POST "$API_URL/login" \ + -H "Content-Type: application/json" \ + -d '{"emailOrUsername":"test","password":"test"}"' \ + >/dev/null 2>&1; then + # 即使登录失败(401),端点也是可用的 + log_info " ✓ POST /login (可用)" + else + log_error " ✗ POST /login 不可用" + all_ok=false + fi + + # 初始化端点(需要管理员 token) + if [ -n "$ADMIN_TOKEN" ]; then + if curl -sf -H "Authorization: Bearer $ADMIN_TOKEN" "$API_URL/init/status" \ + >/dev/null 2>&1; then + log_info " ✓ GET /init/status (管理员)" + else + log_warn " ⚠ GET /init/status 失败 (可能需要管理员权限)" + fi + fi + + return $([ "$all_ok" = true ] && echo 0 || echo 1) +} + +# 检查磁盘空间 +check_disk() { + log_step "检查磁盘空间..." + local available=$(df -BG /app 2>/dev/null | tail -1 | awk '{print $4}' | sed 's/G//' || echo "0") + + if [ "$available" -ge 5 ]; then + log_info "✓ 可用磁盘空间: ${available}GB" + return 0 + else + log_warn "⚠ 可用磁盘空间: ${available}GB (建议至少 5GB)" + return 0 # 仅警告,不失败 + fi +} + +# 检查 Docker 容器状态 +check_containers() { + log_step "检查 Docker 容器状态..." + local all_running=true + + # 检查 API 容器 + if docker ps --format '{{.Names}}' | grep -q 'blog-api'; then + log_info " ✓ API 容器运行中" + else + log_error " ✗ API 容器未运行" + all_running=false + fi + + # 检查数据库容器 + if docker ps --format '{{.Names}}' | grep -q 'blog-postgres'; then + log_info " ✓ PostgreSQL 容器运行中" + else + log_error " ✗ PostgreSQL 容器未运行" + all_running=false + fi + + # 检查 Redis 容器 + if docker ps --format '{{.Names}}' | grep -q 'blog-redis'; then + log_info " ✓ Redis 容器运行中" + else + log_error " ✗ Redis 容器未运行" + all_running=false + fi + + return $([ "$all_running" = true ] && echo 0 || echo 1) +} + +# 主检查流程 +main() { + echo "==========================================" + echo "LinkShare Blog 系统健康检查" + echo "检查时间: $(date '+%Y-%m-%d %H:%M:%S')" + echo "API URL: $API_URL" + echo "==========================================" + echo "" + + local all_passed=true + + # 1. 容器状态检查 + if command -v docker &>/dev/null; then + check_containers || all_passed=false + echo "" + fi + + # 2. API 健康检查 + check_api || all_passed=false + echo "" + + # 3. 数据库检查 + check_database || all_passed=false + echo "" + + # 4. Redis 检查 + check_redis || all_passed=false + echo "" + + # 5. 初始化状态检查 + check_initialization || all_passed=false + echo "" + + # 6. 关键端点检查 + check_endpoints || all_passed=false + echo "" + + # 7. 磁盘空间检查 + check_disk + echo "" + + # 总结 + echo "==========================================" + if [ "$all_passed" = true ]; then + log_info "所有关键检查通过!系统运行正常。" + echo "" + echo "下一步:" + echo " - 访问 API: $API_URL" + echo " - Swagger 文档: $API_URL/api-docs (开发环境)" + echo " - 初始化状态: $API_URL/init/status (需要管理员 Token)" + exit 0 + else + log_error "部分检查失败,请排查上述问题。" + echo "" + echo "故障排查:" + echo " - 查看日志: docker-compose logs -f api" + echo " - 重启服务: make restart" + echo " - 重新部署: make deploy" + echo " - 查看文档: DEPLOYMENT.md" + exit 1 + fi +} + +# 运行主函数 +main diff --git a/scripts/init-db.sql b/scripts/init-db.sql new file mode 100644 index 0000000..a6b6739 --- /dev/null +++ b/scripts/init-db.sql @@ -0,0 +1,29 @@ +-- ============================================ +-- LinkShare Blog - 数据库初始化脚本 +-- ============================================ +-- 用途: 在 PostgreSQL 首次启动时创建必要扩展和配置 +-- 自动由 docker-compose 执行 + +-- 创建 pgcrypto 扩展 (用于密码哈希等) +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +-- 创建 uuid-ossp 扩展 (如果使用 UUID) +-- CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- 设置时区 +SET timezone = 'UTC'; + +-- 创建性能优化索引 (将在 Prisma 迁移后创建) +-- 这些索引可以在迁移文件中定义,这里作为补充 + +-- 示例: 为常用查询字段创建索引 +-- CREATE INDEX IF NOT EXISTS idx_user_email ON "User"(email); +-- CREATE INDEX IF NOT EXISTS idx_article_slug ON "Article"(slug); +-- CREATE INDEX IF NOT EXISTS idx_comment_article_status ON "Comment"("articleId", "status"); + +-- 输出完成信息 +DO $$ +BEGIN + RAISE NOTICE 'Database initialization completed successfully!'; + RAISE NOTICE 'Extensions: pgcrypto'; +END $$; diff --git a/scripts/restore.sh b/scripts/restore.sh new file mode 100644 index 0000000..37cdb75 --- /dev/null +++ b/scripts/restore.sh @@ -0,0 +1,165 @@ +#!/bin/bash + +# ============================================ +# LinkShare Blog - 数据库恢复脚本 +# ============================================ +# 用途: 从备份恢复 PostgreSQL 数据库 +# 用法: ./scripts/restore.sh [options] +# 示例: ./scripts/restore.sh daily_20260328.sql.gz + +set -e + +# 配置 +DB_CONTAINER="blog-postgres" +DB_NAME="linkshare" +DB_USER="blog" + +# 颜色定义 +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' + +log_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +# 显示用法 +show_usage() { + echo "用法: $0 [options]" + echo "" + echo "参数:" + echo " backup_file 备份文件路径 (必需)" + echo "" + echo "选项:" + echo " --drop 恢复前删除现有数据库 (危险!)" + echo " --dry-run 仅验证备份文件,不执行恢复" + echo " --help 显示此帮助信息" + echo "" + echo "示例:" + echo " $0 daily_20260328.sql.gz # 恢复备份" + echo " $0 daily_20260328.sql.gz --drop # 删除并恢复" + echo " $0 daily_20260328.sql.gz --dry-run # 验证备份" + echo "" + echo "可用备份:" + ls -1 backups/*.sql.gz 2>/dev/null || echo " (无备份文件)" +} + +# 检查参数 +if [ $# -lt 1 ] || [ "$1" = "--help" ]; then + show_usage + exit 1 +fi + +BACKUP_FILE=$1 +shift + +# 解析选项 +DROP_DB=false +DRY_RUN=false + +while [[ $# -gt 0 ]]; do + case $1 in + --drop) + DROP_DB=true + shift + ;; + --dry-run) + DRY_RUN=true + shift + ;; + *) + log_error "未知选项: $1" + show_usage + exit 1 + ;; + esac +done + +# 检查备份文件 +if [ ! -f "$BACKUP_FILE" ]; then + log_error "备份文件不存在: $BACKUP_FILE" + exit 1 +fi + +# 验证备份文件 +log_info "验证备份文件..." +if gzip -t "$BACKUP_FILE" 2>/dev/null; then + log_info "✓ 备份文件完整性检查通过" +else + log_error "备份文件损坏或不是有效的 gzip 文件" + exit 1 +fi + +# dry-run 模式 +if [ "$DRY_RUN" = true ]; then + log_info "Dry-run 模式: 不执行恢复" + log_info "备份信息:" + ls -lh "$BACKUP_FILE" + gzip -dc "$BACKUP_FILE" | head -n 20 + log_info "备份文件前20行预览完成" + exit 0 +fi + +# 检查数据库容器 +if ! docker ps | grep -q "$DB_CONTAINER"; then + log_error "数据库容器 '$DB_CONTAINER' 未运行" + exit 1 +fi + +# 警告确认 +echo "" +log_warn "即将执行数据库恢复!" +log_warn "备份文件: $BACKUP_FILE" +log_warn "数据库: $DB_NAME" +if [ "$DROP_DB" = true ]; then + log_warn "警告: 将删除现有数据库!" +fi +echo "" +read -p "确认继续? (yes/no): " CONFIRM +if [ "$CONFIRM" != "yes" ]; then + log_info "操作已取消" + exit 0 +fi + +# 执行恢复 +log_info "开始恢复数据库..." + +if [ "$DROP_DB" = true ]; then + log_info "删除现有数据库..." + docker exec "$DB_CONTAINER" psql -U "$DB_USER" -c "DROP DATABASE IF EXISTS $DB_NAME;" + docker exec "$DB_CONTAINER" psql -U "$DB_USER" -c "CREATE DATABASE $DB_NAME;" + log_info "数据库已重建" +fi + +# 恢复数据 +if gzip -dc "$BACKUP_FILE" | docker exec -i "$DB_CONTAINER" psql -U "$DB_USER" "$DB_NAME"; then + log_info "✓ 数据库恢复成功!" + + # 验证恢复 + log_info "验证恢复结果..." + TABLE_COUNT=$(gzip -dc "$BACKUP_FILE" | grep -c "^CREATE TABLE" || echo "0") + log_info "备份中发现的表数量: $TABLE_COUNT" + + ACTUAL_COUNT=$(docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -t -c "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public';" 2>/dev/null | tr -d '[:space:]') + log_info "恢复后的表数量: $ACTUAL_COUNT" + + if [ "$TABLE_COUNT" -eq "$ACTUAL_COUNT" ] || [ "$ACTUAL_COUNT" -gt 0 ]; then + log_info "✓ 验证通过" + exit 0 + else + log_warn "表数量不匹配,请手动验证数据完整性" + exit 0 + fi +else + log_error "数据库恢复失败" + exit 1 +fi diff --git a/scripts/verify-env.sh b/scripts/verify-env.sh new file mode 100644 index 0000000..0cc4470 --- /dev/null +++ b/scripts/verify-env.sh @@ -0,0 +1,257 @@ +#!/bin/bash + +# ============================================ +# LinkShare Blog - 环境验证脚本 +# ============================================ +# 用途: 在生产环境部署前验证所有必需配置 +# 用法: ./scripts/verify-env.sh + +set -e # 遇到错误退出 + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# 日志函数 +log_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# 检查项计数 +CHECKS_PASSED=0 +CHECKS_FAILED=0 +CHECKS_WARNING=0 + +# 检查函数 +check_file_exists() { + local file=$1 + local description=$2 + if [ -f "$file" ]; then + log_info "$description: 存在 ($file)" + ((CHECKS_PASSED++)) + return 0 + else + log_error "$description: 缺失 ($file)" + ((CHECKS_FAILED++)) + return 1 + fi +} + +check_env_var() { + local var=$1 + local description=$2 + local required=${3:-true} + + local value=$(eval echo "\$$var") + if [ -n "$value" ] && [ "$value" != "your-secret-here" ] && [ "$value" != "change_me" ]; then + log_info "$description ($var): 已设置" + ((CHECKS_PASSED++)) + return 0 + else + if [ "$required" = "true" ]; then + log_error "$description ($var): 未设置或为默认值" + ((CHECKS_FAILED++)) + return 1 + else + log_warn "$description ($var): 未设置或为默认值 (可选)" + ((CHECKS_WARNING++)) + return 0 + fi + fi +} + +check_command() { + local cmd=$1 + local description=$2 + if command -v "$cmd" &> /dev/null; then + local version=$($cmd --version 2>&1 | head -n1) + log_info "$description: 已安装 ($version)" + ((CHECKS_PASSED++)) + return 0 + else + log_error "$description: 未安装" + ((CHECKS_FAILED++)) + return 1 + fi +} + +check_port() { + local port=$1 + local description=$2 + if lsof -Pi :$port -sTCP:LISTEN -t >/dev/null 2>&1; then + log_info "$description (端口 $port): 已占用" + ((CHECKS_WARNING++)) + return 0 + else + log_info "$description (端口 $port): 可用" + ((CHECKS_PASSED++)) + return 0 + fi +} + +check_docker() { + if docker info >/dev/null 2>&1; then + log_info "Docker: 运行中" + ((CHECKS_PASSED++)) + return 0 + else + log_error "Docker: 未运行或无权限" + ((CHECKS_FAILED++)) + return 1 + fi +} + +# ============================================ +# 开始检查 +# ============================================ + +echo "==========================================" +echo "LinkShare Blog 环境验证" +echo "==========================================" +echo "" + +# 1. 检查必需文件 +log_info "检查文件..." +check_file_exists ".env" "环境变量文件" +check_file_exists "docker-compose.yml" "Docker Compose 配置" +check_file_exists "Dockerfile.api" "Dockerfile" +check_file_exists "ecosystem.config.js" "PM2 配置" +check_file_exists "package.json" "Package.json" +echo "" + +# 2. 检查环境变量 +log_info "检查环境变量..." +check_env_var "DATABASE_URL" "数据库连接" +check_env_var "SESSION_COOKIE_SECRET" "Session 密钥" +check_env_var "AI_API_KEY" "AI API 密钥" +check_env_var "SMTP_HOST" "SMTP 主机" +check_env_var "SMTP_PORT" "SMTP 端口" +check_env_var "SMTP_USER" "SMTP 用户名" +check_env_var "SMTP_PASS" "SMTP 密码" +check_env_var "OAUTH2_TOKEN_SIGNING_PRIVATE_KEY" "OAuth2 私钥" +check_env_var "OAUTH2_TOKEN_SIGNING_PUBLIC_KEY" "OAuth2 公钥" +echo "" + +# 3. 检查可选环境变量 +log_info "检查可选环境变量..." +check_env_var "REDIS_HOST" "Redis 主机" "false" +check_env_var "THROTTLE_TTL" "限流配置" "false" +check_env_var "APP_BASE_URL" "应用基础 URL" "false" +echo "" + +# 4. 检查命令行工具 +log_info "检查命令行工具..." +check_command "docker" "Docker CLI" +check_command "docker-compose" "Docker Compose" +check_command "node" "Node.js" +check_command "pnpm" "PNPM" +check_command "git" "Git" +echo "" + +# 5. 检查端口占用 +log_info "检查端口占用..." +check_port "3001" "API 端口" +check_port "5432" "PostgreSQL 端口" +check_port "6379" "Redis 端口" +echo "" + +# 6. 检查 Docker 状态 +log_info "检查 Docker 状态..." +check_docker +echo "" + +# 7. 检查 Docker 镜像 +log_info "检查 Docker 镜像..." +if docker image inspect binglogyblog-api:latest >/dev/null 2>&1; then + log_info "API 镜像: 存在" + ((CHECKS_PASSED++)) +else + log_warn "API 镜像: 不存在 (需要运行 docker-compose build)" + ((CHECKS_WARNING++)) +fi + +if docker image inspect postgres:16-alpine >/dev/null 2>&1; then + log_info "PostgreSQL 镜像: 存在" + ((CHECKS_PASSED++)) +else + log_warn "PostgreSQL 镜像: 不存在 (将自动拉取)" + ((CHECKS_WARNING++)) +fi + +if docker image inspect redis:7-alpine >/dev/null 2>&1; then + log_info "Redis 镜像: 存在" + ((CHECKS_PASSED++)) +else + log_warn "Redis 镜像: 不存在 (将自动拉取)" + ((CHECKS_WARNING++)) +fi +echo "" + +# 8. 检查磁盘空间 +log_info "检查磁盘空间..." +AVAILABLE_SPACE=$(df -BG . | tail -1 | awk '{print $4}' | sed 's/G//') +if [ "$AVAILABLE_SPACE" -ge 10 ]; then + log_info "可用磁盘空间: ${AVAILABLE_SPACE}GB" + ((CHECKS_PASSED++)) +else + log_warn "可用磁盘空间: ${AVAILABLE_SPACE}GB (建议至少 10GB)" + ((CHECKS_WARNING++)) +fi +echo "" + +# 9. 检查数据库迁移状态 +log_info "检查数据库迁移..." +if [ -d "prisma/migrations" ]; then + MIGRATION_COUNT=$(ls -1 prisma/migrations | wc -l) + log_info "迁移文件数量: $MIGRATION_COUNT" + ((CHECKS_PASSED++)) +else + log_warn "未找到迁移文件" + ((CHECKS_WARNING++)) +fi +echo "" + +# ============================================ +# 输出总结 +# ============================================ + +echo "==========================================" +echo "验证完成" +echo "==========================================" +echo -e "通过: ${GREEN}$CHECKS_PASSED${NC}" +echo -e "警告: ${YELLOW}$CHECKS_WARNING${NC}" +echo -e "失败: ${RED}$CHECKS_FAILED${NC}" +echo "" + +if [ $CHECKS_FAILED -gt 0 ]; then + log_error "存在 $CHECKS_FAILED 个问题需要修复" + echo "" + echo "建议操作:" + echo "1. 复制 .env.example 为 .env" + echo "2. 填写所有必需的配置项" + echo "3. 生成 OAuth2 RSA 密钥对" + echo "4. 运行: docker-compose build" + exit 1 +elif [ $CHECKS_WARNING -gt 0 ]; then + log_warn "存在 $CHECKS_WARNING 个警告" + echo "建议继续,但请注意上述警告" + exit 0 +else + log_info "所有检查通过!可以开始部署。" + echo "" + echo "下一步:" + echo " docker-compose build # 构建镜像" + echo " docker-compose up -d # 启动服务" + echo " curl http://localhost:3001/health # 验证健康状态" + exit 0 +fi diff --git a/src/app.module.ts b/src/app.module.ts index 8662803..caa174e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,10 +1,56 @@ import { Module } from '@nestjs/common'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { ThrottlerModule } from '@nestjs/throttler'; +import { AppConfigModule } from './config/config.module'; +import { PrismaModule } from './modules/prisma/prisma.module'; +import { SessionModule } from './modules/session/session.module'; +import { OAuth2Module } from './modules/oauth2/oauth2.module'; +import { MeModule } from './modules/me/me.module'; +import { ArticlesModule } from './modules/articles/articles.module'; +import { CommentsModule } from './modules/comments/comments.module'; +import { ModerationModule } from './modules/moderation/moderation.module'; +import { JobsModule } from './modules/jobs/jobs.module'; +import { HealthModule } from './modules/health/health.module'; +import { UsersModule } from './modules/users/users.module'; +import { EmailModule } from './modules/email/email.module'; +import { AnalyticsModule } from './modules/analytics/analytics.module'; +import { AdminCommentsModule } from './modules/admin/admin-comments/admin-comments.module'; +import { AdminArticlesModule } from './modules/admin/admin-articles/admin-articles.module'; +import { InitModule } from './modules/init/init.module'; @Module({ - imports: [], - controllers: [AppController], - providers: [AppService], + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + }), + AppConfigModule, + PrismaModule, + SessionModule, + OAuth2Module, + MeModule, + ArticlesModule, + CommentsModule, + ModerationModule, + JobsModule, + HealthModule, + UsersModule, + EmailModule, + AnalyticsModule, + AdminCommentsModule, + AdminArticlesModule, + InitModule, + ThrottlerModule.forRootAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => { + const ttl = configService.get('THROTTLE_TTL'); + const limit = configService.get('THROTTLE_LIMIT'); + return { + ttl: ttl ?? 60000, + limit: limit ?? 100, + } as any; // Type assertion to bypass strict type checking + }, + inject: [ConfigService], + }), + ], }) export class AppModule {} diff --git a/src/config/config.module.ts b/src/config/config.module.ts new file mode 100644 index 0000000..a8659e7 --- /dev/null +++ b/src/config/config.module.ts @@ -0,0 +1,57 @@ +import { Global, Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + oauth2Config, + sessionConfig, + aiConfig, + mailConfig, + visitorConfig, + linkAccessConfig, + securityConfig, +} from './env'; + +@Global() +@Module({ + providers: [ + ConfigService, + { + provide: 'OAUTH2_CONFIG', + useValue: oauth2Config, + }, + { + provide: 'SESSION_CONFIG', + useValue: sessionConfig, + }, + { + provide: 'AI_CONFIG', + useValue: aiConfig, + }, + { + provide: 'MAIL_CONFIG', + useValue: mailConfig, + }, + { + provide: 'VISITOR_CONFIG', + useValue: visitorConfig, + }, + { + provide: 'LINK_ACCESS_CONFIG', + useValue: linkAccessConfig, + }, + { + provide: 'SECURITY_CONFIG', + useValue: securityConfig, + }, + ], + exports: [ + 'OAUTH2_CONFIG', + 'SESSION_CONFIG', + 'AI_CONFIG', + 'MAIL_CONFIG', + 'VISITOR_CONFIG', + 'LINK_ACCESS_CONFIG', + 'SECURITY_CONFIG', + ConfigService, + ], +}) +export class AppConfigModule {} diff --git a/src/config/env.ts b/src/config/env.ts new file mode 100644 index 0000000..1ca2040 --- /dev/null +++ b/src/config/env.ts @@ -0,0 +1,58 @@ +export const oauth2Config = { + issuer: process.env.OAUTH2_ISSUER || 'http://localhost:3001', + accessTokenTtlSeconds: parseInt( + process.env.OAUTH2_ACCESS_TOKEN_TTL_SECONDS || '3600', + 10, + ), + refreshTokenTtlSeconds: parseInt( + process.env.OAUTH2_REFRESH_TOKEN_TTL_SECONDS || '2592000', + 10, + ), + tokenSigningPrivateKey: process.env.OAUTH2_TOKEN_SIGNING_PRIVATE_KEY || '', + tokenSigningPublicKey: process.env.OAUTH2_TOKEN_SIGNING_PUBLIC_KEY || '', + defaultClientId: process.env.OAUTH2_DEFAULT_CLIENT_ID || 'web-client', + defaultClientSecret: process.env.OAUTH2_DEFAULT_CLIENT_SECRET || 'change_me', + defaultRedirectUri: + process.env.OAUTH2_DEFAULT_REDIRECT_URI || + 'http://localhost:3000/auth/callback', +}; + +export const sessionConfig = { + cookieSecret: process.env.SESSION_COOKIE_SECRET || 'change_me', + cookieName: process.env.SESSION_COOKIE_NAME || 'linkshare_session', + cookieSecure: process.env.SESSION_COOKIE_SECURE === 'true', + cookieHttpOnly: process.env.SESSION_COOKIE_HTTPONLY !== 'false', +}; + +export const aiConfig = { + provider: process.env.AI_PROVIDER || 'openrouter', + apiKey: process.env.AI_API_KEY || '', + baseUrl: process.env.AI_BASE_URL || 'https://openrouter.ai/api/v1', + modelName: process.env.AI_MODEL_NAME || 'meta-llama/llama-3.1-70b-instruct', + reviewEnabled: process.env.AI_REVIEW_ENABLED === 'true', + temperature: parseFloat(process.env.AI_REVIEW_TEMPERATURE || '0.2'), +}; + +export const mailConfig = { + host: process.env.SMTP_HOST || 'smtp.example.com', + port: parseInt(process.env.SMTP_PORT || '587', 10), + user: process.env.SMTP_USER || '', + pass: process.env.SMTP_PASS || '', + from: process.env.SMTP_FROM || 'LinkShare Blog ', +}; + +export const visitorConfig = { + cookieName: process.env.VISITOR_COOKIE_NAME || 'linkshare_visitor', + cookieMaxAgeDays: parseInt( + process.env.VISITOR_COOKIE_MAX_AGE_DAYS || '365', + 10, + ), +}; + +export const linkAccessConfig = { + defaultMode: process.env.LINK_URL_MODE_DEFAULT || 'slug_token', +}; + +export const securityConfig = { + passwordSaltRounds: parseInt(process.env.PASSWORD_SALT_ROUNDS || '12', 10), +}; diff --git a/src/main.ts b/src/main.ts index f76bc8d..5b220c8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,8 +1,56 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; +import { Logger, ValidationPipe } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import helmet from 'helmet'; async function bootstrap() { const app = await NestFactory.create(AppModule); - await app.listen(process.env.PORT ?? 3000); + + // Security headers + app.use(helmet()); + + // CORS configuration + const configService = app.get(ConfigService); + const isProd = configService.get('NODE_ENV') === 'production'; + const allowedOrigins = isProd + ? [configService.get('APP_BASE_URL')].filter(Boolean) + : ['http://localhost:3000', 'http://localhost:3001']; + + app.enableCors({ + origin: allowedOrigins, + credentials: true, + }); + + // Global validation pipe + const isDev = !isProd; + app.useGlobalPipes( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: isDev, + transformOptions: { + enableImplicitConversion: true, + }, + }), + ); + + // Swagger documentation + if (!isProd) { + const config = new DocumentBuilder() + .setTitle('LinkShare Blog API') + .setDescription('API for blog backend') + .setVersion('1.0') + .addBearerAuth() + .build(); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api-docs', app, document); + Logger.log('Swagger docs available at /api-docs', 'Bootstrap'); + } + + const port = process.env.PORT || 3001; + await app.listen(port); + Logger.log(`API server listening on port ${port}`, 'Bootstrap'); } bootstrap(); diff --git a/src/modules/admin/admin-articles/admin-articles.controller.ts b/src/modules/admin/admin-articles/admin-articles.controller.ts new file mode 100644 index 0000000..477e49c --- /dev/null +++ b/src/modules/admin/admin-articles/admin-articles.controller.ts @@ -0,0 +1,142 @@ +import { + Controller, + Get, + Put, + Delete, + Query, + Param, + Req, + UseGuards, + HttpException, + HttpStatus, + HttpCode, + Body, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiResponse, + ApiParam, + ApiQuery, +} from '@nestjs/swagger'; +import { ArticlesService } from '../../articles/articles.service'; +import { RequireSessionUser } from '../../session/require-session-user.guard'; +import { RequireRole } from '../../common/decorators/require-role.decorator'; +import { RequireRoleGuard } from '../../common/guards/require-role.guard'; + +@ApiTags('管理员 - 文章') +@Controller('admin/articles') +@UseGuards(RequireSessionUser, RequireRoleGuard) +@RequireRole('admin') +export class AdminArticlesController { + constructor(private readonly articlesService: ArticlesService) {} + + @Get() + @ApiOperation({ summary: '获取所有文章(管理员视图)' }) + @ApiResponse({ status: 200, description: '成功获取文章列表', type: Object }) + @ApiQuery({ + name: 'page', + required: false, + type: Number, + description: '页码', + }) + @ApiQuery({ + name: 'limit', + required: false, + type: Number, + description: '每页数量', + }) + @ApiQuery({ + name: 'status', + required: false, + enum: ['draft', 'published', 'archived'], + description: '文章状态', + }) + @ApiQuery({ + name: 'visibility', + required: false, + enum: ['public', 'unlisted', 'private'], + description: '文章可见性', + }) + @ApiQuery({ + name: 'authorId', + required: false, + type: Number, + description: '作者 ID', + }) + async listArticles( + @Req() req: any, + @Query('page') page?: string, + @Query('limit') limit?: string, + @Query('status') status?: string, + @Query('visibility') visibility?: string, + @Query('authorId') authorId?: string, + ) { + const query: any = { + page: page ? parseInt(page, 10) : undefined, + limit: limit ? parseInt(limit, 10) : undefined, + where: {}, + }; + + if (status) query.where.status = status; + if (visibility) query.where.visibility = visibility; + if (authorId) query.where.authorId = parseInt(authorId, 10); + + return this.articlesService.findMany({ + ...query, + include: { + author: { + select: { + id: true, + username: true, + displayName: true, + avatarUrl: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + } + + @Put(':id/force') + @ApiBearerAuth() + @ApiOperation({ summary: '强制更新文章(管理员覆盖作者权限)' }) + @ApiResponse({ status: 200, description: '文章更新成功', type: Object }) + @ApiResponse({ status: 400, description: '请求参数无效' }) + @ApiResponse({ status: 401, description: '未授权' }) + @ApiResponse({ status: 403, description: '无权操作' }) + @ApiResponse({ status: 404, description: '文章不存在' }) + @ApiParam({ name: 'id', description: '文章 ID', type: Number }) + async forceUpdateArticle( + @Req() req: any, + @Param('id') id: string, + @Body() updateData: any, + ) { + const articleId = parseInt(id, 10); + const article = await this.articlesService.findById(articleId); + if (!article) { + throw new HttpException('Article not found', HttpStatus.NOT_FOUND); + } + return this.articlesService.update(articleId, updateData); + } + + @Delete(':id/force') + @ApiBearerAuth() + @ApiOperation({ summary: '强制删除文章(管理员覆盖作者权限)' }) + @ApiResponse({ status: 204, description: '文章删除成功' }) + @ApiResponse({ status: 401, description: '未授权' }) + @ApiResponse({ status: 403, description: '无权操作' }) + @ApiResponse({ status: 404, description: '文章不存在' }) + @ApiParam({ name: 'id', description: '文章 ID', type: Number }) + @HttpCode(HttpStatus.NO_CONTENT) + async forceDeleteArticle(@Req() req: any, @Param('id') id: string) { + const articleId = parseInt(id, 10); + const article = await this.articlesService.findById(articleId); + if (!article) { + throw new HttpException('Article not found', HttpStatus.NOT_FOUND); + } + await this.articlesService.remove(articleId); + return; + } +} diff --git a/src/modules/admin/admin-articles/admin-articles.module.ts b/src/modules/admin/admin-articles/admin-articles.module.ts new file mode 100644 index 0000000..9c76520 --- /dev/null +++ b/src/modules/admin/admin-articles/admin-articles.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AdminArticlesController } from './admin-articles.controller'; +import { ArticlesService } from '../../articles/articles.service'; +import { PrismaModule } from '../../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [AdminArticlesController], + providers: [ArticlesService], +}) +export class AdminArticlesModule {} diff --git a/src/modules/admin/admin-comments/admin-comments.controller.ts b/src/modules/admin/admin-comments/admin-comments.controller.ts new file mode 100644 index 0000000..576a436 --- /dev/null +++ b/src/modules/admin/admin-comments/admin-comments.controller.ts @@ -0,0 +1,61 @@ +import { + Controller, + Get, + Put, + Delete, + Query, + Param, + Req, + UseGuards, + HttpException, + HttpStatus, +} from '@nestjs/common'; +import { CommentsService } from '../../comments/comments.service'; +import { RequireSessionUser } from '../../session/require-session-user.guard'; +import { RequireRole } from '../../common/decorators/require-role.decorator'; +import { RequireRoleGuard } from '../../common/guards/require-role.guard'; + +@Controller('admin/comments') +@UseGuards(RequireSessionUser, RequireRoleGuard) +@RequireRole('admin') +export class AdminCommentsController { + constructor(private readonly commentsService: CommentsService) {} + + @Get() + async listComments( + @Req() req: any, + @Query('articleId') articleId?: string, + @Query('status') status?: string, + @Query('authorId') authorId?: string, + @Query('parentId') parentId?: string, + @Query('page') page?: string, + @Query('limit') limit?: string, + ) { + return this.commentsService.findAdminComments({ + page: page ? parseInt(page, 10) : undefined, + limit: limit ? parseInt(limit, 10) : undefined, + articleId: articleId ? parseInt(articleId, 10) : undefined, + status, + authorId: authorId ? parseInt(authorId, 10) : undefined, + parentId: parentId !== undefined ? parseInt(parentId, 10) : undefined, + }); + } + + @Put(':id/status') + async updateStatus(@Param('id') id: string, @Query('status') status: string) { + const commentId = parseInt(id, 10); + return this.commentsService.updateCommentStatus(commentId, status); + } + + @Delete(':id') + async deleteComment(@Param('id') id: string) { + const commentId = parseInt(id, 10); + return this.commentsService.deleteComment(commentId); + } + + @Get(':id/audits') + async getAudits(@Param('id') id: string) { + const commentId = parseInt(id, 10); + return this.commentsService.getCommentAudits(commentId); + } +} diff --git a/src/modules/admin/admin-comments/admin-comments.module.ts b/src/modules/admin/admin-comments/admin-comments.module.ts new file mode 100644 index 0000000..f48c8c1 --- /dev/null +++ b/src/modules/admin/admin-comments/admin-comments.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AdminCommentsController } from './admin-comments.controller'; +import { CommentsService } from '../../comments/comments.service'; +import { PrismaModule } from '../../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [AdminCommentsController], + providers: [CommentsService], +}) +export class AdminCommentsModule {} diff --git a/src/modules/analytics/analytics.controller.ts b/src/modules/analytics/analytics.controller.ts new file mode 100644 index 0000000..d1e505e --- /dev/null +++ b/src/modules/analytics/analytics.controller.ts @@ -0,0 +1,30 @@ +import { + Controller, + Get, + Query, + Req, + UseGuards, + HttpException, + HttpStatus, +} from '@nestjs/common'; +import { AnalyticsService } from './analytics.service'; +import { RequireSessionUser } from '../session/require-session-user.guard'; +import { RequireRole } from '../common/decorators/require-role.decorator'; +import { RequireRoleGuard } from '../common/guards/require-role.guard'; + +@Controller('analytics') +@UseGuards(RequireSessionUser) +export class AnalyticsController { + constructor(private readonly analyticsService: AnalyticsService) {} + + @Get('summary') + @UseGuards(RequireRoleGuard) + @RequireRole('admin') + async getSummary(@Query('days') days?: string) { + const periodDays = days ? parseInt(days, 10) : 30; + if (isNaN(periodDays) || periodDays < 1 || periodDays > 365) { + throw new HttpException('Invalid days parameter', HttpStatus.BAD_REQUEST); + } + return this.analyticsService.getSummary(periodDays); + } +} diff --git a/src/modules/analytics/analytics.module.ts b/src/modules/analytics/analytics.module.ts new file mode 100644 index 0000000..c6bfb6c --- /dev/null +++ b/src/modules/analytics/analytics.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AnalyticsController } from './analytics.controller'; +import { AnalyticsService } from './analytics.service'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [AnalyticsController], + providers: [AnalyticsService], +}) +export class AnalyticsModule {} diff --git a/src/modules/analytics/analytics.service.spec.ts b/src/modules/analytics/analytics.service.spec.ts new file mode 100644 index 0000000..86013ef --- /dev/null +++ b/src/modules/analytics/analytics.service.spec.ts @@ -0,0 +1,198 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AnalyticsService } from './analytics.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { AnalyticsEventType } from './analytics.service'; + +describe('AnalyticsService', () => { + let service: AnalyticsService; + let prismaService: PrismaService; + + const mockPrismaService = { + analyticsEvent: { + create: jest.fn(), + count: jest.fn(), + }, + article: { + findMany: jest.fn(), + }, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AnalyticsService, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + ], + }).compile(); + + service = module.get(AnalyticsService); + prismaService = module.get(PrismaService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('trackEvent', () => { + it('should track event successfully', async () => { + mockPrismaService.analyticsEvent.create.mockResolvedValueOnce({}); + + await service.trackEvent( + AnalyticsEventType.PAGE_VIEW, + 'session-123', + 1, + '127.0.0.1', + 'Mozilla/5.0', + { path: '/articles' }, + ); + + expect(mockPrismaService.analyticsEvent.create).toHaveBeenCalledWith({ + data: { + type: AnalyticsEventType.PAGE_VIEW, + sessionId: 'session-123', + userId: 1, + ip: '127.0.0.1', + userAgent: 'Mozilla/5.0', + data: { path: '/articles' }, + }, + }); + }); + + it('should track event without optional fields', async () => { + mockPrismaService.analyticsEvent.create.mockResolvedValueOnce({}); + + await service.trackEvent( + AnalyticsEventType.COMMENT_SUBMIT, + 'session-456', + ); + + expect(mockPrismaService.analyticsEvent.create).toHaveBeenCalledWith({ + data: { + type: AnalyticsEventType.COMMENT_SUBMIT, + sessionId: 'session-456', + userId: undefined, + ip: undefined, + userAgent: undefined, + data: undefined, + }, + }); + }); + + it('should handle errors gracefully without throwing', async () => { + mockPrismaService.analyticsEvent.create.mockRejectedValueOnce( + new Error('Database error'), + ); + + // Should not throw + await expect( + service.trackEvent(AnalyticsEventType.ARTICLE_VIEW, 'session-789'), + ).resolves.not.toThrow(); + + // Error should be logged but not propagated + expect(mockPrismaService.analyticsEvent.create).toHaveBeenCalled(); + }); + }); + + describe('getSummary', () => { + const mockArticles = [ + { id: 1, slug: 'article-1', title: 'Article 1', viewCount: 100 }, + { id: 2, slug: 'article-2', title: 'Article 2', viewCount: 50 }, + { id: 3, slug: 'article-3', title: 'Article 3', viewCount: 25 }, + ]; + + beforeEach(() => { + mockPrismaService.analyticsEvent.count.mockResolvedValue(150); + mockPrismaService.article.findMany.mockResolvedValue(mockArticles); + }); + + it('should generate summary for default 30 days', async () => { + const summary = await service.getSummary(); + + expect(summary).toEqual( + expect.objectContaining({ + periodDays: 30, + totalPageViews: 150, + totalArticleViews: 175, // 100 + 50 + 25 + uniqueVisitors: 150, + topArticles: mockArticles, + generatedAt: expect.any(Date), + }), + ); + }); + + it('should generate summary for custom period', async () => { + const summary = await service.getSummary(7); + + expect(summary.periodDays).toBe(7); + }); + + it('should count page views correctly', async () => { + await service.getSummary(); + + expect(mockPrismaService.analyticsEvent.count).toHaveBeenCalledWith({ + where: { + createdAt: expect.any(Object), + type: { + in: [AnalyticsEventType.PAGE_VIEW, AnalyticsEventType.ARTICLE_VIEW], + }, + }, + }); + }); + + it('should count unique visitors by distinct sessionId', async () => { + await service.getSummary(); + + expect(mockPrismaService.analyticsEvent.count).toHaveBeenCalledWith({ + where: { + createdAt: expect.any(Object), + sessionId: { not: null }, + }, + distinct: ['sessionId'], + }); + }); + + it('should fetch top articles by view count', async () => { + await service.getSummary(); + + expect(mockPrismaService.article.findMany).toHaveBeenCalledWith({ + orderBy: { viewCount: 'desc' }, + take: 10, + select: { + id: true, + slug: true, + title: true, + viewCount: true, + }, + }); + }); + + it('should handle empty article list', async () => { + mockPrismaService.article.findMany.mockResolvedValue([]); + + const summary = await service.getSummary(); + + expect(summary.totalArticleViews).toBe(0); + expect(summary.topArticles).toEqual([]); + }); + + it('should calculate date range correctly', async () => { + await service.getSummary(1); + + const dateArg = + mockPrismaService.analyticsEvent.count.mock.calls[0][0].where.createdAt + .gte; + expect(dateArg).toBeInstanceOf(Date); + + const now = new Date(); + const expectedDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); + expect(dateArg.getTime()).toBeCloseTo(expectedDate.getTime(), -3); // Within 1 second + }); + }); +}); diff --git a/src/modules/analytics/analytics.service.ts b/src/modules/analytics/analytics.service.ts new file mode 100644 index 0000000..95fd204 --- /dev/null +++ b/src/modules/analytics/analytics.service.ts @@ -0,0 +1,90 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +export enum AnalyticsEventType { + PAGE_VIEW = 'page_view', + ARTICLE_VIEW = 'article_view', + COMMENT_SUBMIT = 'comment_submit', +} + +@Injectable() +export class AnalyticsService { + private readonly logger = new Logger(AnalyticsService.name); + + constructor(private readonly prisma: PrismaService) {} + + async trackEvent( + type: AnalyticsEventType, + sessionId: string, + userId?: number, + ip?: string, + userAgent?: string, + data?: any, + ) { + try { + await this.prisma.analyticsEvent.create({ + data: { + type, + sessionId, + userId, + ip, + userAgent, + data, + }, + }); + this.logger.debug(`Analytics event: ${type} by session ${sessionId}`); + } catch (error) { + this.logger.error(`Failed to track event ${type}: ${error}`); + // Don't throw - analytics should not break main flow + } + } + + async getSummary(periodDays: number = 30) { + const since = new Date(); + since.setDate(since.getDate() - periodDays); + + // Total page views (PAGE_VIEW + ARTICLE_VIEW events) + const [totalPageViews, uniqueVisitors, articles] = await Promise.all([ + this.prisma.analyticsEvent.count({ + where: { + createdAt: { gte: since }, + type: { + in: [AnalyticsEventType.PAGE_VIEW, AnalyticsEventType.ARTICLE_VIEW], + }, + }, + }), + this.prisma.analyticsEvent.count({ + where: { + createdAt: { gte: since }, + sessionId: { not: null }, + }, + distinct: ['sessionId'], + }), + this.prisma.article.findMany({ + orderBy: { viewCount: 'desc' }, + take: 10, + select: { + id: true, + slug: true, + title: true, + viewCount: true, + }, + }), + ]); + + // Total article views from article viewCount fields (sum of all articles' viewCount) + const totalArticleViews = articles.reduce( + (sum: number, a: any) => sum + a.viewCount, + 0, + ); + + return { + periodDays, + totalPageViews, + totalArticleViews, + uniqueVisitors, + topArticles: articles, + generatedAt: new Date(), + }; + } +} diff --git a/src/modules/articles/articles.controller.ts b/src/modules/articles/articles.controller.ts new file mode 100644 index 0000000..837c040 --- /dev/null +++ b/src/modules/articles/articles.controller.ts @@ -0,0 +1,335 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Param, + Body, + Query, + Req, + UseGuards, + HttpCode, + HttpStatus, + HttpException, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiResponse, + ApiParam, + ApiQuery, +} from '@nestjs/swagger'; +import { ArticlesService } from './articles.service'; +import { + CreateArticleDto, + UpdateArticleDto, + ArticleQueryDto, +} from './dto/article.dto'; +import { RequireSessionUser } from '../session/require-session-user.guard'; +import { AnalyticsService } from '../analytics/analytics.service'; +import { AnalyticsEventType } from '../analytics/analytics.service'; + +@ApiTags('文章') +@Controller('articles') +export class ArticlesController { + constructor( + private readonly articlesService: ArticlesService, + private readonly analyticsService: AnalyticsService, + ) {} + + @Post() + @UseGuards(RequireSessionUser) + @ApiBearerAuth() + @ApiOperation({ summary: '创建文章' }) + @ApiResponse({ status: 201, description: '文章创建成功', type: Object }) + @ApiResponse({ status: 400, description: '请求参数无效' }) + @ApiResponse({ status: 401, description: '未授权' }) + async createArticle( + @Req() req: any, + @Body() createArticleDto: CreateArticleDto, + ) { + // 作者必须是当前登录用户 + const authorId = req.user.id; + const article = await this.articlesService.create({ + ...createArticleDto, + authorId, + }); + return article; + } + + @Get() + @ApiOperation({ summary: '获取文章列表' }) + @ApiResponse({ status: 200, description: '成功获取文章列表', type: Object }) + @ApiQuery({ + name: 'page', + required: false, + type: Number, + description: '页码', + }) + @ApiQuery({ + name: 'limit', + required: false, + type: Number, + description: '每页数量', + }) + @ApiQuery({ + name: 'status', + required: false, + enum: ['draft', 'published', 'archived'], + description: '文章状态', + }) + @ApiQuery({ + name: 'visibility', + required: false, + enum: ['public', 'unlisted', 'private'], + description: '文章可见性', + }) + @ApiQuery({ + name: 'authorId', + required: false, + type: String, + description: '作者 ID', + }) + async getArticles(@Req() req: any, @Query() query: ArticleQueryDto) { + // Track page view + this.analyticsService + .trackEvent( + AnalyticsEventType.PAGE_VIEW, + req.sessionID, + req.user?.id, + req.ip, + req.headers['user-agent'], + { path: '/articles' }, + ) + .catch((err) => { + console.error('Failed to track page view:', err); + }); + + const { page = 1, limit = 20, status, visibility, authorId } = query; + const skip = (page - 1) * limit; + const result = await this.articlesService.findMany({ + skip, + take: limit, + where: { + ...(status && { status }), + ...(visibility && { visibility }), + ...(authorId && { authorId: parseInt(authorId, 10) }), + }, + include: { + author: { + select: { + id: true, + username: true, + displayName: true, + avatarUrl: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + const total = result.total || 0; + const totalPages = Math.ceil(total / limit); + return { + articles: result.articles, + pagination: { + page, + limit, + total, + totalPages, + }, + }; + } + + @Get(':id') + @ApiOperation({ summary: '根据 ID 获取文章' }) + @ApiResponse({ status: 200, description: '成功获取文章', type: Object }) + @ApiResponse({ status: 404, description: '文章不存在' }) + @ApiParam({ name: 'id', description: '文章 ID', type: Number }) + async getArticle(@Req() req: any, @Param('id') id: string) { + const articleId = parseInt(id, 10); + const article = await this.articlesService.findById(articleId, { + include: { + author: { + select: { + id: true, + username: true, + displayName: true, + avatarUrl: true, + }, + }, + reactions: { + include: { + user: { + select: { id: true, username: true }, + }, + }, + }, + comments: { + where: { status: 'approved' }, + include: { + author: { + select: { id: true, username: true, displayName: true }, + }, + replies: { + where: { status: 'approved' }, + include: { + author: { + select: { id: true, username: true, displayName: true }, + }, + }, + orderBy: { createdAt: 'asc' }, + }, + }, + orderBy: { createdAt: 'asc' }, + }, + }, + }); + + if (!article) { + throw new HttpException('Article not found', HttpStatus.NOT_FOUND); + } + + // Track view (debounced per session) + this.trackArticleView(req, articleId); + + return article; + } + + @Get('slug/:slug') + @ApiOperation({ summary: '根据 Slug 获取文章' }) + @ApiResponse({ status: 200, description: '成功获取文章', type: Object }) + @ApiResponse({ status: 404, description: '文章不存在' }) + @ApiParam({ name: 'slug', description: '文章 Slug', type: String }) + async getArticleBySlug(@Req() req: any, @Param('slug') slug: string) { + const article = await this.articlesService.findBySlug(slug, { + include: { + author: { + select: { + id: true, + username: true, + displayName: true, + avatarUrl: true, + }, + }, + reactions: { + include: { + user: { + select: { id: true, username: true }, + }, + }, + }, + comments: { + where: { status: 'approved' }, + include: { + author: { + select: { id: true, username: true, displayName: true }, + }, + replies: { + where: { status: 'approved' }, + include: { + author: { + select: { id: true, username: true, displayName: true }, + }, + }, + orderBy: { createdAt: 'asc' }, + }, + }, + orderBy: { createdAt: 'asc' }, + }, + }, + }); + + if (!article) { + throw new HttpException('Article not found', HttpStatus.NOT_FOUND); + } + + // Track view (debounced per session) + this.trackArticleView(req, article.id); + + return article; + } + + @Put(':id') + @UseGuards(RequireSessionUser) + @ApiBearerAuth() + @ApiOperation({ summary: '更新文章' }) + @ApiResponse({ status: 200, description: '文章更新成功', type: Object }) + @ApiResponse({ status: 400, description: '请求参数无效' }) + @ApiResponse({ status: 401, description: '未授权' }) + @ApiResponse({ status: 403, description: '无权修改此文章' }) + @ApiResponse({ status: 404, description: '文章不存在' }) + @ApiParam({ name: 'id', description: '文章 ID', type: Number }) + async updateArticle( + @Req() req: any, + @Param('id') id: string, + @Body() updateArticleDto: UpdateArticleDto, + ) { + const articleId = parseInt(id, 10); + const article = await this.articlesService.findById(articleId); + if (!article) { + throw new HttpException('Article not found', HttpStatus.NOT_FOUND); + } + // 只有作者或 admin 可以更新 + const currentUser = req.user; + if (article.authorId !== currentUser.id && currentUser.role !== 'admin') { + throw new HttpException('Forbidden', HttpStatus.FORBIDDEN); + } + return this.articlesService.update(articleId, updateArticleDto); + } + + @Delete(':id') + @UseGuards(RequireSessionUser) + @ApiBearerAuth() + @ApiOperation({ summary: '删除文章' }) + @ApiResponse({ status: 204, description: '文章删除成功' }) + @ApiResponse({ status: 401, description: '未授权' }) + @ApiResponse({ status: 403, description: '无权删除此文章' }) + @ApiResponse({ status: 404, description: '文章不存在' }) + @ApiParam({ name: 'id', description: '文章 ID', type: Number }) + @HttpCode(HttpStatus.NO_CONTENT) + async deleteArticle(@Req() req: any, @Param('id') id: string) { + const articleId = parseInt(id, 10); + const article = await this.articlesService.findById(articleId); + if (!article) { + throw new HttpException('Article not found', HttpStatus.NOT_FOUND); + } + const currentUser = req.user; + if (article.authorId !== currentUser.id && currentUser.role !== 'admin') { + throw new HttpException('Forbidden', HttpStatus.FORBIDDEN); + } + await this.articlesService.remove(articleId); + return; + } + + private trackArticleView(req: any, articleId: number): void { + // Initialize session tracking if not present + if (!req.session.viewedArticles) { + req.session.viewedArticles = []; + } + if (!req.session.viewedArticles.includes(articleId)) { + // Mark as viewed + req.session.viewedArticles.push(articleId); + // Increment article viewCount + this.articlesService + .update(articleId, { viewCount: { increment: 1 } } as any) + .catch((err) => { + console.error('Failed to increment view count:', err); + }); + // Track analytics event + this.analyticsService + .trackEvent( + AnalyticsEventType.ARTICLE_VIEW, + req.sessionID, + req.user?.id, + req.ip, + req.headers['user-agent'], + { articleId }, + ) + .catch((err) => { + console.error('Failed to track analytics:', err); + }); + } + } +} diff --git a/src/modules/articles/articles.module.ts b/src/modules/articles/articles.module.ts new file mode 100644 index 0000000..952a729 --- /dev/null +++ b/src/modules/articles/articles.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { ArticlesController } from './articles.controller'; +import { ArticlesService } from './articles.service'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [ArticlesController], + providers: [ArticlesService], +}) +export class ArticlesModule {} diff --git a/src/modules/articles/articles.service.ts b/src/modules/articles/articles.service.ts new file mode 100644 index 0000000..37b116f --- /dev/null +++ b/src/modules/articles/articles.service.ts @@ -0,0 +1,79 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateArticleDto, UpdateArticleDto } from './dto/article.dto'; + +@Injectable() +export class ArticlesService { + constructor(private readonly prisma: PrismaService) {} + + async create(data: CreateArticleDto & { authorId: number }) { + return this.prisma.article.create({ + data, + include: { + author: { + select: { + id: true, + username: true, + displayName: true, + avatarUrl: true, + }, + }, + }, + }); + } + + async findMany(params: { + skip: number; + take: number; + where?: any; + include?: any; + orderBy?: any; + }) { + const [articles, total] = await Promise.all([ + this.prisma.article.findMany({ + ...params, + }), + this.prisma.article.count({ + where: params.where, + }), + ]); + return { articles, total }; + } + + async findById(id: number, include?: any) { + return this.prisma.article.findUnique({ + where: { id }, + ...(include && { include }), + }); + } + + async findBySlug(slug: string, include?: any) { + return this.prisma.article.findUnique({ + where: { slug }, + ...(include && { include }), + }); + } + + async update(id: number, data: UpdateArticleDto) { + return this.prisma.article.update({ + where: { id }, + data, + include: { + author: { + select: { + id: true, + username: true, + displayName: true, + avatarUrl: true, + }, + }, + }, + }); + } + + async remove(id: number) { + return this.prisma.article.delete({ + where: { id }, + }); + } +} diff --git a/src/modules/articles/dto/article.dto.ts b/src/modules/articles/dto/article.dto.ts new file mode 100644 index 0000000..c049424 --- /dev/null +++ b/src/modules/articles/dto/article.dto.ts @@ -0,0 +1,224 @@ +import { + IsString, + IsOptional, + IsEnum, + IsUUID, + IsInt, + Min, + Max, + IsNotEmpty, + Length, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; + +export enum ArticleStatus { + DRAFT = 'draft', + PUBLISHED = 'published', + ARCHIVED = 'archived', +} + +export enum ArticleVisibility { + PUBLIC = 'public', + UNLISTED = 'unlisted', + PRIVATE = 'private', +} + +export class CreateArticleDto { + @ApiProperty({ + description: '文章标题', + example: '如何构建 NestJS 应用', + minLength: 1, + maxLength: 200, + }) + @IsString() + @IsNotEmpty() + @Length(1, 200) + title: string; + + @ApiProperty({ + description: '文章内容(支持 Markdown 或 HTML)', + example: '# 欢迎阅读\n这是一篇示例文章...', + }) + @IsString() + @IsNotEmpty() + content: string; + + @ApiPropertyOptional({ + description: '文章摘要', + example: '本文将介绍如何构建一个完整的博客平台...', + maxLength: 500, + }) + @IsOptional() + @IsString() + @Length(0, 500) + excerpt?: string; + + @ApiPropertyOptional({ + description: '封面图片 URL', + example: 'https://example.com/cover.jpg', + }) + @IsOptional() + @IsString() + coverImageUrl?: string; + + @ApiProperty({ + description: '文章状态', + enum: ArticleStatus, + default: ArticleStatus.DRAFT, + example: ArticleStatus.DRAFT, + }) + @IsEnum(ArticleStatus) + status: ArticleStatus = ArticleStatus.DRAFT; + + @ApiProperty({ + description: '文章可见性', + enum: ArticleVisibility, + default: ArticleVisibility.PUBLIC, + example: ArticleVisibility.PUBLIC, + }) + @IsEnum(ArticleVisibility) + visibility: ArticleVisibility = ArticleVisibility.PUBLIC; + + @ApiPropertyOptional({ + description: '密码哈希(用于密码保护文章)', + example: '$2a$10$...', + deprecated: true, + }) + @IsOptional() + @IsString() + passwordHash?: string; + + @ApiPropertyOptional({ + description: '访问令牌(用于私密文章)', + example: 'abc123token', + }) + @IsOptional() + @IsString() + token?: string; +} + +export class UpdateArticleDto { + @ApiPropertyOptional({ + description: '文章标题', + example: '更新后的标题', + minLength: 1, + maxLength: 200, + }) + @IsOptional() + @IsString() + @Length(1, 200) + title?: string; + + @ApiPropertyOptional({ + description: '文章内容', + example: '# 更新后的内容\n...', + }) + @IsOptional() + @IsString() + content?: string; + + @ApiPropertyOptional({ + description: '文章摘要', + example: '更新后的摘要...', + maxLength: 500, + }) + @IsOptional() + @IsString() + @Length(0, 500) + excerpt?: string; + + @ApiPropertyOptional({ + description: '封面图片 URL', + example: 'https://example.com/new-cover.jpg', + }) + @IsOptional() + @IsString() + coverImageUrl?: string; + + @ApiPropertyOptional({ + description: '文章状态', + enum: ArticleStatus, + example: ArticleStatus.PUBLISHED, + }) + @IsOptional() + @IsEnum(ArticleStatus) + status?: ArticleStatus; + + @ApiPropertyOptional({ + description: '文章可见性', + enum: ArticleVisibility, + example: ArticleVisibility.PRIVATE, + }) + @IsOptional() + @IsEnum(ArticleVisibility) + visibility?: ArticleVisibility; + + @ApiPropertyOptional({ + description: '密码哈希', + example: '$2a$10$...', + deprecated: true, + }) + @IsOptional() + @IsString() + passwordHash?: string; + + @ApiPropertyOptional({ + description: '访问令牌', + example: 'new-token-xyz', + }) + @IsOptional() + @IsString() + token?: string; +} + +export class ArticleQueryDto { + @ApiPropertyOptional({ + description: '页码(从 1 开始)', + example: 1, + minimum: 1, + default: 1, + }) + @IsOptional() + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ + description: '每页数量', + example: 20, + minimum: 1, + maximum: 100, + default: 20, + }) + @IsOptional() + @IsInt() + @Min(1) + limit?: number = 20; + + @ApiPropertyOptional({ + description: '按文章状态筛选', + enum: ArticleStatus, + example: ArticleStatus.PUBLISHED, + }) + @IsOptional() + @IsEnum(ArticleStatus) + status?: ArticleStatus; + + @ApiPropertyOptional({ + description: '按文章可见性筛选', + enum: ArticleVisibility, + example: ArticleVisibility.PUBLIC, + }) + @IsOptional() + @IsEnum(ArticleVisibility) + visibility?: ArticleVisibility; + + @ApiPropertyOptional({ + description: '按作者 ID 筛选', + example: 1, + }) + @IsOptional() + @IsString() + authorId?: string; +} diff --git a/src/modules/comments/comments.controller.ts b/src/modules/comments/comments.controller.ts new file mode 100644 index 0000000..dacd618 --- /dev/null +++ b/src/modules/comments/comments.controller.ts @@ -0,0 +1,76 @@ +import { Controller, Post, Get, Req, Body, Query } from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiParam, + ApiQuery, +} from '@nestjs/swagger'; +import { CommentsService } from './comments.service'; +import { AnalyticsService } from '../analytics/analytics.service'; +import { AnalyticsEventType } from '../analytics/analytics.service'; + +@ApiTags('评论') +@Controller('articles') +export class CommentsController { + constructor( + private readonly commentsService: CommentsService, + private readonly analyticsService: AnalyticsService, + ) {} + + @Post(':id/comments') + @ApiOperation({ summary: '创建评论' }) + @ApiResponse({ status: 201, description: '评论创建成功', type: Object }) + @ApiResponse({ status: 400, description: '请求参数无效' }) + @ApiParam({ name: 'id', description: '文章 ID', type: Number }) + async createComment( + @Req() req: any, + @Body() createCommentDto: { content: string }, + ) { + const articleId = parseInt(req.params.id, 10); + const comment = await this.commentsService.createComment( + articleId, + createCommentDto.content, + ); + + // Track comment submission + this.analyticsService + .trackEvent( + AnalyticsEventType.COMMENT_SUBMIT, + req.sessionID, + req.user?.id, + req.ip, + req.headers['user-agent'], + { + articleId, + commentId: comment.id, + contentLength: comment.content.length, + }, + ) + .catch((err) => { + console.error('Failed to track comment event:', err); + }); + + return comment; + } + + @Get(':id/comments') + @ApiOperation({ summary: '获取文章的评论' }) + @ApiResponse({ status: 200, description: '成功获取评论列表', type: [Object] }) + @ApiParam({ name: 'id', description: '文章 ID', type: Number }) + @ApiQuery({ + name: 'status', + required: false, + enum: ['pending', 'approved', 'rejected'], + description: '评论状态', + }) + async getComments(@Req() req: any, @Query('status') status?: string) { + const articleId = parseInt(req.params.id, 10); + const effectiveStatus = status || 'approved'; + const comments = await this.commentsService.getCommentsByArticle( + articleId, + effectiveStatus, + ); + return comments; + } +} diff --git a/src/modules/comments/comments.module.ts b/src/modules/comments/comments.module.ts new file mode 100644 index 0000000..d2a48dc --- /dev/null +++ b/src/modules/comments/comments.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { CommentsController } from './comments.controller'; +import { CommentsService } from './comments.service'; + +@Module({ + controllers: [CommentsController], + providers: [CommentsService], +}) +export class CommentsModule {} diff --git a/src/modules/comments/comments.service.ts b/src/modules/comments/comments.service.ts new file mode 100644 index 0000000..8833400 --- /dev/null +++ b/src/modules/comments/comments.service.ts @@ -0,0 +1,184 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { ModerationService } from '../moderation/moderation.service'; +import { JobsService } from '../jobs/jobs.service'; +import { ModerationStatus } from '../moderation/moderation.service'; + +@Injectable() +export class CommentsService { + private readonly logger = new Logger(CommentsService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly moderationService: ModerationService, + private readonly jobsService: JobsService, + ) {} + + async createComment( + articleId: number, + content: string, + authorName?: string, + authorEmail?: string, + authorId?: number, + ) { + // 先规则审核 + const ruleResult = await this.moderationService.ruleBasedModerate(content); + const status = ruleResult.status; + + const comment = await this.prisma.comment.create({ + data: { + articleId, + content, + status, + authorName, + authorEmail, + authorId, + }, + }); + + // 如果可疑,触发 AI 异步审核 + if (status === ModerationStatus.SUSPICIOUS) { + await this.jobsService.addModerationReview(comment.id); + } + + return comment; + } + + async getCommentsByArticle(articleId: number, status: string = 'approved') { + const comments = await this.prisma.comment.findMany({ + where: { + articleId, + status, + parentId: null, + }, + include: { + author: true, + replies: { + where: { status: 'approved' }, + include: { author: true }, + orderBy: { createdAt: 'asc' }, + }, + }, + orderBy: { createdAt: 'asc' }, + }); + return comments; + } + + // Admin: list all comments with filters + async findAdminComments(params: { + page?: number; + limit?: number; + articleId?: number; + status?: string; + authorId?: number; + parentId?: number; + }) { + const { + page = 1, + limit = 20, + articleId, + status, + authorId, + parentId, + } = params; + const skip = (page - 1) * limit; + + const [comments, total] = await Promise.all([ + this.prisma.comment.findMany({ + skip, + take: limit, + where: { + ...(articleId && { articleId }), + ...(status && { status }), + ...(authorId && { authorId }), + ...(parentId !== undefined && { parentId }), + }, + include: { + author: true, + article: { + select: { id: true, slug: true, title: true }, + }, + parent: { + select: { id: true, content: true, authorName: true }, + }, + }, + orderBy: { createdAt: 'desc' }, + }), + this.prisma.comment.count({ + where: { + ...(articleId && { articleId }), + ...(status && { status }), + ...(authorId && { authorId }), + ...(parentId !== undefined && { parentId }), + }, + }), + ]); + + return { + comments, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + // Admin: update comment status + async updateCommentStatus(id: number, status: string) { + const comment = await this.prisma.comment.update({ + where: { id }, + data: { status }, + }); + return comment; + } + + // Admin: delete comment (hard delete) + async deleteComment(id: number) { + // Check for replies - maybe cascade? We'll just delete; in schema, replies are children with parentId. + // If there are replies, we should delete them too or prevent deletion. For simplicity, delete only if no replies. + const comment = await this.prisma.comment.findUnique({ + where: { id }, + include: { replies: true }, + }); + if (!comment) { + throw new Error('Comment not found'); + } + if (comment.replies && comment.replies.length > 0) { + // Optionally delete all replies recursively or just disallow + // For now, we'll delete all descendants + await this.deleteCommentTree(id); + return { id }; + } + await this.prisma.comment.delete({ + where: { id }, + }); + return { id }; + } + + private async deleteCommentTree(commentId: number): Promise { + // Delete all descendants + const replies = await this.prisma.comment.findMany({ + where: { parentId: commentId }, + }); + for (const reply of replies) { + await this.deleteCommentTree(reply.id); + } + await this.prisma.comment.delete({ + where: { id: commentId }, + }); + } + + // Admin: get audit history for a comment + async getCommentAudits(commentId: number) { + const audits = await this.prisma.commentAudit.findMany({ + where: { commentId }, + orderBy: { reviewedAt: 'desc' }, + include: { + reviewer: { + select: { id: true, username: true, displayName: true }, + }, + }, + }); + return audits; + } +} diff --git a/src/modules/common/decorators/require-role.decorator.ts b/src/modules/common/decorators/require-role.decorator.ts new file mode 100644 index 0000000..9702623 --- /dev/null +++ b/src/modules/common/decorators/require-role.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; + +export const REQUIRED_ROLES_KEY = 'required_roles'; +export const RequireRole = (...roles: string[]) => + SetMetadata(REQUIRED_ROLES_KEY, roles); diff --git a/src/modules/common/guards/require-role.guard.ts b/src/modules/common/guards/require-role.guard.ts new file mode 100644 index 0000000..daec988 --- /dev/null +++ b/src/modules/common/guards/require-role.guard.ts @@ -0,0 +1,33 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + ForbiddenException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { REQUIRED_ROLES_KEY } from '../decorators/require-role.decorator'; + +@Injectable() +export class RequireRoleGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.get( + REQUIRED_ROLES_KEY, + context.getHandler(), + ); + if (!requiredRoles) { + return true; // No role requirement + } + const request = context.switchToHttp().getRequest(); + const user = request.user; + if (!user || !user.role) { + throw new ForbiddenException('Insufficient permissions'); + } + const hasRole = requiredRoles.includes(user.role); + if (!hasRole) { + throw new ForbiddenException('Insufficient permissions'); + } + return true; + } +} diff --git a/src/modules/email/email.module.ts b/src/modules/email/email.module.ts new file mode 100644 index 0000000..fb5aac5 --- /dev/null +++ b/src/modules/email/email.module.ts @@ -0,0 +1,41 @@ +import { Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bullmq'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { EmailService } from './email.service'; +import { EmailQueueService } from './email.queue.service'; +import { EmailProcessor } from './processors/email.processor'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [ + ConfigModule, + PrismaModule, + BullModule.forRootAsync({ + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => ({ + connection: { + host: configService.get('REDIS_HOST') || 'localhost', + port: configService.get('REDIS_PORT') || 6379, + }, + defaultJobOptions: { + removeOnComplete: { + count: 1000, + age: 24 * 60 * 60 * 1000, + }, + removeOnFail: { + count: 1000, + age: 24 * 60 * 60 * 1000, + }, + }, + }), + inject: [ConfigService], + }), + BullModule.registerQueue({ + name: 'email', + }), + ], + controllers: [], + providers: [EmailService, EmailQueueService, EmailProcessor], + exports: [EmailService, EmailQueueService], +}) +export class EmailModule {} diff --git a/src/modules/email/email.queue.service.ts b/src/modules/email/email.queue.service.ts new file mode 100644 index 0000000..cd927f7 --- /dev/null +++ b/src/modules/email/email.queue.service.ts @@ -0,0 +1,68 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Job, Queue } from 'bullmq'; +import { ConfigService } from '@nestjs/config'; +import { EmailService } from './email.service'; +import { PrismaService } from '../prisma/prisma.service'; + +export interface EmailJobData { + to: string; + subject: string; + html: string; + text?: string; + userId?: number; + attempt?: number; +} + +@Injectable() +export class EmailQueueService { + private readonly logger = new Logger(EmailQueueService.name); + private queue: Queue; + + constructor( + private configService: ConfigService, + private emailService: EmailService, + private prisma: PrismaService, + ) { + this.initializeQueue(); + } + + private initializeQueue() { + const redisHost = + this.configService.get('REDIS_HOST') || 'localhost'; + const redisPort = this.configService.get('REDIS_PORT') || 6379; + + this.queue = new Queue('email', { + connection: { + host: redisHost, + port: redisPort, + }, + defaultJobOptions: { + attempts: 3, + backoff: { + type: 'exponential', + delay: 5000, + }, + removeOnComplete: { + count: 1000, + age: 24 * 60 * 60 * 1000, // 24 hours + }, + removeOnFail: { + count: 1000, + age: 24 * 60 * 60 * 1000, + }, + }, + }); + + this.logger.log('Email queue initialized'); + } + + async addToQueue(data: EmailJobData): Promise { + return await this.queue.add('send-email', data, { + delay: 0, // send immediately + }); + } + + async getQueue() { + return this.queue; + } +} diff --git a/src/modules/email/email.service.spec.ts b/src/modules/email/email.service.spec.ts new file mode 100644 index 0000000..b64b013 --- /dev/null +++ b/src/modules/email/email.service.spec.ts @@ -0,0 +1,213 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EmailService } from './email.service'; +import { ConfigService } from '@nestjs/config'; +import nodemailer from 'nodemailer'; + +describe('EmailService', () => { + let service: EmailService; + let configService: ConfigService; + let mockTransporter: Partial; + + const mockConfigService = { + get: jest.fn((key: string) => { + const config: Record = { + SMTP_HOST: 'smtp.example.com', + SMTP_PORT: 587, + SMTP_USER: 'test@example.com', + SMTP_PASS: 'password', + SMTP_SECURE: false, + SMTP_FROM: '"Test" ', + }; + return config[key]; + }), + }; + + const mockSendMail = jest.fn(); + const mockVerify = jest.fn((callback: (error: Error | null) => void) => + callback(null), + ); + + mockTransporter = { + sendMail: mockSendMail, + verify: mockVerify, + } as any; + + beforeEach(async () => { + // Mock nodemailer.createTransport to return our mock transporter + const createTransportSpy = jest + .spyOn(require('nodemailer'), 'createTransport') + .mockReturnValue(mockTransporter); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EmailService, + { + provide: ConfigService, + useValue: mockConfigService, + }, + ], + }) + .overrideProvider(ConfigService) + .useValue(mockConfigService) + .compile(); + + service = module.get(EmailService); + configService = module.get(ConfigService); + + // Save spy for restoration + (service as any).createTransportSpy = createTransportSpy; + }); + + afterEach(() => { + jest.clearAllMocks(); + // Restore spy + if ((service as any).createTransportSpy) { + (service as any).createTransportSpy.mockRestore(); + } + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('initializeTransporter', () => { + it('should create SMTP transporter with correct configuration', () => { + // Verify was called during initialization + expect(mockVerify).toHaveBeenCalled(); + }); + + it('should handle missing SMTP configuration gracefully', async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EmailService, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string) => undefined), + }, + }, + ], + }).compile(); + + const serviceWithoutSmtp = module.get(EmailService); + expect(serviceWithoutSmtp).toBeDefined(); + }); + }); + + describe('sendMail', () => { + const mailOptions = { + to: 'recipient@example.com', + subject: 'Test Subject', + html: '

Test content

', + text: 'Test content', + }; + + it('should send email successfully', async () => { + mockSendMail.mockResolvedValueOnce({ + messageId: 'test-message-id', + accepted: [mailOptions.to], + rejected: [], + }); + + const result = await service.sendMail(mailOptions); + + expect(mockSendMail).toHaveBeenCalledWith({ + from: expect.stringContaining('Test'), + to: mailOptions.to, + subject: mailOptions.subject, + html: mailOptions.html, + text: mailOptions.text, + }); + expect(result).toEqual({ messageId: 'test-message-id' }); + }); + + it('should use default from address when SMTP_FROM not set', async () => { + const configWithoutFrom = { + get: jest.fn((key: string) => { + if (key === 'SMTP_FROM') return undefined; + return mockConfigService.get(key); + }), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EmailService, + { + provide: ConfigService, + useValue: configWithoutFrom, + }, + ], + }).compile(); + + const serviceWithoutFrom = module.get(EmailService); + (serviceWithoutFrom as any).transporter = mockTransporter; + + mockSendMail.mockResolvedValueOnce({ + messageId: 'test-message-id', + accepted: [mailOptions.to], + rejected: [], + }); + + await serviceWithoutFrom.sendMail(mailOptions); + + expect(mockSendMail).toHaveBeenCalledWith({ + from: expect.stringMatching(/^"Blog" { + mockSendMail.mockRejectedValueOnce(new Error('SMTP error')); + + await expect(service.sendMail(mailOptions)).rejects.toThrow('SMTP error'); + }); + }); + + describe('renderRegistrationEmail', () => { + it('should render registration email with correct content', () => { + const email = service.renderRegistrationEmail( + 'TestUser', + 'https://example.com/confirm', + ); + + expect(email).toContain('Welcome, TestUser!'); + expect(email).toContain('https://example.com/confirm'); + expect(email).toContain('Confirm Email'); + expect(email).toContain('LinkShare Blog'); + }); + }); + + describe('renderPasswordResetEmail', () => { + it('should render password reset email with correct content', () => { + const email = service.renderPasswordResetEmail( + 'TestUser', + 'https://example.com/reset', + ); + + expect(email).toContain('Hello TestUser,'); + expect(email).toContain('https://example.com/reset'); + expect(email).toContain('Reset Password'); + expect(email).toContain('expire in 1 hour'); + }); + }); + + describe('renderNotificationEmail', () => { + it('should render notification email with correct content', () => { + const email = service.renderNotificationEmail( + 'Test Subject', + 'Test message content', + ); + + expect(email).toContain('Test Subject'); + expect(email).toContain('Test message content'); + expect(email).toContain('automated notification'); + }); + }); +}); diff --git a/src/modules/email/email.service.ts b/src/modules/email/email.service.ts new file mode 100644 index 0000000..f4caf8e --- /dev/null +++ b/src/modules/email/email.service.ts @@ -0,0 +1,166 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import nodemailer from 'nodemailer'; + +@Injectable() +export class EmailService { + private readonly logger = new Logger(EmailService.name); + private transporter: nodemailer.Transporter; + + constructor(private configService: ConfigService) { + this.initializeTransporter(); + } + + private initializeTransporter() { + const host = this.configService.get('SMTP_HOST'); + const port = this.configService.get('SMTP_PORT'); + const user = this.configService.get('SMTP_USER'); + const pass = this.configService.get('SMTP_PASS'); + const secure = this.configService.get('SMTP_SECURE') ?? true; + + if (!host || !port) { + this.logger.warn('SMTP not configured, email sending disabled'); + this.transporter = nodemailer.createTransport({ + streamTransport: true, + // Dummy transport for development + } as any); + return; + } + + this.transporter = nodemailer.createTransport({ + host, + port, + secure, + auth: user && pass ? { user, pass } : undefined, + }); + + // Verify connection configuration + this.transporter.verify((error: Error | null) => { + if (error) { + this.logger.error(`SMTP connection error: ${error.message}`); + } else { + this.logger.log('SMTP transporter verified'); + } + }); + } + + async sendMail(options: { + to: string; + subject: string; + html: string; + text?: string; + }): Promise<{ messageId?: string }> { + try { + const result = await this.transporter.sendMail({ + from: + this.configService.get('SMTP_FROM') || + `"Blog" <${this.configService.get('SMTP_USER')}>`, + to: options.to, + subject: options.subject, + html: options.html, + text: options.text, + }); + + this.logger.log( + `Email sent to ${options.to}, messageId: ${result.messageId}`, + ); + return { messageId: result.messageId }; + } catch (error) { + this.logger.error(`Failed to send email to ${options.to}: ${error}`); + throw error; + } + } + + // Template methods + renderRegistrationEmail(username: string, confirmUrl: string): string { + return ` + + + + + Welcome to LinkShare Blog + + + +
+

Welcome, ${username}!

+

Thank you for registering at LinkShare Blog. To activate your account, please click the button below:

+

Confirm Email

+

If the button doesn't work, copy and paste this URL into your browser:

+

${confirmUrl}

+ +
+ + + `.trim(); + } + + renderPasswordResetEmail(username: string, resetUrl: string): string { + return ` + + + + + Password Reset - LinkShare Blog + + + +
+

Password Reset

+

Hello ${username},

+

We received a request to reset your password. Click the button below to set a new password:

+

Reset Password

+

This link will expire in 1 hour.

+

If you didn't request a password reset, please ignore this email. Your password will remain unchanged.

+ +
+ + + `.trim(); + } + + renderNotificationEmail(subject: string, message: string): string { + return ` + + + + + ${subject} + + + +
+

${subject}

+
${message}
+ +
+ + + `.trim(); + } +} diff --git a/src/modules/email/processors/email.processor.ts b/src/modules/email/processors/email.processor.ts new file mode 100644 index 0000000..eed8538 --- /dev/null +++ b/src/modules/email/processors/email.processor.ts @@ -0,0 +1,39 @@ +import { Processor, Process } from '@nestjs/bullmq'; +import type { Job } from 'bullmq'; +import { Injectable, Logger } from '@nestjs/common'; +import { EmailQueueService } from '../email.queue.service'; +import { EmailService } from '../email.service'; + +@Injectable() +@Processor('email') +export class EmailProcessor { + private readonly logger = new Logger(EmailProcessor.name); + + constructor( + private emailQueueService: EmailQueueService, + private emailService: EmailService, + ) {} + + @Process('send-email') + async processSendEmail( + job: Job<{ to: string; subject: string; html: string; text?: string }>, + ) { + const { to, subject, html, text } = job.data; + this.logger.debug(`Processing email to ${to}, subject: ${subject}`); + + try { + const result = await this.emailService.sendMail({ + to, + subject, + html, + text, + }); + + this.logger.log(`Email sent to ${to}, messageId: ${result.messageId}`); + return { success: true, messageId: result.messageId }; + } catch (error) { + this.logger.error(`Failed to send email to ${to}: ${error}`); + throw error; // BullMQ will retry based on job options + } + } +} diff --git a/src/modules/health/health.controller.ts b/src/modules/health/health.controller.ts new file mode 100644 index 0000000..6554435 --- /dev/null +++ b/src/modules/health/health.controller.ts @@ -0,0 +1,55 @@ +import { Controller, Get, Logger } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { JobsModule } from '../jobs/jobs.module'; +import { Queue } from 'bullmq'; + +@Controller() +export class HealthController { + private readonly logger = new Logger(HealthController.name); + + constructor(private readonly prisma: PrismaService) {} + + @Get('health') + async getHealth() { + const health = { + ok: true, + timestamp: new Date().toISOString(), + checks: { + database: 'unknown', + redis: 'unknown', + }, + }; + + // Check database + try { + // Simple query to check connection + await this.prisma.user.count({ where: {} }); + health.checks.database = 'ok'; + } catch (error) { + this.logger.error(`Database health check failed: ${error}`); + health.checks.database = 'error'; + health.ok = false; + } + + // Check Redis (via BullMQ) + try { + // We can get the email queue and check connection + const queue = new Queue('email', { + connection: { + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT || '6379', 10), + }, + } as any); + const connected = await (queue as any).getConnection().ping(); + if (connected) health.checks.redis = 'ok'; + else health.checks.redis = 'error'; + await (queue as any).close(); + } catch (error) { + this.logger.error(`Redis health check failed: ${error}`); + health.checks.redis = 'error'; + health.ok = false; + } + + return health; + } +} diff --git a/src/modules/health/health.module.ts b/src/modules/health/health.module.ts new file mode 100644 index 0000000..7476abe --- /dev/null +++ b/src/modules/health/health.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { HealthController } from './health.controller'; + +@Module({ + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/src/modules/init/init.controller.ts b/src/modules/init/init.controller.ts new file mode 100644 index 0000000..3f59b50 --- /dev/null +++ b/src/modules/init/init.controller.ts @@ -0,0 +1,86 @@ +import { + Controller, + Get, + Post, + Req, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiResponse, +} from '@nestjs/swagger'; +import { InitService, InitStatus } from './init.service'; +import { RequireSessionUser } from '../session/require-session-user.guard'; +import { RequireRole } from '../common/decorators/require-role.decorator'; +import { RequireRoleGuard } from '../common/guards/require-role.guard'; + +@ApiTags('系统初始化') +@Controller('init') +@UseGuards(RequireSessionUser, RequireRoleGuard) +@RequireRole('admin') +export class InitController { + constructor(private readonly initService: InitService) {} + + @Get('status') + @ApiBearerAuth() + @ApiOperation({ summary: '获取系统初始化状态' }) + @ApiResponse({ + status: 200, + description: '成功获取初始化状态', + schema: { + type: 'object', + properties: { + initialized: { type: 'boolean' }, + checks: { + type: 'object', + properties: { + environment: { type: 'string', enum: ['ok', 'missing', 'error'] }, + database: { type: 'string', enum: ['ok', 'error', 'not_migrated'] }, + seedData: { type: 'string', enum: ['ok', 'error', 'not_seeded'] }, + oauth2: { type: 'string', enum: ['ok', 'error', 'not_found'] }, + adminUser: { type: 'string', enum: ['ok', 'error', 'not_found'] }, + }, + }, + details: { + type: 'object', + properties: { + missingEnvVars: { type: 'array', items: { type: 'string' } }, + dbError: { type: 'string' }, + seedError: { type: 'string' }, + }, + }, + }, + }, + }) + @ApiResponse({ status: 401, description: '未授权' }) + @ApiResponse({ status: 403, description: '无权访问' }) + async getInitStatus(@Req() req: any) { + return this.initService.checkInitializationStatus(); + } + + @Post('run') + @ApiBearerAuth() + @ApiOperation({ summary: '手动运行系统初始化(仅开发环境)' }) + @ApiResponse({ + status: 200, + description: '初始化完成', + schema: { + type: 'object', + properties: { + success: { type: 'boolean' }, + message: { type: 'string' }, + }, + }, + }) + @ApiResponse({ status: 401, description: '未授权' }) + @ApiResponse({ status: 403, description: '无权访问' }) + @HttpCode(HttpStatus.OK) + async runInit(@Req() req: any) { + const result = await this.initService.runInitialization(); + return result; + } +} diff --git a/src/modules/init/init.module.ts b/src/modules/init/init.module.ts new file mode 100644 index 0000000..b2e922f --- /dev/null +++ b/src/modules/init/init.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { InitController } from './init.controller'; +import { InitService } from './init.service'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [InitController], + providers: [InitService], +}) +export class InitModule {} diff --git a/src/modules/init/init.service.ts b/src/modules/init/init.service.ts new file mode 100644 index 0000000..b4f3396 --- /dev/null +++ b/src/modules/init/init.service.ts @@ -0,0 +1,195 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PrismaService } from '../prisma/prisma.service'; + +export type CheckStatus = + | 'ok' + | 'missing' + | 'error' + | 'not_migrated' + | 'not_seeded' + | 'not_found'; + +export interface InitStatus { + initialized: boolean; + checks: { + environment: CheckStatus; + database: CheckStatus; + seedData: CheckStatus; + oauth2: CheckStatus; + adminUser: CheckStatus; + }; + details?: { + missingEnvVars?: string[]; + dbError?: string; + seedError?: string; + }; +} + +@Injectable() +export class InitService { + private readonly logger = new Logger(InitService.name); + + // 必需的环境变量列表 + private readonly requiredEnvVars = [ + 'DATABASE_URL', + 'SESSION_COOKIE_SECRET', + 'AI_API_KEY', + 'SMTP_HOST', + 'SMTP_PORT', + 'SMTP_USER', + 'SMTP_PASS', + ]; + + constructor( + private readonly configService: ConfigService, + private readonly prisma: PrismaService, + ) {} + + async checkInitializationStatus(): Promise { + const checks: InitStatus['checks'] = { + environment: 'ok', + database: 'ok', + seedData: 'ok', + oauth2: 'ok', + adminUser: 'ok', + }; + const details: InitStatus['details'] = {}; + + // 1. 检查环境变量 + const missingEnvVars = this.requiredEnvVars.filter( + (key) => !this.configService.get(key), + ); + if (missingEnvVars.length > 0) { + checks.environment = 'missing'; + details.missingEnvVars = missingEnvVars; + } + + // 2. 检查数据库连接和迁移 + try { + // 检查数据库连接 + await this.prisma.user.count(); + + // 检查是否已迁移(检查关键表是否存在) + const tablesExist = await this.checkDatabaseTables(); + if (!tablesExist) { + checks.database = 'not_migrated'; + checks.seedData = 'not_seeded'; + return { initialized: false, checks, details }; + } + } catch (error) { + this.logger.error(`Database check failed: ${error}`); + checks.database = 'error'; + details.dbError = error instanceof Error ? error.message : String(error); + return { initialized: false, checks, details }; + } + + // 3. 检查种子数据 + try { + const adminCount = await this.prisma.user.count({ + where: { role: 'admin' }, + }); + if (adminCount === 0) { + checks.adminUser = 'not_found'; + checks.seedData = 'not_seeded'; + } + + const oauth2Client = await this.prisma.oAuth2Client.findFirst({ + where: { clientId: 'web-client' }, + }); + if (!oauth2Client) { + checks.oauth2 = 'not_found'; + } + } catch (error) { + this.logger.error(`Seed data check failed: ${error}`); + checks.seedData = 'error'; + details.seedError = + error instanceof Error ? error.message : String(error); + } + + const isInitialized = + checks.environment === 'ok' && + checks.database === 'ok' && + checks.seedData === 'ok' && + checks.oauth2 === 'ok' && + checks.adminUser === 'ok'; + + return { + initialized: isInitialized, + checks, + details: Object.keys(details).length > 0 ? details : undefined, + }; + } + + private async checkDatabaseTables(): Promise { + try { + // 检查关键表是否存在且可访问 + const [userCount, articleCount, oauth2ClientCount] = await Promise.all([ + this.prisma.user.count(), + this.prisma.article.count(), + this.prisma.oAuth2Client.count(), + ]); + + this.logger.debug( + `Database tables check: users=${userCount}, articles=${articleCount}, oauth2Clients=${oauth2ClientCount}`, + ); + + return true; + } catch (error) { + this.logger.error(`Database tables check error: ${error}`); + return false; + } + } + + async runInitialization(): Promise<{ success: boolean; message: string }> { + try { + this.logger.log('Starting system initialization...'); + + // 1. 检查环境变量 + const missingEnvVars = this.requiredEnvVars.filter( + (key) => !this.configService.get(key), + ); + if (missingEnvVars.length > 0) { + throw new Error( + `Missing required environment variables: ${missingEnvVars.join(', ')}`, + ); + } + + // 2. 检查数据库连接 + try { + await this.prisma.user.count(); + } catch (error) { + throw new Error( + `Database connection failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + // 3. 检查是否已初始化 + const status = await this.checkInitializationStatus(); + if (status.initialized) { + return { + success: true, + message: 'System is already initialized', + }; + } + + // 4. 运行种子脚本(通过 Prisma CLI) + this.logger.log('Running database seed...'); + // 注意:这里不直接运行 prisma db seed,因为那是 CLI 命令 + // 实际种子数据应该在 docker-compose 启动时通过 prisma db seed 执行 + // 这里只是检查状态 + + return { + success: true, + message: + 'Initialization check completed. Please ensure prisma db seed has been executed.', + }; + } catch (error) { + this.logger.error(`Initialization failed: ${error}`); + return { + success: false, + message: error instanceof Error ? error.message : String(error), + }; + } + } +} diff --git a/src/modules/jobs/job-names.constants.ts b/src/modules/jobs/job-names.constants.ts new file mode 100644 index 0000000..9094f51 --- /dev/null +++ b/src/modules/jobs/job-names.constants.ts @@ -0,0 +1,3 @@ +export const JOB_NAMES = { + MODERATION_LLM_REVIEW: 'moderation:llm_review', +}; diff --git a/src/modules/jobs/jobs.module.ts b/src/modules/jobs/jobs.module.ts new file mode 100644 index 0000000..d5941e8 --- /dev/null +++ b/src/modules/jobs/jobs.module.ts @@ -0,0 +1,33 @@ +import { Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bullmq'; +import { ConfigService } from '@nestjs/config'; +import { JobsService } from './jobs.service'; +import { ModerationProcessor } from './processors/moderation.processor'; + +@Module({ + imports: [ + BullModule.forRootAsync({ + useFactory: async (configService: ConfigService) => ({ + connection: { + host: configService.get('REDIS_HOST') || 'redis', + port: parseInt(configService.get('REDIS_PORT') || '6379', 10), + password: configService.get('REDIS_PASSWORD') || undefined, + }, + defaultJobOptions: { + attempts: 3, + backoff: { + type: 'exponential', + delay: 5000, + }, + }, + }), + inject: [ConfigService], + }), + BullModule.registerQueue({ + name: 'moderation', + }), + ], + providers: [JobsService, ModerationProcessor], + exports: [JobsService], +}) +export class JobsModule {} diff --git a/src/modules/jobs/jobs.service.ts b/src/modules/jobs/jobs.service.ts new file mode 100644 index 0000000..6d7c581 --- /dev/null +++ b/src/modules/jobs/jobs.service.ts @@ -0,0 +1,37 @@ +import { Injectable, OnModuleInit, Inject } from '@nestjs/common'; +import { Queue, Worker, Job } from 'bullmq'; +import { ModerationService } from '../moderation/moderation.service'; + +export const MODERATION_QUEUE_NAME = 'moderation'; + +@Injectable() +export class JobsService implements OnModuleInit { + private worker: Worker; + + constructor( + @Inject(MODERATION_QUEUE_NAME) private moderationQueue: Queue, + private readonly moderationService: ModerationService, + ) {} + + async onModuleInit() { + this.worker = new Worker( + MODERATION_QUEUE_NAME, + async (job: Job) => { + if ((job as any).name === 'moderation:llm_review') { + const { commentId } = (job as any).data; + await this.moderationService.aiReview(commentId); + } + }, + { + connection: (this.moderationQueue as any).client, + }, + ); + (this.worker as any).on('failed', (job: Job, error: Error) => { + console.error(`Job ${(job as any).id} failed:`, error); + }); + } + + async addModerationReview(commentId: number) { + await this.moderationQueue.add('moderation:llm_review', { commentId }); + } +} diff --git a/src/modules/jobs/processors/moderation.processor.ts b/src/modules/jobs/processors/moderation.processor.ts new file mode 100644 index 0000000..deec401 --- /dev/null +++ b/src/modules/jobs/processors/moderation.processor.ts @@ -0,0 +1,16 @@ +import { Processor, Process } from '@nestjs/bullmq'; +import { Injectable } from '@nestjs/common'; +import { ModerationService } from '../../moderation/moderation.service'; +import type { Job } from 'bullmq'; + +@Injectable() +@Processor('moderation') +export class ModerationProcessor { + constructor(private readonly moderationService: ModerationService) {} + + @Process('moderation:llm_review') + async asyncProcess(job: Job) { + const { commentId } = job.data; + await this.moderationService.aiReview(commentId); + } +} diff --git a/src/modules/me/me.controller.ts b/src/modules/me/me.controller.ts new file mode 100644 index 0000000..688a77c --- /dev/null +++ b/src/modules/me/me.controller.ts @@ -0,0 +1,18 @@ +import { Controller, Get, Req, UnauthorizedException } from '@nestjs/common'; +import { OAuth2Guard } from '../oauth2/oauth2.guard'; +import { RequireSessionUser } from '../session/require-session-user.guard'; + +@Controller() +export class MeController { + @Get('me') + async me(@Req() req: any) { + // 优先使用 OAuth2 token,否则使用 session + if (req.user && req.user.userId) { + return { ok: true, user: req.user }; + } + if (req.user && req.user.id) { + return { ok: true, user: req.user }; + } + throw new UnauthorizedException('Not authenticated'); + } +} diff --git a/src/modules/me/me.module.ts b/src/modules/me/me.module.ts new file mode 100644 index 0000000..2e559d2 --- /dev/null +++ b/src/modules/me/me.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { MeController } from './me.controller'; + +@Module({ + controllers: [MeController], +}) +export class MeModule {} diff --git a/src/modules/moderation/moderation.module.ts b/src/modules/moderation/moderation.module.ts new file mode 100644 index 0000000..5506b52 --- /dev/null +++ b/src/modules/moderation/moderation.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { ModerationService } from './moderation.service'; + +@Module({ + providers: [ModerationService], + exports: [ModerationService], +}) +export class ModerationModule {} diff --git a/src/modules/moderation/moderation.service.spec.ts b/src/modules/moderation/moderation.service.spec.ts new file mode 100644 index 0000000..beea76a --- /dev/null +++ b/src/modules/moderation/moderation.service.spec.ts @@ -0,0 +1,311 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ModerationService } from './moderation.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { ModerationStatus } from './moderation.service'; + +describe('ModerationService', () => { + let service: ModerationService; + let prismaService: PrismaService; + + const mockPrismaService = { + comment: { + findUnique: jest.fn(), + update: jest.fn(), + }, + commentAudit: { + create: jest.fn(), + }, + }; + + const mockAiConfig = { + reviewEnabled: true, + baseUrl: 'https://api.openrouter.ai/v1', + apiKey: 'test-api-key', + modelName: 'openai/gpt-3.5-turbo', + promptTemplate: undefined, + temperature: 0.2, + siteUrl: 'http://localhost:3001', + title: 'Test Blog', + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ModerationService, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + { + provide: 'AI_CONFIG', + useValue: mockAiConfig, + }, + ], + }).compile(); + + service = module.get(ModerationService); + prismaService = module.get(PrismaService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('ruleBasedModerate', () => { + it('should reject content with blocked keywords', async () => { + const result = await service.ruleBasedModerate( + 'This contains badword1 in it', + ); + expect(result.status).toBe(ModerationStatus.REJECTED); + expect(result.reason).toContain('badword1'); + }); + + it('should approve clean content', async () => { + const result = await service.ruleBasedModerate('This is a clean comment'); + expect(result.status).toBe(ModerationStatus.APPROVED); + expect(result.reason).toBeUndefined(); + }); + + it('should mark suspicious content that is too long', async () => { + const longContent = 'a'.repeat(1001); + const result = await service.ruleBasedModerate(longContent); + expect(result.status).toBe(ModerationStatus.SUSPICIOUS); + expect(result.reason).toBe('Too long'); + }); + + it('should be case insensitive for keyword matching', async () => { + const result = await service.ruleBasedModerate('This contains BADWORD2'); + expect(result.status).toBe(ModerationStatus.REJECTED); + }); + }); + + describe('aiReview', () => { + it('should skip AI review when disabled', async () => { + const disabledConfig = { ...mockAiConfig, reviewEnabled: false }; + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ModerationService, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + { + provide: 'AI_CONFIG', + useValue: disabledConfig, + }, + ], + }).compile(); + + const serviceWithDisabledAi = + module.get(ModerationService); + + await serviceWithDisabledAi.aiReview(1); + + expect(mockPrismaService.comment.findUnique).not.toHaveBeenCalled(); + }); + + it('should handle non-existent comment gracefully', async () => { + mockPrismaService.comment.findUnique.mockResolvedValueOnce(null); + + await service.aiReview(999); + + expect(mockPrismaService.comment.findUnique).toHaveBeenCalledWith({ + where: { id: 999 }, + include: expect.anything(), + }); + }); + + it('should process AI review successfully', async () => { + const mockComment = { + id: 1, + content: 'Test comment', + article: { + title: 'Test Article', + content: 'Test content', + }, + }; + + mockPrismaService.comment.findUnique.mockResolvedValueOnce(mockComment); + + // Mock successful AI response + global.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: async () => ({ + choices: [ + { + message: { + content: JSON.stringify({ + decision: 'approved', + reason: 'Good comment', + score: 0.95, + }), + }, + }, + ], + }), + }); + + await service.aiReview(1); + + expect(mockPrismaService.commentAudit.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + commentId: 1, + status: ModerationStatus.APPROVED, + reason: 'Good comment', + score: 0.95, + }), + }); + + expect(mockPrismaService.comment.update).toHaveBeenCalledWith({ + where: { id: 1 }, + data: { status: ModerationStatus.APPROVED }, + }); + }); + + it('should handle AI API errors with fallback to rule-based', async () => { + const mockComment = { + id: 2, + content: 'test comment', + article: { + title: 'Test Article', + content: 'Test content', + }, + }; + + mockPrismaService.comment.findUnique.mockResolvedValueOnce(mockComment); + + // Mock fetch to throw error + global.fetch = jest.fn().mockRejectedValueOnce(new Error('API error')); + + await service.aiReview(2); + + // Should fall back to rule-based moderation + expect(mockPrismaService.commentAudit.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + commentId: 2, + reason: expect.stringContaining('AI error'), + }), + }); + }); + }); + + describe('callOpenRouter', () => { + it('should call OpenRouter API with correct parameters', async () => { + const mockResponse = { + choices: [ + { + message: { + content: JSON.stringify({ + decision: 'approved', + reason: 'Clean content', + score: 0.9, + }), + }, + }, + ], + }; + + global.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + }); + + // Access private method via type assertion (for testing only) + const result = await (service as any).callOpenRouter('Test content'); + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/chat/completions'), + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + Authorization: 'Bearer test-api-key', + }), + body: expect.stringContaining('Test content'), + }), + ); + + expect(result).toEqual({ + decision: 'approved', + reason: 'Clean content', + score: 0.9, + }); + }); + + it('should handle non-OK HTTP responses', async () => { + global.fetch = jest.fn().mockResolvedValueOnce({ + ok: false, + status: 400, + statusText: 'Bad Request', + text: async () => 'Invalid request', + }); + + await expect( + (service as any).callOpenRouter('Test content'), + ).rejects.toThrow('OpenRouter API error'); + }); + + it('should handle invalid JSON response', async () => { + global.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: async () => ({ + choices: [{ message: { content: 'invalid json' } }], + }), + }); + + await expect( + (service as any).callOpenRouter('Test content'), + ).rejects.toThrow('Failed to parse AI response'); + }); + + it('should validate decision values', async () => { + global.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: async () => ({ + choices: [ + { + message: { + content: JSON.stringify({ + decision: 'invalid', + reason: 'Test', + score: 0.5, + }), + }, + }, + ], + }), + }); + + await expect( + (service as any).callOpenRouter('Test content'), + ).rejects.toThrow('Invalid decision'); + }); + + it('should validate score range', async () => { + global.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: async () => ({ + choices: [ + { + message: { + content: JSON.stringify({ + decision: 'approved', + reason: 'Test', + score: 1.5, + }), + }, + }, + ], + }), + }); + + await expect( + (service as any).callOpenRouter('Test content'), + ).rejects.toThrow('Invalid score'); + }); + }); +}); diff --git a/src/modules/moderation/moderation.service.ts b/src/modules/moderation/moderation.service.ts new file mode 100644 index 0000000..9809f00 --- /dev/null +++ b/src/modules/moderation/moderation.service.ts @@ -0,0 +1,191 @@ +import { Injectable, Inject, Logger } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +export enum ModerationStatus { + APPROVED = 'approved', + REJECTED = 'rejected', + SUSPICIOUS = 'suspicious', +} + +@Injectable() +export class ModerationService { + private readonly logger = new Logger(ModerationService.name); + private defaultPromptTemplate = ` +You are a content moderator for a blog platform. Review the following comment and classify it as one of: +- "approved": clean, respectful, relevant +- "rejected": contains profanity, harassment, spam, or off-topic +- "suspicious": borderline, needs human review + +Consider: +- Tone: respectful vs aggressive +- Content: relevant to the article vs off-topic +- Spam indicators: excessive links, promotional content + +Comment content: "{{content}}" + +Respond ONLY with a JSON object containing: +{ + "decision": "approved|rejected|suspicious", + "reason": "Brief explanation (1-2 sentences)", + "score": 0.0-1.0 (confidence level) +} +`.trim(); + + constructor( + private readonly prisma: PrismaService, + @Inject('AI_CONFIG') private readonly aiConfig: any, + ) {} + + async ruleBasedModerate( + content: string, + ): Promise<{ status: ModerationStatus; reason?: string }> { + const blockedKeywords = ['badword1', 'badword2', 'spam']; + const lower = content.toLowerCase(); + for (const word of blockedKeywords) { + if (lower.includes(word)) { + return { + status: ModerationStatus.REJECTED, + reason: `Blocked keyword: ${word}`, + }; + } + } + if (content.length > 1000) { + return { status: ModerationStatus.SUSPICIOUS, reason: 'Too long' }; + } + return { status: ModerationStatus.APPROVED }; + } + + async aiReview(commentId: number) { + // Skip if AI review disabled + if (!this.aiConfig.reviewEnabled) { + this.logger.debug(`AI review disabled, skipping comment ${commentId}`); + return; + } + + const comment = await this.prisma.comment.findUnique({ + where: { id: commentId }, + include: { + article: { + select: { title: true, content: true }, + }, + }, + }); + if (!comment) return; + + try { + const decision = await this.callOpenRouter(comment.content); + this.logger.log( + `AI review for comment ${commentId}: ${decision.decision} (score: ${decision.score})`, + ); + + const status = + decision.decision === 'approved' + ? ModerationStatus.APPROVED + : decision.decision === 'rejected' + ? ModerationStatus.REJECTED + : ModerationStatus.SUSPICIOUS; + + await this.prisma.commentAudit.create({ + data: { + commentId, + status, + reason: decision.reason, + score: decision.score, + }, + }); + + await this.prisma.comment.update({ + where: { id: commentId }, + data: { status }, + }); + } catch (error) { + this.logger.error(`AI review failed for comment ${commentId}: ${error}`); + // Fallback: if rule-based says suspicious, keep suspicious; otherwise approve + const ruleBased = await this.ruleBasedModerate(comment.content); + const fallbackStatus = + ruleBased.status === ModerationStatus.SUSPICIOUS + ? ModerationStatus.SUSPICIOUS + : ModerationStatus.APPROVED; + + await this.prisma.commentAudit.create({ + data: { + commentId, + status: fallbackStatus, + reason: `AI error: ${error instanceof Error ? error.message : 'Unknown error'}. Fallback to ${fallbackStatus}.`, + score: null, + }, + }); + + await this.prisma.comment.update({ + where: { id: commentId }, + data: { status: fallbackStatus }, + }); + } + } + + private async callOpenRouter(content: string): Promise<{ + decision: 'approved' | 'rejected' | 'suspicious'; + reason: string; + score: number; + }> { + const prompt = this.aiConfig.promptTemplate || this.defaultPromptTemplate; + const renderedPrompt = prompt.replace(/\{\{content\}\}/g, content); + + const response = await fetch(this.aiConfig.baseUrl + '/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.aiConfig.apiKey}`, + 'HTTP-Referer': this.aiConfig.siteUrl || 'http://localhost:3001', + 'X-Title': this.aiConfig.title || 'LinkShare Blog', + }, + body: JSON.stringify({ + model: this.aiConfig.modelName, + messages: [ + { + role: 'system', + content: + 'You are a content moderation assistant that responds in JSON.', + }, + { role: 'user', content: renderedPrompt }, + ], + temperature: this.aiConfig.temperature || 0.2, + response_format: { type: 'json_object' }, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `OpenRouter API error: ${response.status} ${response.statusText} - ${errorText}`, + ); + } + + const data = await response.json(); + const contentStr = data.choices?.[0]?.message?.content; + if (!contentStr) { + throw new Error('Invalid response from OpenRouter: no content'); + } + + try { + const parsed = JSON.parse(contentStr); + const decision = parsed.decision?.toLowerCase(); + if (!['approved', 'rejected', 'suspicious'].includes(decision)) { + throw new Error(`Invalid decision: ${decision}`); + } + const score = parseFloat(parsed.score); + if (isNaN(score) || score < 0 || score > 1) { + throw new Error(`Invalid score: ${score}`); + } + return { + decision: decision as 'approved' | 'rejected' | 'suspicious', + reason: parsed.reason || 'No reason provided', + score, + }; + } catch (e) { + throw new Error( + `Failed to parse AI response: ${e instanceof Error ? e.message : e}`, + ); + } + } +} diff --git a/src/modules/oauth2/dto/oauth2.dto.ts b/src/modules/oauth2/dto/oauth2.dto.ts new file mode 100644 index 0000000..4b4f979 --- /dev/null +++ b/src/modules/oauth2/dto/oauth2.dto.ts @@ -0,0 +1,23 @@ +export class AuthorizationCodeDto { + response_type!: 'code'; + client_id!: string; + redirect_uri!: string; + scope?: string; + state?: string; +} + +export class TokenRequestDto { + grant_type!: 'authorization_code' | 'client_credentials'; + code?: string; + redirect_uri?: string; + client_id?: string; + client_secret?: string; +} + +export class TokenResponseDto { + access_token!: string; + token_type!: string; + expires_in!: number; + refresh_token?: string; + scope?: string; +} diff --git a/src/modules/oauth2/oauth2.controller.ts b/src/modules/oauth2/oauth2.controller.ts new file mode 100644 index 0000000..4433e1f --- /dev/null +++ b/src/modules/oauth2/oauth2.controller.ts @@ -0,0 +1,71 @@ +import { + Controller, + Get, + Post, + Req, + Query, + UnauthorizedException, + BadRequestException, +} from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; +import { OAuth2Service } from './oauth2.service'; +import { RequireSessionUser } from '../session/require-session-user.guard'; +import type { Request } from 'express'; + +@Controller('oauth2') +export class OAuth2Controller { + constructor(private readonly oauth2Service: OAuth2Service) {} + + @Get('authorize') + async authorize(@Req() req: Request, @Query() query: any) { + const { response_type, client_id, redirect_uri, scope, state } = query; + + const result = await this.oauth2Service.authorize( + response_type, + client_id, + redirect_uri, + scope, + state, + (req.session as any)?.userId, + ); + + if (result.redirectUrl.startsWith('/')) { + // 重定向到前端 + return req.res!.redirect(303, result.redirectUrl); + } + return req.res!.redirect(303, result.redirectUrl); + } + + @Post('token') + @Throttle({ + limit: 10, + ttl: 60000, + } as any) + async token(@Req() req: Request) { + const grantType = req.body.grant_type; + const { code, redirect_uri, client_id, client_secret } = req.body; + + try { + const tokenResponse = await this.oauth2Service.token( + grantType, + code, + redirect_uri, + client_id, + client_secret, + ); + return tokenResponse; + } catch (error) { + if (error instanceof BadRequestException) { + throw error; + } + throw new UnauthorizedException('invalid_grant'); + } + } + + @Get('logout') + async logout(@Req() req: Request) { + // 可选:清理 session 或 token + // 这里只返回成功,实际登出由前端 session 处理 + return { ok: true }; + } +} diff --git a/src/modules/oauth2/oauth2.guard.ts b/src/modules/oauth2/oauth2.guard.ts new file mode 100644 index 0000000..2f122bf --- /dev/null +++ b/src/modules/oauth2/oauth2.guard.ts @@ -0,0 +1,24 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { CanActivate, ExecutionContext } from '@nestjs/common'; +import { OAuth2Service } from './oauth2.service'; + +@Injectable() +export class OAuth2Guard implements CanActivate { + constructor(private readonly oauth2Service: OAuth2Service) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const authHeader = request.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + throw new UnauthorizedException('missing_token'); + } + const token = authHeader.substring(7); + const validation = await this.oauth2Service.validateAccessToken(token); + if (!validation) { + throw new UnauthorizedException('invalid_token'); + } + // 将 token 信息注入 request + request.user = validation; + return true; + } +} diff --git a/src/modules/oauth2/oauth2.module.ts b/src/modules/oauth2/oauth2.module.ts new file mode 100644 index 0000000..7dc313b --- /dev/null +++ b/src/modules/oauth2/oauth2.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { OAuth2Controller } from './oauth2.controller'; +import { OAuth2Service } from './oauth2.service'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [OAuth2Controller], + providers: [OAuth2Service], + exports: [OAuth2Service], +}) +export class OAuth2Module {} diff --git a/src/modules/oauth2/oauth2.service.ts b/src/modules/oauth2/oauth2.service.ts new file mode 100644 index 0000000..677814a --- /dev/null +++ b/src/modules/oauth2/oauth2.service.ts @@ -0,0 +1,256 @@ +import { + Injectable, + Inject, + BadRequestException, + UnauthorizedException, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import * as crypto from 'crypto'; +import * as jwt from 'jsonwebtoken'; +import { oauth2Config } from '../../config/env'; + +@Injectable() +export class OAuth2Service { + constructor( + private readonly prisma: PrismaService, + @Inject('OAUTH2_CONFIG') private readonly oauth2Config: any, + ) {} + + async authorize( + responseType: string, + clientId: string, + redirectUri: string, + scope?: string, + state?: string, + userId?: number, + ): Promise<{ redirectUrl: string }> { + if (responseType !== 'code') { + throw new BadRequestException('unsupported_response_type'); + } + + const client = await this.prisma.oAuth2Client.findUnique({ + where: { clientId }, + }); + if (!client) { + throw new BadRequestException('invalid_client'); + } + + // 验证 redirect_uri 是否在客户端注册的列表中 + if (!client.redirectUris.includes(redirectUri)) { + throw new BadRequestException('invalid_redirect_uri'); + } + + if (!userId) { + // 未登录,重定向到前端登录页,带上 next=当前URL(编码后) + const next = encodeURIComponent( + `/oauth2/authorize?response_type=${responseType}&client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${scope || ''}&state=${state || ''}`, + ); + const loginUrl = `/login?next=${next}`; + return { redirectUrl: loginUrl }; + } + + // 已登录,生成 authorization code + const code = crypto.randomBytes(32).toString('hex'); + const codeHash = crypto.createHash('sha256').update(code).digest('hex'); + const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes + + await this.prisma.oAuth2AuthorizationCode.create({ + data: { + codeHash, + clientId, + userId, + redirectUri, + scopes: scope || '', + expiresAt, + }, + }); + + // 重定向回 redirect_uri,携带 code 和 state + const url = new URL(redirectUri); + url.searchParams.set('code', code); + if (state) { + url.searchParams.set('state', state); + } + return { redirectUrl: url.toString() }; + } + + async token( + grantType: string, + code?: string, + redirectUri?: string, + clientId?: string, + clientSecret?: string, + ): Promise { + if (grantType === 'authorization_code') { + if (!code || !redirectUri || !clientId || !clientSecret) { + throw new BadRequestException('invalid_request'); + } + + const client = await this.prisma.oAuth2Client.findUnique({ + where: { clientId }, + }); + if (!client || client.clientSecret !== clientSecret) { + throw new UnauthorizedException('invalid_client'); + } + + const codeHash = crypto.createHash('sha256').update(code).digest('hex'); + const authCode = await this.prisma.oAuth2AuthorizationCode.findFirst({ + where: { codeHash }, + }); + + if (!authCode) { + throw new BadRequestException('invalid_grant'); + } + if (authCode.used) { + throw new BadRequestException('invalid_grant'); + } + if (authCode.expiresAt < new Date()) { + throw new BadRequestException('invalid_grant'); + } + if (authCode.redirectUri !== redirectUri) { + throw new BadRequestException('invalid_grant'); + } + + // 标记为已使用 + await this.prisma.oAuth2AuthorizationCode.update({ + where: { id: authCode.id }, + data: { used: true }, + }); + + // 生成 access token 和 refresh token + const accessToken = this.signToken({ + sub: authCode.userId?.toString() || '', + clientId: authCode.clientId, + scope: authCode.scopes, + }); + const refreshToken = crypto.randomBytes(32).toString('hex'); + + const expiresAt = new Date( + Date.now() + this.oauth2Config.accessTokenTtlSeconds * 1000, + ); + const refreshExpiresAt = new Date( + Date.now() + this.oauth2Config.refreshTokenTtlSeconds * 1000, + ); + + await this.prisma.oAuth2Token.create({ + data: { + accessTokenHash: crypto + .createHash('sha256') + .update(accessToken) + .digest('hex'), + refreshTokenHash: crypto + .createHash('sha256') + .update(refreshToken) + .digest('hex'), + clientId: authCode.clientId, + userId: authCode.userId, + scopes: authCode.scopes, + expiresAt, + }, + }); + + return { + access_token: accessToken, + token_type: 'Bearer', + expires_in: this.oauth2Config.accessTokenTtlSeconds, + refresh_token: refreshToken, + scope: authCode.scopes, + }; + } + + if (grantType === 'client_credentials') { + if (!clientId || !clientSecret) { + throw new BadRequestException('invalid_request'); + } + + const client = await this.prisma.oAuth2Client.findUnique({ + where: { clientId }, + }); + if (!client || client.clientSecret !== clientSecret) { + throw new UnauthorizedException('invalid_client'); + } + + const accessToken = this.signToken({ + sub: '', + clientId, + scope: client.scopes, + }); + const expiresAt = new Date( + Date.now() + this.oauth2Config.accessTokenTtlSeconds * 1000, + ); + + await this.prisma.oAuth2Token.create({ + data: { + accessTokenHash: crypto + .createHash('sha256') + .update(accessToken) + .digest('hex'), + clientId, + userId: null, + scopes: client.scopes, + expiresAt, + }, + }); + + return { + access_token: accessToken, + token_type: 'Bearer', + expires_in: this.oauth2Config.accessTokenTtlSeconds, + scope: client.scopes, + }; + } + + throw new BadRequestException('unsupported_grant_type'); + } + + async validateAccessToken( + accessToken: string, + ): Promise<{ userId?: number; clientId: string; scope: string } | null> { + try { + const payload = jwt.verify( + accessToken, + this.oauth2Config.tokenSigningPublicKey, + { algorithms: ['RS256'] }, + ) as any; + + const tokenHash = crypto + .createHash('sha256') + .update(accessToken) + .digest('hex'); + const tokenRecord = await this.prisma.oAuth2Token.findFirst({ + where: { accessTokenHash: tokenHash }, + }); + + if ( + !tokenRecord || + tokenRecord.revoked || + tokenRecord.expiresAt < new Date() + ) { + return null; + } + + return { + userId: tokenRecord.userId, + clientId: tokenRecord.clientId, + scope: tokenRecord.scopes, + }; + } catch (e) { + return null; + } + } + + private signToken(payload: { + sub: string; + clientId: string; + scope: string; + }): string { + const privateKey = this.oauth2Config.tokenSigningPrivateKey; + if (!privateKey) { + throw new Error('OAuth2 token signing private key not configured'); + } + return jwt.sign(payload, privateKey, { + algorithm: 'RS256', + expiresIn: `${this.oauth2Config.accessTokenTtlSeconds}s`, + }); + } +} diff --git a/src/modules/prisma/prisma.module.ts b/src/modules/prisma/prisma.module.ts new file mode 100644 index 0000000..ec0ce32 --- /dev/null +++ b/src/modules/prisma/prisma.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/src/modules/prisma/prisma.service.ts b/src/modules/prisma/prisma.service.ts new file mode 100644 index 0000000..36c186a --- /dev/null +++ b/src/modules/prisma/prisma.service.ts @@ -0,0 +1,651 @@ +import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +// 定义 Prisma 客户端的精简接口 +interface PrismaClientLike extends PrismaClient { + user: any; + article: any; + comment: any; + reaction: any; + emailMessage: any; + commentAudit: any; + analyticsEvent: any; + oAuth2Client: any; + oAuth2AuthorizationCode: any; + oAuth2Token: any; +} + +// Mock 数据接口类型 +interface MockModel { + findUnique: (params: any) => Promise; + findMany: (params: any) => Promise; + findFirst: (params: any) => Promise; + count: (params: any) => Promise; + create: (data: any) => Promise; + update: (params: any) => Promise; + delete: (params: any) => Promise<{ id: any } | null>; +} + +@Injectable() +export class PrismaService implements OnModuleInit, OnModuleDestroy { + // 声明属性以满足 TypeScript 类型检查 + user: any; + article: any; + comment: any; + reaction: any; + emailMessage: any; + commentAudit: any; + analyticsEvent: any; + oAuth2Client: any; + oAuth2AuthorizationCode: any; + oAuth2Token: any; + private readonly isDevelopment = process.env.NODE_ENV !== 'production'; + private prisma?: PrismaClient; + + // 开发环境内存存储 + private devUsers: any[] = [ + { + id: 1, + email: 'admin@example.com', + username: 'admin', + passwordHash: 'password123', + role: 'admin', + isActive: true, + }, + ]; + private devArticles: any[] = [ + { + id: 1, + slug: 'welcome', + title: 'Welcome', + content: 'Sample', + excerpt: null, + coverImageUrl: null, + status: 'published', + visibility: 'public', + authorId: 1, + viewCount: 0, + publishedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + private devComments: any[] = []; + private devReactions: any[] = []; + private devEmailMessages: any[] = []; + private devCommentAudits: any[] = []; + private devAnalyticsEvents: any[] = []; + + constructor() {} + + onModuleInit() { + if (!this.isDevelopment) { + this.prisma = new PrismaClient(); + } else { + console.log('PrismaService: Using in-memory mock for development'); + } + } + + onModuleDestroy() { + if (this.prisma) { + this.prisma.$disconnect(); + } + } + + // 获取当前客户端(生产环境返回 PrismaClient,开发环境返回 mock) + private getClient(): any { + if (!this.isDevelopment || !this.prisma) { + return this.prisma!; + } + + // 返回开发环境的 mock 实现 + return { + user: this.createUserMock(), + article: this.createArticleMock(), + comment: this.createCommentMock(), + reaction: this.createReactionMock(), + emailMessage: this.createEmailMessageMock(), + commentAudit: this.createCommentAuditMock(), + analyticsEvent: this.createAnalyticsEventMock(), + oAuth2Client: this.createOAuth2ClientMock(), + oAuth2AuthorizationCode: this.createOAuth2AuthorizationCodeMock(), + oAuth2Token: this.createOAuth2TokenMock(), + $connect: async () => {}, + $disconnect: async () => {}, + $on: async () => {}, + $off: async () => {}, + }; + } + + // 创建 mock 实现 + private createUserMock(): MockModel & { + findFirst: (params: any) => Promise; + } { + return { + findFirst: async (params: any) => { + if (params.where?.email) + return ( + this.devUsers.find((u) => u.email === params.where.email) || null + ); + if (params.where?.username) + return ( + this.devUsers.find((u) => u.username === params.where.username) || + null + ); + if (params.where?.id) + return this.devUsers.find((u) => u.id === params.where.id) || null; + return null; + }, + findUnique: async (params: any) => + this.devUsers.find((u) => u.id === params.where.id) || null, + findMany: async (params: any) => { + let result = [...this.devUsers]; + if (params.where) { + if (params.where.role) + result = result.filter((u) => u.role === params.where.role); + if (params.where.isActive !== undefined) + result = result.filter((u) => u.isActive === params.where.isActive); + } + if (params.orderBy) { + const key = Object.keys(params.orderBy)[0]; + const dir = params.orderBy[key]; + result.sort((u, v) => + u[key] < v[key] ? (dir === 'asc' ? -1 : 1) : dir === 'asc' ? 1 : -1, + ); + } + if (params.skip) result = result.slice(params.skip); + if (params.take) result = result.slice(0, params.take); + return result; + }, + count: async (params: any) => { + let result = [...this.devUsers]; + if (params.where) { + if (params.where.role) + result = result.filter((u) => u.role === params.where.role); + if (params.where.isActive !== undefined) + result = result.filter((u) => u.isActive === params.where.isActive); + } + return result.length; + }, + create: async (data: any) => { + const id = Math.max(...this.devUsers.map((u) => u.id), 0) + 1; + const user = { + id, + ...data, + createdAt: new Date(), + updatedAt: new Date(), + }; + this.devUsers.push(user); + return user; + }, + update: async (params: any) => { + const idx = this.devUsers.findIndex((u) => u.id === params.where.id); + if (idx === -1) return null; + this.devUsers[idx] = { + ...this.devUsers[idx], + ...params.data, + updatedAt: new Date(), + }; + return this.devUsers[idx]; + }, + delete: async (params: any) => { + const idx = this.devUsers.findIndex((u) => u.id === params.where.id); + if (idx === -1) return null; + this.devUsers.splice(idx, 1); + return { id: params.where.id }; + }, + }; + } + + private createArticleMock(): MockModel< + any, + { articles: any[]; total: number } + > & { + findFirst: (params: any) => Promise; + } { + return { + findFirst: async (params: any) => + this.devArticles.find((a) => a.id === params.where.id) || null, + findUnique: async (params: any) => + this.devArticles.find((a) => a.id === params.where.id) || null, + findMany: async (params: any) => { + let result = [...this.devArticles]; + if (params.where) { + if (params.where.authorId) + result = result.filter((a) => a.authorId === params.where.authorId); + if (params.where.status) + result = result.filter((a) => a.status === params.where.status); + if (params.where.visibility) + result = result.filter( + (a) => a.visibility === params.where.visibility, + ); + if (params.where.slug) + result = result.filter((a) => a.slug === params.where.slug); + } + if (params.orderBy) { + const key = Object.keys(params.orderBy)[0]; + const dir = params.orderBy[key]; + result.sort((a, b) => + a[key] < b[key] ? (dir === 'asc' ? -1 : 1) : dir === 'asc' ? 1 : -1, + ); + } + if (params.skip) result = result.slice(params.skip); + if (params.take) result = result.slice(0, params.take); + if (params.include) { + result = result.map((a) => ({ + ...a, + author: this.devUsers.find((u) => u.id === a.authorId) || null, + })); + } + return { articles: result, total: result.length }; + }, + count: async (params: any) => { + let result = [...this.devArticles]; + if (params.where) { + if (params.where.authorId) + result = result.filter((a) => a.authorId === params.where.authorId); + if (params.where.status) + result = result.filter((a) => a.status === params.where.status); + if (params.where.visibility) + result = result.filter( + (a) => a.visibility === params.where.visibility, + ); + } + return result.length; + }, + create: async (data: any) => { + const id = Math.max(...this.devArticles.map((a) => a.id), 0) + 1; + const article = { + id, + ...data, + createdAt: new Date(), + updatedAt: new Date(), + }; + this.devArticles.push(article); + return article; + }, + update: async (params: any) => { + const idx = this.devArticles.findIndex((a) => a.id === params.where.id); + if (idx === -1) return null; + const current = this.devArticles[idx]; + const updated = { ...current }; + // 处理增量更新 + const newData = { ...params.data }; + if ( + newData.viewCount && + typeof newData.viewCount === 'object' && + newData.viewCount.increment + ) { + updated.viewCount = + (current.viewCount || 0) + newData.viewCount.increment; + delete newData.viewCount; + } + this.devArticles[idx] = { + ...updated, + ...newData, + updatedAt: new Date(), + }; + return this.devArticles[idx]; + }, + delete: async (params: any) => { + const idx = this.devArticles.findIndex((a) => a.id === params.where.id); + if (idx === -1) return null; + this.devArticles.splice(idx, 1); + return { id: params.where.id }; + }, + }; + } + + private createCommentMock(): MockModel & { + findFirst: (params: any) => Promise; + } { + return { + findFirst: async (params: any) => + this.devComments.find((c) => c.id === params.where.id) || null, + findUnique: async (params: any) => + this.devComments.find((c) => c.id === params.where.id) || null, + findMany: async (params: any) => { + let result = [...this.devComments]; + if (params.where) { + if (params.where.articleId) + result = result.filter( + (c) => c.articleId === params.where.articleId, + ); + if (params.where.status) + result = result.filter((c) => c.status === params.where.status); + if (params.where.parentId !== undefined) + result = result.filter((c) => c.parentId === params.where.parentId); + } + if (params.orderBy) { + const key = Object.keys(params.orderBy)[0]; + const dir = params.orderBy[key]; + result.sort((a, b) => + a[key] < b[key] ? (dir === 'asc' ? -1 : 1) : dir === 'asc' ? 1 : -1, + ); + } + if (params.include) { + result = result.map((c) => ({ + ...c, + author: c.authorId + ? this.devUsers.find((u) => u.id === c.authorId) || null + : null, + replies: c.parentId + ? [] + : this.devComments + .filter((r) => r.parentId === c.id) + .map((r) => ({ + ...r, + author: r.authorId + ? this.devUsers.find((u) => u.id === r.authorId) || null + : null, + })), + })); + } + return result; + }, + count: async (params: any) => { + let result = [...this.devComments]; + if (params.where) { + if (params.where.articleId) + result = result.filter( + (c) => c.articleId === params.where.articleId, + ); + if (params.where.status) + result = result.filter((c) => c.status === params.where.status); + if (params.where.authorId) + result = result.filter((c) => c.authorId === params.where.authorId); + if (params.where.parentId !== undefined) + result = result.filter((c) => c.parentId === params.where.parentId); + } + return result.length; + }, + create: async (data: any) => { + const id = Math.max(...this.devComments.map((c) => c.id), 0) + 1; + const comment = { + id, + ...data, + createdAt: new Date(), + updatedAt: new Date(), + }; + this.devComments.push(comment); + return comment; + }, + update: async (params: any) => { + const idx = this.devComments.findIndex((c) => c.id === params.where.id); + if (idx === -1) return null; + this.devComments[idx] = { + ...this.devComments[idx], + ...params.data, + updatedAt: new Date(), + }; + return this.devComments[idx]; + }, + delete: async (params: any) => { + const idx = this.devComments.findIndex((c) => c.id === params.where.id); + if (idx === -1) return null; + this.devComments.splice(idx, 1); + return { id: params.where.id }; + }, + }; + } + + private createReactionMock(): MockModel & { + findFirst: (params: any) => Promise; + } { + return { + findFirst: async (params: any): Promise => null, + findUnique: async () => null, + findMany: async (params: any) => { + let result = [...this.devReactions]; + if (params.where) { + if (params.where.articleId) + result = result.filter( + (r) => r.articleId === params.where.articleId, + ); + if (params.where.userId) + result = result.filter((r) => r.userId === params.where.userId); + } + return result; + }, + count: async () => 0, + create: async (data: any) => { + const id = Math.max(...this.devReactions.map((r) => r.id), 0) + 1; + const reaction = { id, ...data, createdAt: new Date() }; + this.devReactions.push(reaction); + return reaction; + }, + update: async () => null, + delete: async (params: any) => { + const idx = this.devReactions.findIndex( + (r) => r.id === params.where.id, + ); + if (idx === -1) return null; + this.devReactions.splice(idx, 1); + return { id: params.where.id }; + }, + }; + } + + private createEmailMessageMock(): MockModel & { + findFirst: (params: any) => Promise; + } { + return { + findFirst: async (params: any) => { + let result = [...this.devEmailMessages]; + if (params.where) { + if (params.where.status) + result = result.filter((e) => e.status === params.where.status); + if (params.where.id) + result = result.filter((e) => e.id === params.where.id); + } + return result[0] || null; + }, + findUnique: async () => null, + findMany: async (params: any) => { + let result = [...this.devEmailMessages]; + if (params.where) { + if (params.where.status) + result = result.filter((e) => e.status === params.where.status); + } + if (params.orderBy) { + const key = Object.keys(params.orderBy)[0]; + const dir = params.orderBy[key]; + result.sort((a, b) => + a[key] < b[key] ? (dir === 'asc' ? -1 : 1) : dir === 'asc' ? 1 : -1, + ); + } + return result; + }, + count: async () => 0, + create: async (data: any) => { + const id = Math.max(...this.devEmailMessages.map((e) => e.id), 0) + 1; + const email = { + id, + ...data, + createdAt: new Date(), + scheduledAt: data.scheduledAt || new Date(), + }; + this.devEmailMessages.push(email); + return email; + }, + update: async (params: any) => { + const idx = this.devEmailMessages.findIndex( + (e) => e.id === params.where.id, + ); + if (idx === -1) return null; + this.devEmailMessages[idx] = { + ...this.devEmailMessages[idx], + ...params.data, + updatedAt: new Date(), + }; + return this.devEmailMessages[idx]; + }, + delete: async (params: any) => { + const idx = this.devEmailMessages.findIndex( + (e) => e.id === params.where.id, + ); + if (idx === -1) return null; + this.devEmailMessages.splice(idx, 1); + return { id: params.where.id }; + }, + }; + } + + private createCommentAuditMock(): MockModel & { + findFirst: (params: any) => Promise; + } { + return { + findFirst: async (params: any): Promise => null, + findUnique: async () => null, + findMany: async (params: any) => { + let result = [...this.devCommentAudits]; + if (params.where) { + if (params.where.commentId) + result = result.filter( + (a) => a.commentId === params.where.commentId, + ); + } + if (params.orderBy) { + const key = Object.keys(params.orderBy)[0]; + const dir = params.orderBy[key]; + result.sort((a, b) => + a[key] < b[key] ? (dir === 'asc' ? -1 : 1) : dir === 'asc' ? 1 : -1, + ); + } + if (params.include) { + result = result.map((a) => ({ + ...a, + reviewer: a.reviewerId + ? this.devUsers.find((u) => u.id === a.reviewerId) || null + : null, + })); + } + return result; + }, + count: async () => 0, + create: async (data: any) => { + const id = Math.max(...this.devCommentAudits.map((a) => a.id), 0) + 1; + const audit = { + id, + ...data, + createdAt: new Date(), + reviewedAt: new Date(), + }; + this.devCommentAudits.push(audit); + return audit; + }, + update: async () => null, + delete: async () => null, + }; + } + + private createAnalyticsEventMock(): MockModel & { + count: (params: any) => Promise; + } { + return { + findUnique: async () => null, + findMany: async () => [], + findFirst: async (params: any): Promise => null, + count: async (params: any) => { + let result = [...this.devAnalyticsEvents]; + if (params.where) { + if (params.where.type) + result = result.filter((e) => e.type === params.where.type); + if (params.where.createdAt) { + if (params.where.createdAt.gte) { + result = result.filter( + (e) => e.createdAt >= params.where.createdAt.gte, + ); + } + } + if (params.where.sessionId) + result = result.filter( + (e) => e.sessionId === params.where.sessionId, + ); + } + if (params.distinct && params.distinct.includes('sessionId')) { + const sessions = new Set(result.map((e) => e.sessionId)); + return sessions.size; + } + return result.length; + }, + create: async (data: any) => { + const id = Math.max(...this.devAnalyticsEvents.map((e) => e.id), 0) + 1; + const event = { id, ...data, createdAt: new Date() }; + this.devAnalyticsEvents.push(event); + return event; + }, + update: async () => null, + delete: async () => null, + }; + } + + private createOAuth2ClientMock(): MockModel { + return { + findUnique: async (params: any) => { + if (params.where?.clientId === 'web-client') { + return { + clientId: 'web-client', + clientSecret: 'change_me', + redirectUris: ['http://localhost:3000/auth/callback'], + scopes: 'read write', + createdAt: new Date(), + }; + } + return null; + }, + findFirst: async (params: any): Promise => null, + findMany: async () => [], + count: async () => 0, + create: async (data: any) => ({ id: 1, ...data }), + update: async () => null, + delete: async () => null, + }; + } + + private createOAuth2AuthorizationCodeMock(): MockModel & { + findFirst: (params: any) => Promise; + update: (params: any) => Promise; + } { + return { + findFirst: async (params: any) => ({ + id: 1, + codeHash: 'hash', + clientId: 'web-client', + userId: 1, + redirectUri: 'http://localhost:3000/auth/callback', + scopes: 'read write', + expiresAt: new Date(Date.now() + 10 * 60 * 1000), + used: false, + }), + findUnique: async () => null, + findMany: async () => [], + count: async () => 0, + create: async (data: any) => ({ id: 1, ...data }), + update: async (params: any) => ({ ...params.where, ...params.data }), + delete: async () => null, + }; + } + + private createOAuth2TokenMock(): MockModel & { + findFirst: (params: any) => Promise; + } { + return { + findFirst: async (params: any) => ({ + id: 1, + accessTokenHash: 'hash', + clientId: 'web-client', + userId: 1, + scopes: 'read write', + expiresAt: new Date(Date.now() + 3600 * 1000), + revoked: false, + }), + findUnique: async () => null, + findMany: async () => [], + count: async () => 0, + create: async (data: any) => ({ id: 1, ...data }), + update: async () => null, + delete: async () => null, + }; + } +} diff --git a/src/modules/session/require-session-user.guard.ts b/src/modules/session/require-session-user.guard.ts new file mode 100644 index 0000000..e781577 --- /dev/null +++ b/src/modules/session/require-session-user.guard.ts @@ -0,0 +1,24 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { CanActivate, ExecutionContext } from '@nestjs/common'; +import { Request } from 'express'; +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class RequireSessionUser implements CanActivate { + constructor(private prisma: PrismaService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const userId = request.session?.userId; + if (!userId) { + throw new UnauthorizedException('Not authenticated'); + } + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + throw new UnauthorizedException('User not found'); + } + const { passwordHash, ...userWithoutPassword } = user; + request.user = userWithoutPassword; + return true; + } +} diff --git a/src/modules/session/session.controller.ts b/src/modules/session/session.controller.ts new file mode 100644 index 0000000..ba66143 --- /dev/null +++ b/src/modules/session/session.controller.ts @@ -0,0 +1,74 @@ +import { + Controller, + Get, + Post, + Req, + Res, + Body, + UseGuards, + UnauthorizedException, +} from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; +import { SessionService } from './session.service'; +import type { Request, Response } from 'express'; +import { ConfigService } from '@nestjs/config'; +import { RequireSessionUser } from './require-session-user.guard'; + +@Controller() +export class SessionController { + constructor( + private readonly sessionService: SessionService, + private readonly configService: ConfigService, + ) {} + + @Get('login') + getLogin() { + return { ok: true }; + } + + @Post('login') + @Throttle({ + limit: 5, + ttl: 60000, // 1 minute + } as any) + async login( + @Req() req: Request, + @Body() loginDto: { emailOrUsername: string; password: string }, + ) { + const user = await this.sessionService.validateUser( + loginDto.emailOrUsername, + loginDto.password, + ); + if (!user) { + throw new UnauthorizedException('Invalid credentials'); + } + req.session.userId = user.id; + const { passwordHash, ...safeUser } = user; + return { ok: true, user: safeUser }; + } + + @Post('logout') + async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) { + await new Promise((resolve, reject) => { + req.session.destroy((err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + const cookieName = + this.configService.get('SESSION_COOKIE_NAME') || + 'linkshare_session'; + res.clearCookie(cookieName); + return { ok: true }; + } + + // 测试用:获取当前登录用户信息 + @Get('me') + @UseGuards(RequireSessionUser) + async me(@Req() req: Request) { + return { ok: true, user: req.user }; + } +} diff --git a/src/modules/session/session.e2e-spec.ts b/src/modules/session/session.e2e-spec.ts new file mode 100644 index 0000000..4710383 --- /dev/null +++ b/src/modules/session/session.e2e-spec.ts @@ -0,0 +1,130 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; +import { AppModule } from '../../app.module'; +import { INestApplication } from '@nestjs/common'; + +describe('OAuth2 + Session (e2e)', () => { + let app: INestApplication; + let sessionCookie: string | undefined; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + it('/login (POST) - success', async () => { + const response = await request(app.getHttpServer()) + .post('/login') + .send({ emailOrUsername: 'admin@example.com', password: 'password123' }) + .expect(200); + expect(response.body).toEqual({ ok: true, user: expect.any(Object) }); + const setCookie = response.headers['set-cookie'] as string[]; + if (setCookie && setCookie.length > 0) { + sessionCookie = setCookie[0].split(';')[0]; + } + expect(sessionCookie).toBeDefined(); + }); + + it('/me (GET) - with session', async () => { + expect(sessionCookie).toBeDefined(); + await request(app.getHttpServer()) + .get('/me') + .set('Cookie', sessionCookie!) + .expect(200); + }); + + it('/oauth2/authorize - not logged in, redirect to login', async () => { + const query = new URLSearchParams({ + response_type: 'code', + client_id: 'web-client', + redirect_uri: 'http://localhost:3000/auth/callback', + scope: 'read write', + state: 'teststate', + }).toString(); + const response = await request(app.getHttpServer()) + .get(`/oauth2/authorize?${query}`) + .expect(302); + expect(response.headers.location).toContain('/login?next='); + }); + + it('/oauth2/authorize - after login, redirect with code', async () => { + // 假设 sessionCookie 已存在 + expect(sessionCookie).toBeDefined(); + const query = new URLSearchParams({ + response_type: 'code', + client_id: 'web-client', + redirect_uri: 'http://localhost:3000/auth/callback', + scope: 'read write', + state: 'teststate', + }).toString(); + const response = await request(app.getHttpServer()) + .get(`/oauth2/authorize?${query}`) + .set('Cookie', sessionCookie!) + .expect(302); + const location = response.headers.location; + expect(location).toContain('http://localhost:3000/auth/callback?code='); + expect(location).toContain('state=teststate'); + }); + + it('/oauth2/token - authorization_code grant', async () => { + // 先从 authorize 获取 code + const query = new URLSearchParams({ + response_type: 'code', + client_id: 'web-client', + redirect_uri: 'http://localhost:3000/auth/callback', + scope: 'read write', + state: 'teststate', + }).toString(); + const authResponse = await request(app.getHttpServer()) + .get(`/oauth2/authorize?${query}`) + .set('Cookie', sessionCookie!); + const location = authResponse.headers.location; + const code = new URL(location).searchParams.get('code'); + expect(code).toBeDefined(); + + // 用 code 换 token + const tokenResponse = await request(app.getHttpServer()) + .post('/oauth2/token') + .type('form') + .send({ + grant_type: 'authorization_code', + code, + redirect_uri: 'http://localhost:3000/auth/callback', + client_id: 'web-client', + client_secret: 'change_me', + }) + .expect(200); + expect(tokenResponse.body).toEqual({ + access_token: expect.any(String), + token_type: 'Bearer', + expires_in: expect.any(Number), + refresh_token: expect.any(String), + scope: 'read write', + }); + }); + + it('/oauth2/token - client_credentials grant', async () => { + const response = await request(app.getHttpServer()) + .post('/oauth2/token') + .type('form') + .send({ + grant_type: 'client_credentials', + client_id: 'web-client', + client_secret: 'change_me', + }) + .expect(200); + expect(response.body).toEqual({ + access_token: expect.any(String), + token_type: 'Bearer', + expires_in: expect.any(Number), + scope: 'read write', + }); + }); +}); diff --git a/src/modules/session/session.module.ts b/src/modules/session/session.module.ts new file mode 100644 index 0000000..9a1d1e9 --- /dev/null +++ b/src/modules/session/session.module.ts @@ -0,0 +1,40 @@ +import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common'; +import session from 'express-session'; +import { ConfigService } from '@nestjs/config'; +import { SessionService } from './session.service'; +import { SessionController } from './session.controller'; + +@Module({ + providers: [SessionService], + controllers: [SessionController], + exports: [SessionService], +}) +export class SessionModule implements NestModule { + constructor(private configService: ConfigService) {} + + configure(consumer: MiddlewareConsumer) { + const secret = + this.configService.get('SESSION_COOKIE_SECRET') || 'change_me'; + consumer + .apply( + session({ + name: + this.configService.get('SESSION_COOKIE_NAME') || + 'linkshare_session', + secret, + resave: false, + saveUninitialized: false, + cookie: { + httpOnly: + this.configService.get('SESSION_COOKIE_HTTPONLY') !== + 'false', + secure: + this.configService.get('SESSION_COOKIE_SECURE') === + 'true', + maxAge: 1000 * 60 * 60 * 24 * 7, + }, + }), + ) + .forRoutes('*'); + } +} diff --git a/src/modules/session/session.service.ts b/src/modules/session/session.service.ts new file mode 100644 index 0000000..77ba221 --- /dev/null +++ b/src/modules/session/session.service.ts @@ -0,0 +1,25 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import * as bcrypt from 'bcrypt'; + +@Injectable() +export class SessionService { + constructor(private readonly prisma: PrismaService) {} + + async validateUser(emailOrUsername: string, password: string) { + const user = await this.prisma.user.findFirst({ + where: { + OR: [{ email: emailOrUsername }, { username: emailOrUsername }], + }, + }); + if (!user) { + return null; + } + const valid = await bcrypt.compare(password, user.passwordHash); + if (!valid) { + return null; + } + const { passwordHash, ...safeUser } = user; + return safeUser; + } +} diff --git a/src/modules/users/dto/user.dto.ts b/src/modules/users/dto/user.dto.ts new file mode 100644 index 0000000..bee52c5 --- /dev/null +++ b/src/modules/users/dto/user.dto.ts @@ -0,0 +1,263 @@ +import { + IsString, + IsOptional, + IsEnum, + IsEmail, + IsBoolean, + IsInt, + Min, + Max, + IsNotEmpty, + Length, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export enum UserRole { + ADMIN = 'admin', + MODERATOR = 'moderator', + USER = 'user', +} + +export class CreateUserDto { + @ApiProperty({ + description: '用户名', + example: 'john_doe', + minLength: 3, + maxLength: 50, + }) + @IsString() + @IsNotEmpty() + @Length(3, 50) + username: string; + + @ApiProperty({ + description: '邮箱地址', + example: 'john@example.com', + }) + @IsEmail() + @IsNotEmpty() + email: string; + + @ApiProperty({ + description: '密码(至少 6 位)', + example: 'securePassword123', + minLength: 6, + maxLength: 100, + }) + @IsString() + @IsNotEmpty() + @Length(6, 100) + password: string; + + @ApiPropertyOptional({ + description: '显示名称', + example: 'John Doe', + maxLength: 100, + }) + @IsOptional() + @IsString() + @Length(0, 100) + displayName?: string; + + @ApiPropertyOptional({ + description: '头像 URL', + example: 'https://example.com/avatar.jpg', + }) + @IsOptional() + @IsString() + avatarUrl?: string; + + @ApiPropertyOptional({ + description: '用户角色', + enum: UserRole, + default: UserRole.USER, + example: UserRole.USER, + }) + @IsOptional() + @IsEnum(UserRole) + role: UserRole = UserRole.USER; + + @ApiPropertyOptional({ + description: '是否激活', + default: true, + example: true, + }) + @IsOptional() + @IsBoolean() + isActive?: boolean = true; +} + +export class UpdateUserDto { + @ApiPropertyOptional({ + description: '用户名', + example: 'updated_username', + minLength: 3, + maxLength: 50, + }) + @IsOptional() + @IsString() + @Length(3, 50) + username?: string; + + @ApiPropertyOptional({ + description: '邮箱地址', + example: 'updated@example.com', + }) + @IsOptional() + @IsEmail() + email?: string; + + @ApiPropertyOptional({ + description: '密码(至少 6 位)', + example: 'newSecurePassword456', + minLength: 6, + maxLength: 100, + }) + @IsOptional() + @IsString() + @Length(6, 100) + password?: string; + + @ApiPropertyOptional({ + description: '显示名称', + example: 'Updated Name', + maxLength: 100, + }) + @IsOptional() + @IsString() + @Length(0, 100) + displayName?: string; + + @ApiPropertyOptional({ + description: '头像 URL', + example: 'https://example.com/new-avatar.jpg', + }) + @IsOptional() + @IsString() + avatarUrl?: string; + + @ApiPropertyOptional({ + description: '用户角色', + enum: UserRole, + example: UserRole.MODERATOR, + }) + @IsOptional() + @IsEnum(UserRole) + role?: UserRole; + + @ApiPropertyOptional({ + description: '是否激活', + example: false, + }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class UserResponseDto { + @ApiProperty({ + description: '用户 ID', + example: 1, + }) + id: number; + + @ApiProperty({ + description: '用户名', + example: 'john_doe', + }) + @IsString() + username: string; + + @ApiProperty({ + description: '邮箱地址', + example: 'john@example.com', + }) + @IsString() + email: string; + + @ApiPropertyOptional({ + description: '显示名称', + example: 'John Doe', + }) + @IsOptional() + @IsString() + displayName?: string; + + @ApiPropertyOptional({ + description: '头像 URL', + example: 'https://example.com/avatar.jpg', + }) + @IsOptional() + @IsString() + avatarUrl?: string; + + @ApiProperty({ + description: '用户角色', + enum: UserRole, + example: UserRole.USER, + }) + @IsEnum(UserRole) + role: UserRole; + + @ApiProperty({ + description: '是否激活', + example: true, + }) + @IsBoolean() + isActive: boolean; + + @ApiProperty({ + description: '创建时间', + example: '2024-01-01T00:00:00.000Z', + }) + createdAt: Date; + + @ApiProperty({ + description: '更新时间', + example: '2024-01-01T00:00:00.000Z', + }) + updatedAt: Date; +} + +export class UserQueryDto { + @ApiPropertyOptional({ + description: '页码(从 1 开始)', + example: 1, + minimum: 1, + default: 1, + }) + @IsOptional() + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ + description: '每页数量', + example: 20, + minimum: 1, + maximum: 100, + default: 20, + }) + @IsOptional() + @IsInt() + @Min(1) + @Max(100) + limit?: number = 20; + + @ApiPropertyOptional({ + description: '按用户角色筛选', + enum: UserRole, + example: UserRole.USER, + }) + @IsOptional() + @IsEnum(UserRole) + role?: UserRole; + + @ApiPropertyOptional({ + description: '按激活状态筛选', + example: true, + }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts new file mode 100644 index 0000000..8414674 --- /dev/null +++ b/src/modules/users/users.controller.ts @@ -0,0 +1,145 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Param, + Body, + Query, + Req, + UseGuards, + HttpCode, + HttpStatus, + HttpException, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiResponse, + ApiParam, + ApiQuery, +} from '@nestjs/swagger'; +import { UsersService } from './users.service'; +import { + CreateUserDto, + UpdateUserDto, + UserResponseDto, + UserQueryDto, +} from './dto/user.dto'; +import { RequireSessionUser } from '../session/require-session-user.guard'; +import { RequireRole } from '../common/decorators/require-role.decorator'; +import { RequireRoleGuard } from '../common/guards/require-role.guard'; + +@ApiTags('用户') +@Controller('users') +@UseGuards(RequireSessionUser) +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + @Post() + @UseGuards(RequireRoleGuard) + @RequireRole('admin') + @ApiBearerAuth() + @ApiOperation({ summary: '创建用户(仅管理员)' }) + @ApiResponse({ + status: 201, + description: '用户创建成功', + type: UserResponseDto, + }) + @ApiResponse({ status: 400, description: '请求参数无效' }) + @ApiResponse({ status: 401, description: '未授权' }) + @ApiResponse({ status: 403, description: '无权创建用户' }) + async createUser( + @Body() createUserDto: CreateUserDto, + ): Promise { + return this.usersService.create(createUserDto); + } + + @Get() + @ApiOperation({ summary: '获取用户列表' }) + @ApiResponse({ status: 200, description: '成功获取用户列表', type: Object }) + @ApiQuery({ + name: 'page', + required: false, + type: Number, + description: '页码', + }) + @ApiQuery({ + name: 'limit', + required: false, + type: Number, + description: '每页数量', + }) + @ApiQuery({ + name: 'role', + required: false, + enum: ['admin', 'moderator', 'user'], + description: '用户角色', + }) + @ApiQuery({ + name: 'isActive', + required: false, + type: Boolean, + description: '是否激活', + }) + async getUsers(@Query() query: UserQueryDto) { + const result = await this.usersService.findMany(query); + return result; + } + + @Get(':id') + @ApiOperation({ summary: '根据 ID 获取用户' }) + @ApiResponse({ + status: 200, + description: '成功获取用户', + type: UserResponseDto, + }) + @ApiResponse({ status: 404, description: '用户不存在' }) + @ApiParam({ name: 'id', description: '用户 ID', type: Number }) + async getUser(@Param('id') id: string) { + const userId = parseInt(id, 10); + return this.usersService.findById(userId); + } + + @Put(':id') + @UseGuards(RequireRoleGuard) + @RequireRole('admin') + @ApiBearerAuth() + @ApiOperation({ summary: '更新用户(仅管理员)' }) + @ApiResponse({ + status: 200, + description: '用户更新成功', + type: UserResponseDto, + }) + @ApiResponse({ status: 400, description: '请求参数无效' }) + @ApiResponse({ status: 401, description: '未授权' }) + @ApiResponse({ status: 403, description: '无权更新用户' }) + @ApiResponse({ status: 404, description: '用户不存在' }) + @ApiParam({ name: 'id', description: '用户 ID', type: Number }) + async updateUser( + @Param('id') id: string, + @Body() updateUserDto: UpdateUserDto, + ): Promise { + const userId = parseInt(id, 10); + return this.usersService.update(userId, updateUserDto); + } + + @Delete(':id') + @UseGuards(RequireRoleGuard) + @RequireRole('admin') + @ApiBearerAuth() + @ApiOperation({ summary: '删除用户(仅管理员)' }) + @ApiResponse({ status: 204, description: '用户删除成功' }) + @ApiResponse({ status: 401, description: '未授权' }) + @ApiResponse({ status: 403, description: '无权删除用户' }) + @ApiResponse({ status: 404, description: '用户不存在' }) + @ApiParam({ name: 'id', description: '用户 ID', type: Number }) + @HttpCode(HttpStatus.NO_CONTENT) + async deleteUser(@Param('id') id: string) { + const userId = parseInt(id, 10); + await this.usersService.remove(userId); + return; + } +} diff --git a/src/modules/users/users.module.ts b/src/modules/users/users.module.ts new file mode 100644 index 0000000..63129c6 --- /dev/null +++ b/src/modules/users/users.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; +import { PrismaModule } from '../prisma/prisma.module'; +import { RequireRoleGuard } from '../common/guards/require-role.guard'; + +@Module({ + imports: [PrismaModule], + controllers: [UsersController], + providers: [UsersService, RequireRoleGuard], +}) +export class UsersModule {} diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts new file mode 100644 index 0000000..1e65eab --- /dev/null +++ b/src/modules/users/users.service.ts @@ -0,0 +1,172 @@ +import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { + CreateUserDto, + UpdateUserDto, + UserResponseDto, + UserQueryDto, +} from './dto/user.dto'; +import * as bcrypt from 'bcrypt'; + +@Injectable() +export class UsersService { + constructor(private readonly prisma: PrismaService) {} + + async create(data: CreateUserDto): Promise { + // Check for existing username or email + const existing = await this.prisma.user.findFirst({ + where: { + OR: [{ username: data.username }, { email: data.email }], + }, + }); + if (existing) { + throw new HttpException( + 'Username or email already exists', + HttpStatus.CONFLICT, + ); + } + + const hashedPassword = await bcrypt.hash(data.password, 10); + + const user = await this.prisma.user.create({ + data: { + username: data.username, + email: data.email, + passwordHash: hashedPassword, + displayName: data.displayName, + avatarUrl: data.avatarUrl, + role: data.role, + isActive: data.isActive, + }, + }); + + return this.mapToResponseDto(user); + } + + async findMany(query: UserQueryDto) { + const { page = 1, limit = 20, role, isActive } = query; + const skip = (page - 1) * limit; + + const [users, total] = await Promise.all([ + this.prisma.user.findMany({ + skip, + take: limit, + where: { + ...(role && { role }), + ...(isActive !== undefined && { isActive }), + }, + orderBy: { createdAt: 'desc' }, + }), + this.prisma.user.count({ + where: { + ...(role && { role }), + ...(isActive !== undefined && { isActive }), + }, + }), + ]); + + const responseUsers = users.map(this.mapToResponseDto); + + return { + users: responseUsers, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }; + } + + async findById(id: number): Promise { + const user = await this.prisma.user.findUnique({ + where: { id }, + }); + if (!user) { + throw new HttpException('User not found', HttpStatus.NOT_FOUND); + } + return this.mapToResponseDto(user); + } + + async update(id: number, data: UpdateUserDto): Promise { + const existing = await this.prisma.user.findUnique({ + where: { id }, + }); + if (!existing) { + throw new HttpException('User not found', HttpStatus.NOT_FOUND); + } + + // Check username/email uniqueness if being updated + if (data.username || data.email) { + const duplicate = await this.prisma.user.findFirst({ + where: { + AND: [ + { id: { not: id } }, + { + OR: [ + ...(data.username ? [{ username: data.username }] : []), + ...(data.email ? [{ email: data.email }] : []), + ], + }, + ], + }, + }); + if (duplicate) { + throw new HttpException( + 'Username or email already in use', + HttpStatus.CONFLICT, + ); + } + } + + const updateData: any = { ...data }; + if (data.password) { + updateData.passwordHash = await bcrypt.hash(data.password, 10); + } + delete updateData.password; + + const user = await this.prisma.user.update({ + where: { id }, + data: updateData, + }); + + return this.mapToResponseDto(user); + } + + async remove(id: number): Promise { + const existing = await this.prisma.user.findUnique({ + where: { id }, + }); + if (!existing) { + throw new HttpException('User not found', HttpStatus.NOT_FOUND); + } + + // Check if user has articles or comments before deletion (optional) + const articleCount = await this.prisma.article.count({ + where: { authorId: id }, + }); + const commentCount = await this.prisma.comment.count({ + where: { authorId: id }, + }); + + if (articleCount > 0 || commentCount > 0) { + throw new HttpException( + 'Cannot delete user with existing articles or comments. Deactivate instead.', + HttpStatus.BAD_REQUEST, + ); + } + + await this.prisma.user.delete({ + where: { id }, + }); + } + + private mapToResponseDto(user: any): UserResponseDto { + const { passwordHash, ...rest } = user; + return { + ...rest, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + } as UserResponseDto; + } +} diff --git a/src/types/bullmq-full.d.ts b/src/types/bullmq-full.d.ts new file mode 100644 index 0000000..ca51d95 --- /dev/null +++ b/src/types/bullmq-full.d.ts @@ -0,0 +1,30 @@ +declare module 'bullmq' { + export interface Job { + data: TData; + id: string | number; + opts: any; + // 其他属性... + } + + export class Queue { + constructor(name: string, options?: any); + add(name: string, data?: any, options?: any): Promise; + // 其他方法... + } + + export class Worker { + constructor( + queueName: string, + processor: (job: Job) => Promise, + options?: any, + ); + // 其他方法... + } + + export interface JobOptions { + attempts?: number; + backoff?: any; + delay?: number; + // 其他选项... + } +} diff --git a/src/types/bullmq.d.ts b/src/types/bullmq.d.ts new file mode 100644 index 0000000..9e7780a --- /dev/null +++ b/src/types/bullmq.d.ts @@ -0,0 +1,3 @@ +declare module 'bullmq' { + export * from 'bullmq'; +} diff --git a/src/types/express-session.d.ts b/src/types/express-session.d.ts new file mode 100644 index 0000000..7c7cb01 --- /dev/null +++ b/src/types/express-session.d.ts @@ -0,0 +1,15 @@ +import 'express-session'; + +declare module 'express-session' { + interface SessionData { + userId?: number; + } +} + +declare global { + namespace Express { + interface Request { + user?: any; + } + } +} diff --git a/src/types/nestjs-bullmq.d.ts b/src/types/nestjs-bullmq.d.ts new file mode 100644 index 0000000..a88074b --- /dev/null +++ b/src/types/nestjs-bullmq.d.ts @@ -0,0 +1,31 @@ +declare module '@nestjs/bullmq' { + import { ClassDecorator, MethodDecorator } from '@nestjs/common'; + + export function Processor(queueName: string): ClassDecorator; + export function Process(jobName: string): MethodDecorator; + + export const BullModule: any; + export interface BullModuleOptions { + name?: string; + options?: any; + } + + export interface Queue { + add(name: string, data?: any, options?: any): Promise; + getConnection(): any; + close(): Promise; + on(event: string, callback: (job: any) => void): void; + } + + export interface Worker { + on(event: string, callback: (job: any, error: Error) => void): void; + } + + export interface Job { + data: TData; + name: string; + id: string | number; + opts: any; + client: any; + } +} diff --git a/src/types/nodemailer.d.ts b/src/types/nodemailer.d.ts new file mode 100644 index 0000000..e98847b --- /dev/null +++ b/src/types/nodemailer.d.ts @@ -0,0 +1,4 @@ +declare module 'nodemailer' { + import * as nodemailer from 'nodemailer'; + export = nodemailer; +}