// 任务模块: 任务 / 子任务 / 跨项目拷贝 import { Router } from 'express' import { prisma } from '../db.js' import { authRequired } from '../middleware.js' import { loadTaskForUser, loadProjectForUser, loadColumns, loadMemberIds, } from '../task-domain/access.js' import { resolveTaskWrite } from '../task-domain/resolveStatus.js' import { filterVisible } from '../task-domain/retention.js' import { copyTask } from '../task-domain/copyTask.js' const router = Router() router.use(authRequired) const TASK_INCLUDE = { column: true, assignee: { select: { id: true, name: true } }, creator: { select: { id: true, name: true } }, members: { include: { user: { select: { id: true, name: true } } } }, tags: true, children: { include: { column: true, assignee: { select: { id: true, name: true } }, members: { include: { user: { select: { id: true, name: true } } } }, tags: true, }, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], }, } // 归一化: members / tags 平铺为数组, 便于前端直接用 function shape(task) { if (!task) return task const out = { ...task, members: (task.members || []).map((m) => ({ userId: m.userId, name: m.user?.name })), tags: (task.tags || []).map((t) => t.name), } if (Array.isArray(task.children)) out.children = task.children.map(shape) return out } // ---------- 项目下的任务列表 ---------- // GET /api/projects/:id/tasks router.get('/projects/:id/tasks', 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 columns = await loadColumns(project.id) const rows = await prisma.task.findMany({ where: { projectId: project.id, parentId: null }, include: TASK_INCLUDE, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], }) const shaped = rows.map(shape) // 保留选项过滤 (T-FL-*): 已完成任务按所属列配置隐藏 const visible = filterVisible(shaped, columns) res.json({ items: visible, columns, total: shaped.length }) } catch (e) { next(e) } }) // POST /api/projects/:id/tasks { title, description?, parentId?, assigneeId?, memberIds?, tags? } router.post('/projects/:id/tasks', 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 { title, description = '', parentId, assigneeId, memberIds = [], tags = [], kind } = req.body || {} const cleanTitle = String(title || '').trim() if (!cleanTitle) return res.status(400).json({ error: '任务标题不能为空' }) if (cleanTitle.length > 200) return res.status(400).json({ error: '任务标题不能超过 200 字' }) const columns = await loadColumns(project.id) if (!columns.length) return res.status(400).json({ error: '项目没有可用的看板列' }) // 子任务校验: 父任务必须存在、同项目、且本身是顶层任务 (T-ST-01/02, T-EX-12) let pid = null if (parentId) { const parent = await prisma.task.findUnique({ where: { id: Number(parentId) } }) if (!parent) return res.status(404).json({ error: '父任务不存在' }) if (parent.projectId !== project.id) return res.status(400).json({ error: '子任务必须与父任务同项目' }) if (parent.parentId) return res.status(400).json({ error: '仅支持一级子任务' }) pid = parent.id } const memberIds_ = await loadMemberIds(project.id) const memberSet = new Set(memberIds_) // 负责人: 未指定 => 默认创建者本人 (T-TK-09); 显式 null => 不指派 let finalAssignee = null if (assigneeId === undefined) finalAssignee = memberSet.has(userId) ? userId : null else if (assigneeId !== null) { const aid = Number(assigneeId) if (!memberSet.has(aid)) return res.status(400).json({ error: '负责人必须是项目成员' }) finalAssignee = aid } const firstCol = columns[0] const row = await prisma.task.create({ data: { projectId: project.id, columnId: firstCol.id, parentId: pid, title: cleanTitle, description: String(description || ''), kind: kind ? String(kind).trim().slice(0, 20) : null, status: firstCol.boundStatus || 'todo', assigneeId: finalAssignee, creatorId: userId, extras: [], members: { create: (Array.isArray(memberIds) ? memberIds : []) .map(Number).filter((id) => memberSet.has(id)) .map((uid) => ({ userId: uid })), }, tags: { create: (Array.isArray(tags) ? tags : []).filter(Boolean).map((n) => ({ name: String(n).slice(0, 20) })) }, }, include: TASK_INCLUDE, }) res.json({ item: shape(row) }) } catch (e) { next(e) } }) // POST /api/projects/:id/tasks/reorder { ids: [顶层任务 id 的新顺序] } // 按数组下标重写 sortOrder (组内拖动排序; 子任务不参与, 跟随父任务) router.post('/projects/:id/tasks/reorder', 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).filter(Number.isFinite) : [] if (!ids.length) return res.status(400).json({ error: 'ids 不能为空' }) const rows = await prisma.task.findMany({ where: { projectId: project.id, id: { in: ids }, parentId: null }, select: { id: true }, }) if (rows.length !== new Set(ids).size) return res.status(400).json({ error: '包含无效的任务' }) await prisma.$transaction( ids.map((id, i) => prisma.task.update({ where: { id }, data: { sortOrder: i } })), ) res.json({ ok: true }) } catch (e) { next(e) } }) // ---------- 单条任务 ---------- // GET /api/tasks/:id router.get('/tasks/:id', async (req, res, next) => { try { const userId = req.user.sub const found = await loadTaskForUser(req.params.id, userId) if (!found) return res.status(404).json({ error: '任务不存在或无权限' }) res.json({ item: shape(found.task) }) } catch (e) { next(e) } }) // PUT /api/tasks/:id 关键: 列/状态联动裁决 router.put('/tasks/:id', async (req, res, next) => { try { const userId = req.user.sub const found = await loadTaskForUser(req.params.id, userId) if (!found) return res.status(404).json({ error: '任务不存在或无权限' }) const { task, project } = found const body = req.body || {} const columns = await loadColumns(project.id) const data = {} // 文本 / 扩展信息 if (body.title !== undefined) { const t = String(body.title).trim() if (!t) return res.status(400).json({ error: '任务标题不能为空' }) if (t.length > 200) return res.status(400).json({ error: '任务标题不能超过 200 字' }) data.title = t } if (body.description !== undefined) data.description = String(body.description) // 任务类型标签: null 清空, 否则截断保存 if (body.kind !== undefined) { const k = String(body.kind || '').trim().slice(0, 20) data.kind = k || null } if (body.extras !== undefined) { if (!Array.isArray(body.extras)) return res.status(400).json({ error: '扩展信息格式非法' }) data.extras = body.extras.map((e) => ({ label: String(e?.label ?? '').slice(0, 40), value: String(e?.value ?? ''), url: e?.url ? String(e.url) : '', })) } if (body.sortOrder !== undefined) data.sortOrder = Number(body.sortOrder) || 0 // 负责人: 必须是项目成员或 null (T-AS-03) const memberIds = await loadMemberIds(project.id) const memberSet = new Set(memberIds) if (body.assigneeId !== undefined) { if (body.assigneeId === null || body.assigneeId === '') data.assigneeId = null else { const aid = Number(body.assigneeId) if (!memberSet.has(aid)) return res.status(400).json({ error: '负责人必须是项目成员' }) data.assigneeId = aid } } // 列 / 状态: 走统一裁决 (T-BD-*) const patch = {} if (body.columnId !== undefined) patch.columnId = Number(body.columnId) if (body.status !== undefined) { if (!['todo', 'doing', 'done'].includes(body.status)) return res.status(400).json({ error: '任务状态取值非法' }) patch.status = body.status } if (patch.columnId !== undefined && !columns.some((c) => c.id === patch.columnId)) { return res.status(400).json({ error: '看板列不存在' }) } let finalStatus = task.status if (Object.keys(patch).length) { const r = resolveTaskWrite(task, patch, columns) data.status = r.status data.columnId = r.columnId finalStatus = r.status } // 完成时间: 进入 done 写 now, 退出 done 置空 (T-FL-10) if (finalStatus === 'done' && task.status !== 'done') data.doneAt = new Date() else if (finalStatus !== 'done' && data.status !== undefined) data.doneAt = null // 成员 / 标签: 整体替换 if (Array.isArray(body.memberIds)) { const ids = body.memberIds.map(Number).filter((id) => memberSet.has(id)) await prisma.taskMember.deleteMany({ where: { taskId: task.id } }) if (ids.length) { await prisma.taskMember.createMany({ data: ids.map((uid) => ({ taskId: task.id, userId: uid })), }) } } if (Array.isArray(body.tags)) { await prisma.taskTag.deleteMany({ where: { taskId: task.id } }) const names = body.tags.map((n) => String(n).trim()).filter(Boolean).slice(0, 10) if (names.length) { await prisma.taskTag.createMany({ data: names.map((name) => ({ taskId: task.id, name: name.slice(0, 20) })) }) } } // 拖拽降为子任务 (T-ST-12~15) if (body.parentId !== undefined) { if (body.parentId === null) { data.parentId = null } else { const pid = Number(body.parentId) if (pid === task.id) return res.status(400).json({ error: '不能成为自己的子任务' }) const parent = await prisma.task.findUnique({ where: { id: pid } }) if (!parent) return res.status(404).json({ error: '目标任务不存在' }) if (parent.projectId !== task.projectId) return res.status(400).json({ error: '子任务必须与父任务同项目' }) if (parent.parentId) return res.status(400).json({ error: '仅支持一级子任务' }) const childCount = await prisma.task.count({ where: { parentId: task.id } }) if (childCount > 0) return res.status(400).json({ error: '该任务已有子任务,不能成为子任务' }) data.parentId = pid } } const row = await prisma.task.update({ where: { id: task.id }, data, include: TASK_INCLUDE }) res.json({ item: shape(row) }) } catch (e) { next(e) } }) // DELETE /api/tasks/:id 级联删除子任务 (T-ST-11, T-EX-07) router.delete('/tasks/:id', async (req, res, next) => { try { const userId = req.user.sub const found = await loadTaskForUser(req.params.id, userId) if (!found) return res.status(404).json({ error: '任务不存在或无权限' }) const childCount = await prisma.task.count({ where: { parentId: found.task.id } }) await prisma.task.delete({ where: { id: found.task.id } }) res.json({ ok: true, deletedSubtasks: childCount }) } catch (e) { next(e) } }) // POST /api/tasks/:id/copy { projectId } 跨项目拷贝 (T-CP-*) router.post('/tasks/:id/copy', async (req, res, next) => { try { const userId = req.user.sub const found = await loadTaskForUser(req.params.id, userId) if (!found) return res.status(404).json({ error: '任务不存在或无权限' }) const targetId = Number(req.body?.projectId) const targetProject = await loadProjectForUser(targetId, userId) if (!targetProject) return res.status(403).json({ error: '目标项目不存在或你不是其成员' }) const targetColumns = await loadColumns(targetProject.id) if (!targetColumns.length) return res.status(400).json({ error: '目标项目没有可用的看板列' }) const targetMemberIds = await loadMemberIds(targetProject.id) // 源任务需带列名映射信息 const sourceColumns = await loadColumns(found.project.id) const source = { ...found.task, __columns: sourceColumns } const result = await copyTask({ source, targetProject, targetColumns, operatorId: userId, targetMemberIds, }) const fresh = await prisma.task.findUnique({ where: { id: result.task.id }, include: TASK_INCLUDE }) res.json({ item: shape(fresh), dropped: result.dropped }) } catch (e) { next(e) } }) export default router