import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; import { v4 as uuid } from 'uuid'; import { db } from '../db/index'; import { projects, sprintSnapshots, milestones, taskSnapshots, gitCommits, gitPRs, users, objectives, keyResults, projectRepos, krLogs, userProjectPermissions } from '../db/schema'; import { eq, and, desc, gte, inArray } from 'drizzle-orm'; import { requireRole } from '../middleware/role'; import { AppError } from '../middleware/error-handler'; import { getAllowedProjectIds } from '../services/permissions'; import dayjs from 'dayjs'; export const projectRoutes = new Hono(); // GET /api/projects — 所有登录用户都能查(观察者仅返回已分配的项目) projectRoutes.get('/projects', async (c) => { const user = c.get('user'); const allowedIds = await getAllowedProjectIds(user); let allProjects = await db.select().from(projects); if (allowedIds !== null) { allProjects = allProjects.filter(p => allowedIds.includes(p.id)); } return c.json({ code: 0, data: allProjects.map(p => ({ id: p.id, name: p.name, identifier: p.identifier, planeProjectId: p.planeProjectId, createdAt: p.createdAt instanceof Date ? p.createdAt.toISOString() : p.createdAt, lastSyncedAt: p.lastSyncedAt?.toISOString() || null, })), message: 'success', }); }); // POST /api/projects — admin/manager/developer 都能创建 const createProjectSchema = z.object({ name: z.string().min(1).max(200), identifier: z.string().min(1).max(20).toUpperCase(), }); projectRoutes.post('/projects', requireRole('admin', 'manager', 'developer'), zValidator('json', createProjectSchema), async (c) => { const user = c.get('user'); const data = c.req.valid('json'); const id = uuid(); const now = new Date(); await db.insert(projects).values({ id, planeProjectId: `local-${id}`, name: data.name, identifier: data.identifier, createdAt: now, updatedAt: now, }); // 开发者创建项目时自动获得该项目的查看权限 if (user.role === 'developer') { await db.insert(userProjectPermissions).values({ id: uuid(), userId: user.sub, projectId: id, createdAt: now, }); } return c.json({ code: 0, data: { id }, message: 'success' }, 201); } ); // DELETE /api/projects/:id — 仅 admin 能删 projectRoutes.delete('/projects/:id', requireRole('admin'), async (c) => { const id = c.req.param('id'); // 先清理关联数据(外键约束) await db.delete(userProjectPermissions).where(eq(userProjectPermissions.projectId, id)); await db.delete(projectRepos).where(eq(projectRepos.projectId, id)); // 清理 OKR:KR logs → KR → Objectives const objs = await db.select().from(objectives).where(eq(objectives.projectId, id)); for (const obj of objs) { const krs = await db.select().from(keyResults).where(eq(keyResults.objectiveId, obj.id)); for (const kr of krs) { await db.delete(krLogs).where(eq(krLogs.krId, kr.id)); } await db.delete(keyResults).where(eq(keyResults.objectiveId, obj.id)); } await db.delete(objectives).where(eq(objectives.projectId, id)); await db.delete(projects).where(eq(projects.id, id)); return c.json({ code: 0, data: null, message: 'success' }); } ); // PATCH /api/projects/:id — 开发者(有权限)或管理员可编辑 const updateProjectSchema = z.object({ name: z.string().min(1).max(200).optional(), identifier: z.string().min(1).max(20).toUpperCase().optional(), }); projectRoutes.patch('/projects/:id', requireRole('admin', 'manager', 'developer'), zValidator('json', updateProjectSchema), async (c) => { const id = c.req.param('id'); const user = c.get('user'); const data = c.req.valid('json'); // 开发者需要有该项目的权限 if (user.role === 'developer') { const allowedIds = await getAllowedProjectIds(user); if (allowedIds !== null && !allowedIds.includes(id)) { throw new AppError(40103, 'Insufficient permissions', 403); } } const project = await db.query.projects.findFirst({ where: eq(projects.id, id) }); if (!project) { throw new AppError(40402, 'Project not found', 404); } const updateData: Record = { updatedAt: new Date() }; if (data.name) updateData.name = data.name; if (data.identifier) updateData.identifier = data.identifier; await db.update(projects).set(updateData).where(eq(projects.id, id)); return c.json({ code: 0, data: { id }, message: 'success' }); } ); // GET /api/projects/:id/repos — 所有登录用户 projectRoutes.get('/projects/:id/repos', async (c) => { const projectId = c.req.param('id'); const bindings = await db.select().from(projectRepos) .where(eq(projectRepos.projectId, projectId)); return c.json({ code: 0, data: bindings, message: 'success' }); }); // POST /api/projects/:id/repos — admin/manager/developer 能绑定仓库 const bindRepoSchema = z.object({ repoName: z.string().min(1) }); projectRoutes.post('/projects/:id/repos', requireRole('admin', 'manager', 'developer'), zValidator('json', bindRepoSchema), async (c) => { const projectId = c.req.param('id'); const { repoName } = c.req.valid('json'); const id = uuid(); await db.insert(projectRepos).values({ id, projectId, repoName, createdAt: new Date() }); return c.json({ code: 0, data: { id }, message: 'success' }, 201); } ); // DELETE /api/project-repos/:id — admin/manager 能解绑 projectRoutes.delete('/project-repos/:id', requireRole('admin', 'manager'), async (c) => { const id = c.req.param('id'); await db.delete(projectRepos).where(eq(projectRepos.id, id)); return c.json({ code: 0, data: null, message: 'success' }); } ); // GET /api/projects/:id projectRoutes.get('/projects/:id', async (c) => { const projectId = c.req.param('id'); const user = c.get('user'); // 观察者只能查看已分配的项目 const allowedIds = await getAllowedProjectIds(user); if (allowedIds !== null && !allowedIds.includes(projectId)) { throw new AppError(40103, 'Insufficient permissions', 403); } const project = await db.query.projects.findFirst({ where: eq(projects.id, projectId), }); if (!project) { throw new AppError(40402, 'Project not found', 404); } // Current cycle const activeSprint = await db.query.sprintSnapshots.findFirst({ where: and( eq(sprintSnapshots.projectId, projectId), eq(sprintSnapshots.status, 'active') ), }); const currentCycle = activeSprint ? { name: activeSprint.name, startDate: activeSprint.startDate || '', endDate: activeSprint.endDate || '', deliveryRate: (activeSprint.totalPoints || 0) > 0 ? Math.round(((activeSprint.completedPoints || 0) / (activeSprint.totalPoints || 1)) * 100) : 0, burndown: (activeSprint.burndownData as any[]) || [], } : null; // Milestones const projectMilestones = await db.select().from(milestones) .where(eq(milestones.projectId, projectId)); const milestoneData = projectMilestones.map(m => ({ id: m.id, name: m.name, status: m.status || 'backlog', targetDate: m.targetDate || '', progress: (m.totalIssues || 0) > 0 ? Math.round(((m.completedIssues || 0) / (m.totalIssues || 1)) * 100) : 0, totalIssues: m.totalIssues || 0, completedIssues: m.completedIssues || 0, })); // Task Matrix const tasks = await db.select().from(taskSnapshots) .where(eq(taskSnapshots.projectId, projectId)); const memberTaskMap: Record = {}; const allUsers = await db.select().from(users); const userMap = new Map(allUsers.map(u => [u.id, u.displayName])); for (const task of tasks) { const uid = task.assigneeId || 'unassigned'; if (!memberTaskMap[uid]) { memberTaskMap[uid] = { todo: 0, inProgress: 0, review: 0, done: 0, totalPoints: 0, name: userMap.get(uid) || 'Unassigned' }; } const m = memberTaskMap[uid]; if (task.status === 'todo') m.todo++; else if (task.status === 'in_progress') m.inProgress++; else if (task.status === 'review') m.review++; else if (task.status === 'done') m.done++; m.totalPoints += task.storyPoints || 0; } const taskMatrix = { members: Object.entries(memberTaskMap).map(([userId, data]) => ({ userId, ...data, })), }; // OKR for this project (batch queries to avoid N+1) const projectObjectives = await db.select().from(objectives) .where(eq(objectives.projectId, projectId)); const objIds = projectObjectives.map(o => o.id); const allKRsForProject = objIds.length > 0 ? await db.select().from(keyResults).where(inArray(keyResults.objectiveId, objIds)) : []; const krIds = allKRsForProject.map(kr => kr.id); const allLogsForProject = krIds.length > 0 ? await db.select().from(krLogs).where(inArray(krLogs.krId, krIds)).orderBy(desc(krLogs.createdAt)) : []; // Group KRs by objective, logs by KR const krsByObj = new Map(); for (const kr of allKRsForProject) { if (!krsByObj.has(kr.objectiveId)) krsByObj.set(kr.objectiveId, []); krsByObj.get(kr.objectiveId)!.push(kr); } const logsByKR = new Map(); for (const log of allLogsForProject) { if (!logsByKR.has(log.krId)) logsByKR.set(log.krId, []); const arr = logsByKR.get(log.krId)!; if (arr.length < 5) arr.push(log); } const okrData = []; let totalOKRProgress = 0; for (const obj of projectObjectives) { const krs = krsByObj.get(obj.id) || []; okrData.push({ id: obj.id, title: obj.title, ownerId: obj.ownerId || null, ownerName: obj.ownerId ? (userMap.get(obj.ownerId) || '未指定') : '未指定', period: obj.period, startDate: obj.startDate || null, endDate: obj.endDate || null, progress: obj.progress || 0, keyResults: krs.map(kr => { const logs = logsByKR.get(kr.id) || []; const wasPostponed = logs.some(l => l.action === 'postponed'); const lastPostponeReason = logs.find(l => l.action === 'postponed')?.detail || null; return { id: kr.id, title: kr.title, targetValue: kr.targetValue, currentValue: kr.currentValue || 0, unit: kr.unit || '', weight: kr.weight || 1, status: kr.status || 'active', wasPostponed, lastPostponeReason, startDate: kr.startDate || null, endDate: kr.endDate || null, progress: kr.targetValue > 0 ? Math.round(((kr.currentValue || 0) / kr.targetValue) * 100) : 0, }; }), }); totalOKRProgress += obj.progress || 0; } const avgOKRProgress = projectObjectives.length > 0 ? Math.round(totalOKRProgress / projectObjectives.length) : 0; // Git Activity for project (filter by bound repos) const boundRepos = await db.select().from(projectRepos) .where(eq(projectRepos.projectId, projectId)); // 解析仓库名:支持完整 URL、owner/name、纯名称 const boundRepoNames = new Set(boundRepos.map(r => { let cleaned = r.repoName.trim().replace(/\.git$/, ''); if (cleaned.includes('://')) { try { const parts = new URL(cleaned).pathname.split('/').filter(Boolean); return parts[parts.length - 1] || cleaned; } catch {} } if (cleaned.includes('/')) return cleaned.split('/').pop() || cleaned; return cleaned; })); // 获取该项目绑定仓库的 Git 数据(按仓库名过滤,避免全表扫描) const boundRepoNamesList = Array.from(boundRepoNames); const recentCommits = boundRepoNamesList.length > 0 ? await db.select().from(gitCommits).where(inArray(gitCommits.repoName, boundRepoNamesList)) : []; const recentPRs = boundRepoNamesList.length > 0 ? await db.select().from(gitPRs).where(inArray(gitPRs.repoName, boundRepoNamesList)) : []; const weeklyTrend: { weekStart: string; commits: number; prs: number }[] = []; for (let i = 0; i < 12; i++) { const weekStart = dayjs().subtract(11 - i, 'week').startOf('week'); const weekEnd = weekStart.add(7, 'day'); weeklyTrend.push({ weekStart: weekStart.format('YYYY-MM-DD'), commits: recentCommits.filter(c => { const d = dayjs(c.committedAt); return d.isAfter(weekStart) && d.isBefore(weekEnd); }).length, prs: recentPRs.filter(p => { const d = dayjs(p.createdAt); return d.isAfter(weekStart) && d.isBefore(weekEnd); }).length, }); } const gitActivity = { recentCommits: recentCommits.length, recentPRs: recentPRs.length, weeklyTrend, boundRepos: Array.from(boundRepoNames), }; return c.json({ code: 0, data: { project: { id: project.id, name: project.name, identifier: project.identifier, lastSyncedAt: project.lastSyncedAt?.toISOString() || null, }, currentCycle, milestones: milestoneData, taskMatrix, gitActivity, recentCommitList: recentCommits .sort((a, b) => dayjs(b.committedAt).valueOf() - dayjs(a.committedAt).valueOf()) .slice(0, 15) .map(c => ({ sha: c.sha?.slice(0, 7) || '', message: (c.message || '').split('\n')[0].slice(0, 60), authorName: c.userId ? (userMap.get(c.userId) || c.authorName) : c.authorName, committedAt: c.committedAt instanceof Date ? c.committedAt.toISOString() : c.committedAt, })), okr: { objectives: okrData, overallProgress: avgOKRProgress, }, }, message: 'success', }); });