// 任务模块: 项目 / 成员 / 看板列 import { Router } from 'express' import { prisma } from '../db.js' import { authRequired } from '../middleware.js' import { loadProjectForUser, loadColumns, loadMemberIds } from '../task-domain/access.js' import { genUuid, withUuid, normProjectCode, allocProjectCode } from '../task-domain/identity.js' const router = Router() router.use(authRequired) // 新建项目时的默认 4 列 (T-COL-01, T-BD-09) export const DEFAULT_COLUMNS = [ { name: 'Plan', sortOrder: 0, boundStatus: 'todo' }, { name: 'Doing', sortOrder: 1, boundStatus: 'doing' }, { name: 'Action', sortOrder: 2, boundStatus: null }, { name: 'Done', sortOrder: 3, boundStatus: 'done' }, ] export const MAX_COLUMNS = 10 const MAX_NAME = 20 /** 建 4 个默认列 */ async function createDefaultColumns(projectId) { await prisma.boardColumn.createMany({ data: withUuid(DEFAULT_COLUMNS.map((c) => ({ ...c, projectId, retention: 'forever' }))), }) } /** 确保用户有一个个人默认项目 (T-PJ-01), 幂等 */ export async function ensurePersonalProject(userId) { const exist = await prisma.project.findFirst({ where: { ownerId: userId, isPersonal: true } }) if (exist) return exist const user = await prisma.user.findUnique({ where: { id: userId }, select: { name: true } }) const name = `${user?.name || '我'}的任务` const project = await prisma.project.create({ data: { name, ownerId: userId, isPersonal: true, uuid: genUuid(), code: await allocProjectCode(name), members: { create: { uuid: genUuid(), userId } }, }, }) await createDefaultColumns(project.id) return project } // 任务类型标签: 项目级自定义, 未配置时用默认四类 export const DEFAULT_TASK_KINDS = ['需求', 'Bug', 'FEATURE', '优化'] // 自动归档档位 (T-ARC-02) export const AUTO_ARCHIVE_OPTIONS = ['3d', '1w', '1m', 'never'] export function normTaskKinds(v) { if (!Array.isArray(v)) return null const out = [] for (const k of v) { const s = String(k || '').trim().slice(0, 20) if (s && !out.includes(s)) out.push(s) if (out.length >= 30) break } return out } function projectSummary(p) { return { id: p.id, uuid: p.uuid, code: p.code, name: p.name, ownerId: p.ownerId, isPersonal: p.isPersonal, archived: p.archived, createdAt: p.createdAt, updatedAt: p.updatedAt, taskKinds: normTaskKinds(p.taskKinds) || DEFAULT_TASK_KINDS, icon: p.icon || null, autoArchive: AUTO_ARCHIVE_OPTIONS.includes(p.autoArchive) ? p.autoArchive : 'never', memberCount: p._count?.members ?? undefined, taskCount: p._count?.tasks ?? undefined, } } // ---------- 项目 ---------- // GET /api/projects 我参与的项目 router.get('/', async (req, res, next) => { try { const userId = req.user.sub await ensurePersonalProject(userId) const rows = await prisma.project.findMany({ where: { members: { some: { userId } } }, include: { _count: { select: { members: true, tasks: { where: { archived: false } } } } }, orderBy: [{ archived: 'asc' }, { updatedAt: 'desc' }], }) res.json({ items: rows.map(projectSummary) }) } catch (e) { next(e) } }) // POST /api/projects { name } router.post('/', async (req, res, next) => { try { const userId = req.user.sub const name = String(req.body?.name || '').trim() if (!name) return res.status(400).json({ error: '项目名称不能为空' }) if (name.length > MAX_NAME) return res.status(400).json({ error: `项目名称不能超过 ${MAX_NAME} 字` }) // 项目短编号: 可由客户端自拟 (2~8 位字母数字, 全局唯一), 未提供时按名称自动分配 let code const rawCode = req.body?.code if (rawCode !== undefined && rawCode !== null && String(rawCode).trim() !== '') { code = normProjectCode(rawCode) if (!code) return res.status(400).json({ error: '项目编号须为 2~8 位字母数字' }) const dup = await prisma.project.findUnique({ where: { code } }) if (dup) return res.status(409).json({ error: `项目编号 ${code} 已被占用` }) } else { code = await allocProjectCode(name) } const project = await prisma.project.create({ data: { name, ownerId: userId, isPersonal: false, uuid: genUuid(), code, members: { create: { uuid: genUuid(), userId } }, }, }) await createDefaultColumns(project.id) const full = await prisma.project.findUnique({ where: { id: project.id }, include: { _count: { select: { members: true, tasks: { where: { archived: false } } } } }, }) res.json({ item: projectSummary(full) }) } catch (e) { next(e) } }) // GET /api/projects/:id 详情(项目 + 成员 + 列) router.get('/:id', async (req, res, next) => { try { const userId = req.user.sub const project = await loadProjectForUser(req.params.id, userId) if (!project) return res.status(404).json({ error: '项目不存在或无权限' }) const [members, columns] = await Promise.all([ prisma.projectMember.findMany({ where: { projectId: project.id }, include: { user: { select: { id: true, name: true, email: true } } }, orderBy: { createdAt: 'asc' }, }), loadColumns(project.id), ]) res.json({ item: { ...projectSummary(project), members: members.map((m) => ({ userId: m.userId, name: m.user.name, email: m.user.email, permission: m.permission })), columns, notes: Array.isArray(project.notes) ? project.notes : [], }, }) } catch (e) { next(e) } }) // PUT /api/projects/:id { name?, archived?, notes? } router.put('/:id', async (req, res, next) => { try { const userId = req.user.sub const project = await loadProjectForUser(req.params.id, userId) if (!project) return res.status(404).json({ error: '项目不存在或无权限' }) const { name, archived, notes, taskKinds, icon, autoArchive, code } = req.body || {} const data = {} if (name !== undefined) { const t = String(name).trim() if (!t) return res.status(400).json({ error: '项目名称不能为空' }) if (t.length > MAX_NAME) return res.status(400).json({ error: `项目名称不能超过 ${MAX_NAME} 字` }) data.name = t } // 项目短编号: 可修改 (2~8 位字母数字, 全局唯一) if (code !== undefined) { const c = normProjectCode(code) if (!c) return res.status(400).json({ error: '项目编号须为 2~8 位字母数字' }) if (c !== project.code) { const dup = await prisma.project.findUnique({ where: { code: c } }) if (dup) return res.status(409).json({ error: `项目编号 ${c} 已被占用` }) data.code = c } } // 任务类型标签: 整体替换; 空数组回落默认 if (taskKinds !== undefined) { if (!Array.isArray(taskKinds)) return res.status(400).json({ error: '任务类型格式非法' }) const kinds = normTaskKinds(taskKinds) if (!kinds?.length) return res.status(400).json({ error: '至少保留一个任务类型' }) data.taskKinds = kinds } // 项目图标: data URL (客户端已裁剪), null 清除回退首字色块 if (icon !== undefined && icon !== null) { const s = String(icon) if (!s.startsWith('data:image/')) return res.status(400).json({ error: '图标格式非法' }) if (s.length > 300_000) return res.status(400).json({ error: '图标过大' }) data.icon = s } else if (icon === null) { data.icon = null } // 自动归档档位 (T-ARC-02) if (autoArchive !== undefined) { if (!AUTO_ARCHIVE_OPTIONS.includes(autoArchive)) return res.status(400).json({ error: '自动归档档位非法' }) data.autoArchive = autoArchive } if (archived !== undefined) data.archived = !!archived // 项目信息块 (T-PJ-13): 任意多个 { title, content }, 整体替换 if (notes !== undefined) { if (!Array.isArray(notes)) return res.status(400).json({ error: '信息块格式非法' }) data.notes = notes.slice(0, 50).map((n) => ({ title: String(n?.title ?? '').slice(0, 50), content: String(n?.content ?? '').slice(0, 20000), })) } const row = await prisma.project.update({ where: { id: project.id }, data }) res.json({ item: projectSummary(row) }) } catch (e) { next(e) } }) // DELETE /api/projects/:id 个人项目拒绝 (T-PJ-02, T-EX-09) router.delete('/:id', async (req, res, next) => { try { const userId = req.user.sub const project = await loadProjectForUser(req.params.id, userId) if (!project) return res.status(404).json({ error: '项目不存在或无权限' }) if (project.isPersonal) return res.status(400).json({ error: '个人默认项目不可删除' }) await prisma.project.delete({ where: { id: project.id } }) res.json({ ok: true }) } catch (e) { next(e) } }) // ---------- 成员 ---------- // POST /api/projects/:id/members { userId } | { email } router.post('/:id/members', async (req, res, next) => { try { const userId = req.user.sub const project = await loadProjectForUser(req.params.id, userId) if (!project) return res.status(404).json({ error: '项目不存在或无权限' }) if (project.isPersonal) return res.status(400).json({ error: '个人默认项目不可添加成员' }) const { userId: targetId, email } = req.body || {} let target = null if (targetId) target = await prisma.user.findUnique({ where: { id: Number(targetId) } }) else if (email) target = await prisma.user.findFirst({ where: { email: String(email).trim() } }) if (!target) return res.status(404).json({ error: '用户不存在' }) const exist = await prisma.projectMember.findUnique({ where: { projectId_userId: { projectId: project.id, userId: target.id } }, }) if (exist) return res.status(409).json({ error: '该用户已是项目成员' }) await prisma.projectMember.create({ data: { uuid: genUuid(), projectId: project.id, userId: target.id } }) res.json({ ok: true, member: { userId: target.id, name: target.name } }) } catch (e) { next(e) } }) // DELETE /api/projects/:id/members/:uid 移除成员 / 自己退出 (T-PJ-06/07) router.delete('/:id/members/:uid', async (req, res, next) => { try { const userId = req.user.sub const project = await loadProjectForUser(req.params.id, userId) if (!project) return res.status(404).json({ error: '项目不存在或无权限' }) if (project.isPersonal) return res.status(400).json({ error: '个人默认项目不可移除成员' }) const target = Number(req.params.uid) // 只能移除自己, 或是创建人移除他人 (本期权限均等, 允许成员自退) if (target !== userId && project.ownerId !== userId) { return res.status(403).json({ error: '只能移除自己,或由创建人移除他人' }) } await prisma.projectMember.deleteMany({ where: { projectId: project.id, userId: target } }) // 成员清空后项目静默保留 (T-PJ-08) res.json({ ok: true }) } catch (e) { next(e) } }) // ---------- 看板列 ---------- // GET /api/projects/:id/columns router.get('/:id/columns', async (req, res, next) => { try { const userId = req.user.sub const project = await loadProjectForUser(req.params.id, userId) if (!project) return res.status(404).json({ error: '项目不存在或无权限' }) res.json({ items: await loadColumns(project.id) }) } catch (e) { next(e) } }) // POST /api/projects/:id/columns { name } router.post('/:id/columns', async (req, res, next) => { try { const userId = req.user.sub const project = await loadProjectForUser(req.params.id, userId) if (!project) return res.status(404).json({ error: '项目不存在或无权限' }) const name = String(req.body?.name || '').trim() if (!name) return res.status(400).json({ error: '列名不能为空' }) if (name.length > MAX_NAME) return res.status(400).json({ error: `列名不能超过 ${MAX_NAME} 字` }) const cols = await loadColumns(project.id) if (cols.length >= MAX_COLUMNS) return res.status(400).json({ error: `最多 ${MAX_COLUMNS} 列` }) if (cols.some((c) => c.name === name)) return res.status(409).json({ error: '列名已存在' }) const row = await prisma.boardColumn.create({ data: { projectId: project.id, name, sortOrder: cols.length ? Math.max(...cols.map((c) => c.sortOrder)) + 1 : 0, boundStatus: null, retention: 'forever', }, }) res.json({ item: row }) } catch (e) { next(e) } }) // PUT /api/projects/:id/columns/order { ids: number[] } router.put('/:id/columns/order', async (req, res, next) => { try { const userId = req.user.sub const project = await loadProjectForUser(req.params.id, userId) if (!project) return res.status(404).json({ error: '项目不存在或无权限' }) const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(Number) : [] const cols = await loadColumns(project.id) if (ids.length !== cols.length) return res.status(400).json({ error: '列数量不匹配' }) await prisma.$transaction( ids.map((id, idx) => prisma.boardColumn.update({ where: { id }, data: { sortOrder: idx } })), ) res.json({ items: await loadColumns(project.id) }) } catch (e) { next(e) } }) // PUT /api/columns/:id { name?, boundStatus?, retention? } export const columnRouter = Router() columnRouter.use(authRequired) columnRouter.put('/:id', async (req, res, next) => { try { const userId = req.user.sub const id = Number(req.params.id) const col = await prisma.boardColumn.findUnique({ where: { id } }) if (!col) return res.status(404).json({ error: '列不存在' }) const project = await loadProjectForUser(col.projectId, userId) if (!project) return res.status(404).json({ error: '项目不存在或无权限' }) const { name, boundStatus, retention } = req.body || {} const data = {} if (name !== undefined) { const t = String(name).trim() if (!t) return res.status(400).json({ error: '列名不能为空' }) if (t.length > MAX_NAME) return res.status(400).json({ error: `列名不能超过 ${MAX_NAME} 字` }) const dup = await prisma.boardColumn.findFirst({ where: { projectId: col.projectId, name: t, NOT: { id } } }) if (dup) return res.status(409).json({ error: '列名已存在' }) data.name = t } if (boundStatus !== undefined) { // null | todo | doing | done if (boundStatus !== null && !['todo', 'doing', 'done'].includes(boundStatus)) { return res.status(400).json({ error: '绑定状态取值非法' }) } data.boundStatus = boundStatus } if (retention !== undefined) { if (!['never', 'today', '3d', 'week', 'month', 'forever'].includes(retention)) { return res.status(400).json({ error: '保留选项取值非法' }) } data.retention = retention } const row = await prisma.boardColumn.update({ where: { id }, data }) res.json({ item: row }) } catch (e) { next(e) } }) // DELETE /api/columns/:id?moveToColumnId=N 删列并迁移任务 (T-COL-04, T-EX-16/17) columnRouter.delete('/:id', async (req, res, next) => { try { const userId = req.user.sub const id = Number(req.params.id) const col = await prisma.boardColumn.findUnique({ where: { id } }) if (!col) return res.status(404).json({ error: '列不存在' }) const project = await loadProjectForUser(col.projectId, userId) if (!project) return res.status(404).json({ error: '项目不存在或无权限' }) const cols = await loadColumns(col.projectId) if (cols.length <= 1) return res.status(400).json({ error: '至少保留一列' }) // 迁移目标: 指定列 否则相邻列(优先后一个, 没有则前一个) let targetId = Number(req.query.moveToColumnId) || 0 if (!targetId) { const idx = cols.findIndex((c) => c.id === id) targetId = (cols[idx + 1] || cols[idx - 1]).id } const target = cols.find((c) => c.id === targetId) if (!target || target.id === id) return res.status(400).json({ error: '迁移目标列非法' }) // 迁移任务: 状态按目标列绑定重新裁决 (设计文档 §2.4) const tasks = await prisma.task.findMany({ where: { columnId: id } }) const newStatus = target.boundStatus || null await prisma.task.updateMany({ where: { columnId: id }, data: { columnId: target.id, ...(newStatus ? { status: newStatus } : {}), }, }) await prisma.boardColumn.delete({ where: { id } }) res.json({ ok: true, moved: tasks.length, toColumnId: target.id }) } catch (e) { next(e) } }) export { createDefaultColumns, loadMemberIds } export default router