All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m1s
- 新增 PATCH /api/projects/:id 开发者有权限可编辑项目 - 侧边栏项目列表改用项目API直接拉取,路由切换时自动刷新 - 项目筛选器和权限分配下拉框只显示项目名称,标签自动折叠 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
389 lines
14 KiB
TypeScript
389 lines
14 KiB
TypeScript
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<string, any> = { 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<string, { todo: number; inProgress: number; review: number; done: number; totalPoints: number; name: string }> = {};
|
||
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<string, typeof allKRsForProject>();
|
||
for (const kr of allKRsForProject) {
|
||
if (!krsByObj.has(kr.objectiveId)) krsByObj.set(kr.objectiveId, []);
|
||
krsByObj.get(kr.objectiveId)!.push(kr);
|
||
}
|
||
const logsByKR = new Map<string, typeof allLogsForProject>();
|
||
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',
|
||
});
|
||
});
|