From 5b1f0848ac1a922a059a334abe5a9f863d23e9b5 Mon Sep 17 00:00:00 2001 From: Wang Zhuoxuan Date: Thu, 23 Apr 2026 12:32:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E4=BF=AE=E6=94=B9=E8=87=AA=E5=B7=B1=E5=AF=86=E7=A0=81?= =?UTF-8?q?=E7=9A=84=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 PUT /v1/admin/change-password 端点,允许已登录管理员 (admin / super_admin)修改自己的密码。需验证旧密码, 且新旧密码不能相同。错误由全局 errorHandler 统一处理。 --- CLAUDE.md | 6 +- docs/ci-deployment-guide.md | 9 +- docs/database-migration-guide.md | 406 ++++++++++++++++++ .../services/admin/admin-auth.test.ts | 92 ++++ src/routes/admin/auth.ts | 36 ++ src/services/admin/admin-auth.ts | 48 ++- 6 files changed, 589 insertions(+), 8 deletions(-) create mode 100644 docs/database-migration-guide.md create mode 100644 src/__tests__/services/admin/admin-auth.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 0e34c27..7bd713f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ src/ ├── index.ts # 入口:Fastify 实例 + 插件注册 + 路由挂载 ├── db/ │ ├── client.ts # 数据库连接(mysql2 pool + drizzle) -│ └── schema.ts # 全部 14 张表定义(唯一真相源) +│ └── schema.ts # 全部 15 张表定义(唯一真相源) ├── types/ # TypeScript 类型(auth, quiz, user, api) ├── utils/ │ ├── config.ts # 环境变量(Zod 校验,启动时 fail-fast) @@ -105,12 +105,12 @@ db/seeds/index.ts # 幂等种子导入脚本 ## 数据库 -- **14 张表**,定义在 `src/db/schema.ts` +- **15 张表**,定义在 `src/db/schema.ts` - 核心(7):`users`, `categories`, `questions`, `knowledge_cards`, `user_progress`, `skill_tree`, `user_chapter_progress` - 反馈(2):`question_ratings`, `user_feedback` - 游戏化(3):`achievements`, `user_achievements`, `leaderboard_snapshots` - 商业(1):`subscriptions` -- 审计(1):`admin_audit_log` +- 管理员(2):`admin_users`, `admin_audit_log` - Schema 定义在 `src/db/schema.ts`,迁移由 `drizzle-kit` 从 schema 自动生成 - `datetime` 列使用 `default(sql\`CURRENT_TIMESTAMP\`)`(MySQL datetime 无 `defaultNow()`) diff --git a/docs/ci-deployment-guide.md b/docs/ci-deployment-guide.md index d800814..faaadb3 100644 --- a/docs/ci-deployment-guide.md +++ b/docs/ci-deployment-guide.md @@ -920,11 +920,12 @@ docker stats --no-stream # 测试库重置(清空测试数据) mysql -h your-rds-endpoint -u duoqi_test -p -e "DROP DATABASE duoqi_test; CREATE DATABASE duoqi_test CHARACTER SET utf8mb4;" -# 执行迁移 -docker compose exec api-prod npx drizzle-kit migrate +# 执行迁移(从本地或 CI 中执行,容器内无 drizzle-kit) +# 详见 docs/database-migration-guide.md +DATABASE_URL=mysql://duoqi_prod:password@rm-xxxxx:3306/duoqi_prod bun run db:migrate -# 导入种子数据到测试库 -docker compose --profile test exec api-test bun run db:seed +# 导入种子数据到测试库(首次部署时从本地执行) +DATABASE_URL=mysql://duoqi_test:password@rm-xxxxx:3306/duoqi_test bun run db:seed # 备份生产库 mysqldump -h your-rds-endpoint -u duoqi_prod -p duoqi_prod > /opt/backups/duoqi_prod_$(date +%Y%m%d).sql diff --git a/docs/database-migration-guide.md b/docs/database-migration-guide.md new file mode 100644 index 0000000..89f3df1 --- /dev/null +++ b/docs/database-migration-guide.md @@ -0,0 +1,406 @@ +# 数据库初始化与迁移指南 + +> 本地开发机 → 阿里云 RDS MySQL 8.0+ 的数据库操作完整指南 + +## 目录 + +- [概述](#概述) +- [前置条件](#前置条件) +- [首次初始化](#首次初始化) + - [Step 0:创建 RDS 数据库](#step-0创建-rds-数据库) + - [Step 1:配置本地连接串](#step-1配置本地连接串) + - [Step 2:推送表结构](#step-2推送表结构) + - [Step 3:导入种子数据](#step-3导入种子数据) + - [Step 4:验证](#step-4验证) +- [日常表结构变更](#日常表结构变更) + - [开发流程](#开发流程) + - [生产部署流程](#生产部署流程) +- [常用操作速查](#常用操作速查) +- [故障排查](#故障排查) + +--- + +## 概述 + +### 技术选型 + +| 组件 | 说明 | +|------|------| +| ORM | Drizzle ORM — 类型安全,schema 即代码 | +| Schema 真相源 | `src/db/schema.ts` — 所有表定义的唯一来源 | +| 迁移工具 | `drizzle-kit` — 生成和执行 SQL 迁移文件 | +| 迁移文件目录 | `db/migrations/` — 版本化的 SQL 变更记录 | +| 种子数据 | `content/*.json` + `db/seeds/index.ts` | + +### 表结构总览(15 张表) + +| 类别 | 表名 | 说明 | +|------|------|------| +| 核心 | `users`, `categories`, `questions`, `knowledge_cards`, `user_progress`, `skill_tree`, `user_chapter_progress` | 用户、题目、进度 | +| 反馈 | `question_ratings`, `user_feedback` | 评价与反馈 | +| 游戏化 | `achievements`, `user_achievements`, `leaderboard_snapshots` | 成就与排行 | +| 商业 | `subscriptions` | 订阅与 IAP | +| 管理员 | `admin_users`, `admin_audit_log` | 后台管理与审计 | + +### 两条核心命令 + +| 场景 | 命令 | 说明 | +|------|------|------| +| 开发/首次部署 | `bun run db:push` | 直接从 `schema.ts` 同步到数据库,适合快速迭代 | +| 生产增量变更 | `bun run db:migrate` | 执行 `db/migrations/` 中的 SQL 文件,幂等且可审计 | + +> **选择原则**:首次初始化用 `db:push`(简单直接),后续变更用 `db:migrate`(安全可控)。 + +--- + +## 前置条件 + +### 1. 本地环境 + +```bash +# 确认 bun 已安装 +bun --version + +# 安装项目依赖 +cd duoqi-api +bun install +``` + +### 2. 阿里云 RDS + +- 已创建 MySQL 8.0+ 实例 +- 已在 RDS 控制台 **白名单设置** 中添加本机公网 IP +- 已获取连接信息:地址、端口、用户名、密码 + +### 3. 网络连通性验证 + +```bash +# 替换为实际 RDS 地址 +mysql -h rm-xxxxx.mysql.rds.aliyuncs.com -u root -p -e "SELECT 1;" +``` + +如果连接超时,检查: +- RDS 白名单是否已添加本机 IP([阿里云 RDS 控制台](https://rdsnext.console.aliyun.com/) → 数据安全性 → 白名单设置) +- 本机防火墙/代理是否出站 3306 端口 + +--- + +## 首次初始化 + +> 适用于:全新的 RDS 实例,数据库为空。 + +### Step 0:创建 RDS 数据库 + +在阿里云 RDS 控制台的 **DMS 数据管理** 或本地 MySQL 客户端中执行: + +```sql +-- 生产库 +CREATE DATABASE duoqi_prod CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- 测试库 +CREATE DATABASE duoqi_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- 创建用户并授权(如使用 root 可跳过) +CREATE USER 'duoqi_prod'@'%' IDENTIFIED BY 'your-secure-password'; +GRANT ALL PRIVILEGES ON duoqi_prod.* TO 'duoqi_prod'@'%'; + +CREATE USER 'duoqi_test'@'%' IDENTIFIED BY 'your-secure-password'; +GRANT ALL PRIVILEGES ON duoqi_test.* TO 'duoqi_test'@'%'; + +FLUSH PRIVILEGES; +``` + +### Step 1:配置本地连接串 + +编辑本地项目 `.env` 文件,将 `DATABASE_URL` 指向目标 RDS 数据库: + +```env +# 初始化生产库 +DATABASE_URL=mysql://duoqi_prod:your-password@rm-xxxxx.mysql.rds.aliyuncs.com:3306/duoqi_prod + +# 或初始化测试库 +# DATABASE_URL=mysql://duoqi_test:your-password@rm-xxxxx.mysql.rds.aliyuncs.com:3306/duoqi_test +``` + +> **注意**:操作完成后,记得将 `DATABASE_URL` 改回本地开发值,避免后续开发误操作生产数据库。 + +### Step 2:推送表结构 + +```bash +# 从 schema.ts 直接同步全部 15 张表 + 外键 + 索引 +bun run db:push +``` + +`drizzle-kit push` 会: +1. 读取 `src/db/schema.ts` 中的表定义 +2. 连接数据库,对比当前状态 +3. 创建所有缺失的表、列、索引和外键 + +输出示例: +``` +[✓] Changes applied successfully. The following changes were made: + └─ Table "users" was created + └─ Table "categories" was created + └─ ... (共 15 张表) +``` + +> **替代方案**:如果不想用 `db:push`,也可以用 `db:migrate` 执行已有的迁移文件: +> ```bash +> bun run db:migrate +> ``` +> 效果相同,区别在于 `db:migrate` 执行的是 `db/migrations/0000_melodic_blacklash.sql` 这个固定文件。 + +### Step 3:导入种子数据 + +```bash +bun run db:seed +``` + +种子脚本 (`db/seeds/index.ts`) 按依赖拓扑顺序导入,**幂等安全**(可重复执行): + +``` +Step 0: admin_users → 默认管理员 (admin / admin123) +Step 1: categories → 4 个分类(历史、戏曲、相声...) +Step 2: skill_tree → 技能树节点 +Step 3: questions → 题目 + knowledge_cards → 知识卡片(依赖 questions) +Step 4: achievements → 成就定义 +``` + +输出示例: +``` +Admin user seeded: username=admin, password=admin123 (CHANGE IN PRODUCTION!) +Categories: 4 inserted, 0 skipped +Skill tree: 12 inserted, 0 skipped +Questions: 60 inserted, 0 skipped +Achievements: 8 inserted, 0 skipped + +Seed data import complete! +``` + +> [!WARNING] +> 默认管理员密码 `admin123` 仅用于初始化,**首次登录后必须立即修改**。 + +### Step 4:验证 + +```bash +# 方式一:Drizzle Studio(浏览器可视化) +bun run db:studio +# 访问 https://local.drizzle.studio → 浏览所有表和数据 + +# 方式二:命令行查询 +mysql -h rm-xxxxx.mysql.rds.aliyuncs.com -u duoqi_prod -p duoqi_prod \ + -e "SHOW TABLES; SELECT COUNT(*) AS question_count FROM questions;" +``` + +预期结果:`SHOW TABLES` 输出 15 张表,`questions` 有数据。 + +--- + +## 日常表结构变更 + +> 适用于:schema 已初始化,后续迭代需要新增/修改表结构。 + +### 开发流程 + +``` +修改 schema.ts → 生成迁移文件 → 本地验证 → 提交代码 +``` + +#### 1. 修改 Schema + +编辑 `src/db/schema.ts`,例如新增一张表: + +```typescript +// src/db/schema.ts — 示例:新增通知表 +export const notifications = mysqlTable('notifications', { + id: char('id', { length: 36 }).primaryKey(), + userId: char('user_id', { length: 36 }).notNull(), + title: varchar('title', { length: 200 }).notNull(), + content: text('content'), + read: tinyint('read').default(0), + createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), +}); +``` + +#### 2. 生成迁移文件 + +```bash +bun run db:generate +``` + +`drizzle-kit generate` 会对比 `schema.ts` 与上一次快照,在 `db/migrations/` 中生成新的 SQL 文件: + +``` +db/migrations/ +├── 0000_melodic_blacklash.sql ← 初始化(已存在) +├── 0001_add_notifications.sql ← 新增 +└── meta/ + ├── 0001_snapshot.json + └── _journal.json +``` + +> [!important] +> 生成的 SQL 文件**必须提交到 Git**。迁移文件是生产部署的变更记录,不可遗漏。 + +#### 3. 本地验证 + +```bash +# 方式 A:在本地 MySQL 验证 +bun run db:push + +# 方式 B:模拟生产流程(推荐) +bun run db:migrate +``` + +验证通过后,将迁移文件和 schema 变更一起提交: + +```bash +git add src/db/schema.ts db/migrations/ +git commit -m "feat: 新增通知表" +``` + +### 生产部署流程 + +迁移文件随代码提交后,通过以下两种方式应用到生产数据库: + +#### 方式 A:CI 自动执行(推荐) + +在 `.gitea/workflows/deploy.yml` 的部署 Job 中,**在构建镜像之前**执行迁移: + +```yaml +# deploy.yml — 在 build-and-deploy-prod 和 build-and-deploy-test 中添加 +steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Run database migrations + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + run: bun run db:migrate + + # ... 后续 build / deploy 步骤 +``` + +> **为什么迁移必须在部署前执行?** +> 新代码可能依赖新表/新列。先部署新代码再迁移,中间会有服务报错窗口。先迁移则旧代码仍可正常运行(向后兼容的 schema 变更)。 + +#### 方式 B:手动从本地执行(紧急/首次) + +从本地开发机直接连接 RDS 执行: + +```bash +# 1. 临时将 .env 的 DATABASE_URL 改为生产 RDS +# 2. 执行迁移 +bun run db:migrate +# 3. 改回本地 DATABASE_URL +``` + +或通过环境变量覆盖(不修改 .env): + +```bash +DATABASE_URL=mysql://duoqi_prod:password@rm-xxxxx:3306/duoqi_prod bun run db:migrate +``` + +--- + +## 常用操作速查 + +| 操作 | 命令 | 说明 | +|------|------|------| +| 推送表结构 | `bun run db:push` | 从 schema.ts 同步到数据库(开发用) | +| 生成迁移文件 | `bun run db:generate` | 对比 schema 差异,生成 SQL 文件 | +| 执行迁移 | `bun run db:migrate` | 执行未应用的迁移 SQL 文件 | +| 导入种子数据 | `bun run db:seed` | 幂等导入分类、题目、成就等 | +| 可视化浏览 | `bun run db:studio` | 启动 Drizzle Studio Web UI | +| 类型检查 | `bun run typecheck` | 确认 schema.ts 无类型错误 | + +### Drizzle Kit 命令对比 + +``` +schema.ts (源码真相) + │ + ├── db:push ────────→ 直接同步到数据库(无迁移文件,开发用) + │ + ├── db:generate ────→ 生成 SQL 迁移文件到 db/migrations/ + │ (需要提交到 Git) + │ + └── db:migrate ────→ 执行 db/migrations/ 中的 SQL + (生产部署用,幂等) +``` + +--- + +## 故障排查 + +### 1. `db:push` 报连接超时 + +```bash +# 检查网络连通性 +mysql -h rm-xxxxx.mysql.rds.aliyuncs.com -u root -p -e "SELECT 1;" + +# 如果超时,检查 RDS 白名单 +# 阿里云控制台 → RDS → 数据安全性 → 白名单设置 +# 添加本机公网 IP(可通过 curl ifconfig.me 查看) +curl ifconfig.me +``` + +### 2. `db:push` 报表已存在 + +``` +Error: Table 'users' already exists +``` + +说明数据库中已有表结构。如果是首次初始化,可能是之前执行过。`db:push` 是幂等的,已有表不会重复创建,可以安全忽略。 + +### 3. `db:migrate` 报迁移已应用 + +``` +No pending migrations to execute +``` + +所有迁移文件已执行过,这是正常输出。 + +### 4. `db:seed` 重复执行 + +种子脚本是幂等的 — 每条数据插入前都会检查是否已存在,输出 `skipped` 计数。可安全重复运行。 + +### 5. `db:generate` 未生成新文件 + +``` +Everything is fine, no changes detected +``` + +说明 `schema.ts` 没有变更,或变更已生成过迁移文件。检查: +- 是否修改了 `src/db/schema.ts` 并保存 +- `db/migrations/meta/_journal.json` 中是否已记录该变更 + +### 6. Schema 变更后类型报错 + +```bash +# 重新运行类型检查 +bun run typecheck + +# 常见原因:schema.ts 中修改了字段,但引用该字段的 service/routes 未更新 +``` + +### 7. 本地误操作生产数据库 + +如果不小心对生产数据库执行了破坏性操作: + +```bash +# 1. 立即停止操作,不要重复执行 +# 2. 从备份恢复 +mysql -h rm-xxxxx -u duoqi_prod -p duoqi_prod < /opt/backups/duoqi_prod_YYYYMMDD.sql + +# 3. 如果没有备份,在 RDS 控制台使用「数据恢复」功能(需已开启 binlog) +``` + +--- + +**文档版本**: v1.0.0 +**最后更新**: 2026-04-22 +**维护者**: Duoqi Team diff --git a/src/__tests__/services/admin/admin-auth.test.ts b/src/__tests__/services/admin/admin-auth.test.ts new file mode 100644 index 0000000..7d0f535 --- /dev/null +++ b/src/__tests__/services/admin/admin-auth.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Mock } from 'vitest'; + +// Mock bcryptjs +vi.mock('bcryptjs', () => ({ + compare: vi.fn(), + hash: vi.fn(), +})); + +// Build the mock DB once, then restore individual mocks in beforeEach +function buildMockDb() { + const db: Record = {}; + db.select = vi.fn().mockReturnValue(db); + db.from = vi.fn().mockReturnValue(db); + db.where = vi.fn().mockReturnValue(db); + db.limit = vi.fn().mockResolvedValue([]); + db.update = vi.fn().mockReturnValue(db); + db.set = vi.fn().mockReturnValue(db); + return db; +} + +const mockDb = buildMockDb(); + +vi.mock('../../../db/client.js', () => ({ db: mockDb })); + +const { changePassword } = await import('../../../services/admin/admin-auth.js'); +const bcrypt = await import('bcryptjs'); + +const mockAdmin = { + id: 'admin-123', + username: 'testadmin', + passwordHash: '$2a$10$hashedoldpassword', + role: 'admin', + isActive: 1, +}; + +describe('changePassword', () => { + beforeEach(() => { + // Reset call counts but keep implementations + mockDb.select.mockClear().mockReturnValue(mockDb); + mockDb.from.mockClear().mockReturnValue(mockDb); + mockDb.where.mockClear().mockReturnValue(mockDb); + mockDb.update.mockClear().mockReturnValue(mockDb); + mockDb.set.mockClear().mockReturnValue(mockDb); + + // Per-test defaults + mockDb.limit.mockResolvedValue([mockAdmin]); + (bcrypt.compare as Mock).mockResolvedValue(true); + (bcrypt.hash as Mock).mockResolvedValue('$2a$10$hashednewpassword'); + }); + + it('changes password when current password is correct', async () => { + await changePassword('admin-123', 'oldPassword1!', 'newPassword1!'); + + expect(bcrypt.compare).toHaveBeenCalledWith('oldPassword1!', mockAdmin.passwordHash); + expect(bcrypt.hash).toHaveBeenCalledWith('newPassword1!', 10); + expect(mockDb.update).toHaveBeenCalled(); + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ passwordHash: '$2a$10$hashednewpassword' }), + ); + }); + + it('throws UnauthorizedError when admin not found', async () => { + mockDb.limit.mockResolvedValue([]); + + await expect(changePassword('admin-123', 'old', 'new')) + .rejects.toThrow('Admin user not found'); + }); + + it('throws ForbiddenError when admin is disabled', async () => { + mockDb.limit.mockResolvedValue([{ ...mockAdmin, isActive: 0 }]); + + await expect(changePassword('admin-123', 'old', 'new')) + .rejects.toThrow('Admin account is disabled'); + }); + + it('throws UnauthorizedError when current password is wrong', async () => { + (bcrypt.compare as Mock).mockResolvedValue(false); + + await expect(changePassword('admin-123', 'wrong', 'new')) + .rejects.toThrow('Current password is incorrect'); + + expect(mockDb.update).not.toHaveBeenCalled(); + }); + + it('throws ValidationError when new password equals current password', async () => { + await expect(changePassword('admin-123', 'samePass1!', 'samePass1!')) + .rejects.toThrow('New password must be different from current password'); + + expect(mockDb.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/routes/admin/auth.ts b/src/routes/admin/auth.ts index 71f4082..ef2d99e 100644 --- a/src/routes/admin/auth.ts +++ b/src/routes/admin/auth.ts @@ -2,6 +2,7 @@ import { FastifyInstance } from 'fastify'; import { z } from 'zod'; import * as adminAuthService from '../../services/admin/admin-auth.js'; import { config } from '../../utils/config.js'; +import type { JwtPayload } from '../../types/auth.js'; // Zod schema for login request const loginSchema = z.object({ @@ -9,6 +10,12 @@ const loginSchema = z.object({ password: z.string().min(8, 'Password must be at least 8 characters'), }); +// Zod schema for change-password request +const changePasswordSchema = z.object({ + currentPassword: z.string().min(8, 'Current password must be at least 8 characters'), + newPassword: z.string().min(8, 'New password must be at least 8 characters').max(128, 'New password must be at most 128 characters'), +}); + export async function adminAuthRoutes(app: FastifyInstance): Promise { // New: Username/password login app.post('/login', async (request, reply) => { @@ -60,4 +67,33 @@ export async function adminAuthRoutes(app: FastifyInstance): Promise { return { success: true, data: { authenticated: true }, error: null }; }); + + // Change own password (any authenticated admin) + app.put('/change-password', async (request, reply) => { + const parsed = changePasswordSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + success: false, + data: null, + error: { + code: 'VALIDATION_ERROR', + message: parsed.error.issues[0]?.message ?? 'Invalid request body', + }, + }); + } + + const user = request.user as JwtPayload | undefined; + if (!user?.userId) { + return reply.status(401).send({ + success: false, + data: null, + error: { code: 'UNAUTHORIZED', message: 'Authentication required' }, + }); + } + + const { currentPassword, newPassword } = parsed.data; + + await adminAuthService.changePassword(user.userId, currentPassword, newPassword); + return { success: true, data: { message: 'Password changed successfully' }, error: null }; + }); } diff --git a/src/services/admin/admin-auth.ts b/src/services/admin/admin-auth.ts index 3002fcd..8c383d7 100644 --- a/src/services/admin/admin-auth.ts +++ b/src/services/admin/admin-auth.ts @@ -4,7 +4,7 @@ import { eq } from 'drizzle-orm'; import * as bcrypt from 'bcryptjs'; import { v4 as uuid } from 'uuid'; import type { FastifyInstance } from 'fastify'; -import { ForbiddenError } from '../../utils/errors.js'; +import { ForbiddenError, UnauthorizedError, ValidationError } from '../../utils/errors.js'; import type { JwtPayload, AdminLoginResponse } from '../../types/auth.js'; const SALT_ROUNDS = 10; @@ -110,6 +110,52 @@ export async function logFailedLogin( } } +/** + * Change an admin's own password. + * Verifies the current password before updating. + * + * @param adminId - The authenticated admin's ID (from JWT) + * @param currentPassword - The admin's current password for verification + * @param newPassword - The new password to set + * @throws {NotFoundError} If admin not found + * @throws {ForbiddenError} If admin account is disabled + * @throws {UnauthorizedError} If current password is incorrect + */ +export async function changePassword( + adminId: string, + currentPassword: string, + newPassword: string, +): Promise { + const [admin] = await db + .select() + .from(adminUsers) + .where(eq(adminUsers.id, adminId)) + .limit(1); + + if (!admin) { + throw new UnauthorizedError('Admin user not found'); + } + + if (!admin.isActive) { + throw new ForbiddenError('Admin account is disabled'); + } + + const isValid = await bcrypt.compare(currentPassword, admin.passwordHash); + if (!isValid) { + throw new UnauthorizedError('Current password is incorrect'); + } + + if (currentPassword === newPassword) { + throw new ValidationError('New password must be different from current password'); + } + + const passwordHash = await hashPassword(newPassword); + await db + .update(adminUsers) + .set({ passwordHash, updatedAt: new Date() }) + .where(eq(adminUsers.id, adminId)); +} + /** * Hash password for storage */