# Conflicts: # generated/prisma/internal/class.ts # generated/prisma/internal/prismaNamespace.ts # generated/prisma/internal/prismaNamespaceBrowser.ts # generated/prisma/models/BoardColumn.ts # generated/prisma/models/Project.ts # generated/prisma/models/ProjectMember.ts # generated/prisma/models/Task.ts # generated/prisma/models/TaskMember.ts # generated/prisma/models/TaskTag.ts # generated/prisma/models/User.ts # server/routes/projects.js # server/routes/tasks.js # src/api/task.js
414 lines
17 KiB
JavaScript
414 lines
17 KiB
JavaScript
// 任务模块: 任务 / 子任务 / 跨项目拷贝
|
|
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'
|
|
import { runAutoArchiveForProject } from '../task-domain/archive.js'
|
|
import { genUuid, withUuid, withTaskNumber } from '../task-domain/identity.js'
|
|
|
|
const router = Router()
|
|
router.use(authRequired)
|
|
|
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
|
|
|
|
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: {
|
|
where: { archived: false },
|
|
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: '项目不存在或无权限' })
|
|
|
|
// 读时惰性自动归档 (T-ARC-02): 保证刚到期的隐藏任务当场消失
|
|
await runAutoArchiveForProject(project.id)
|
|
|
|
const columns = await loadColumns(project.id)
|
|
const rows = await prisma.task.findMany({
|
|
where: { projectId: project.id, parentId: null, archived: false },
|
|
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) }
|
|
})
|
|
|
|
// ---------- 归档 (T-ARC) ----------
|
|
|
|
// POST /api/tasks/:id/archive 手动归档: 连带全部子任务, 幂等
|
|
router.post('/tasks/:id/archive', 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 } = found
|
|
const children = await prisma.task.findMany({ where: { parentId: task.id }, select: { id: true } })
|
|
const r = await prisma.task.updateMany({
|
|
where: { id: { in: [task.id, ...children.map((c) => c.id)] } },
|
|
data: { archived: true, archivedAt: new Date(), archiveSource: 'manual' },
|
|
})
|
|
res.json({ ok: true, count: r.count })
|
|
} catch (e) { next(e) }
|
|
})
|
|
|
|
// POST /api/tasks/:id/unarchive 恢复: 回原列, 状态不变, 连带子任务
|
|
router.post('/tasks/:id/unarchive', 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 } = found
|
|
const children = await prisma.task.findMany({ where: { parentId: task.id }, select: { id: true } })
|
|
const r = await prisma.task.updateMany({
|
|
where: { id: { in: [task.id, ...children.map((c) => c.id)] } },
|
|
data: { archived: false, archivedAt: null, archiveSource: null },
|
|
})
|
|
res.json({ ok: true, count: r.count })
|
|
} catch (e) { next(e) }
|
|
})
|
|
|
|
// GET /api/projects/:id/tasks/archived 已归档查询: 过滤 + 游标分页 (T-ARC-04)
|
|
router.get('/projects/:id/tasks/archived', 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 q = req.query || {}
|
|
const where = { projectId: project.id, archived: true, parentId: null }
|
|
if (q.keyword) where.title = { contains: String(q.keyword).trim() }
|
|
if (q.kind) where.kind = String(q.kind)
|
|
if (q.columnId && Number.isFinite(Number(q.columnId))) where.columnId = Number(q.columnId)
|
|
if (q.from && DATE_RE.test(q.from)) where.archivedAt = { ...(where.archivedAt || {}), gte: new Date(q.from + 'T00:00:00+08:00') }
|
|
if (q.to && DATE_RE.test(q.to)) where.archivedAt = { ...(where.archivedAt || {}), lte: new Date(q.to + 'T23:59:59.999+08:00') }
|
|
|
|
const limit = Math.min(100, Math.max(1, Number(q.limit) || 20))
|
|
if (q.cursor) {
|
|
const [ts, id] = String(q.cursor).split('|')
|
|
const t = new Date(ts)
|
|
if (Number.isNaN(t.getTime()) || !Number.isFinite(Number(id))) {
|
|
return res.status(400).json({ error: 'cursor 无效' })
|
|
}
|
|
where.OR = [
|
|
{ archivedAt: { lt: t } },
|
|
{ archivedAt: t, id: { lt: Number(id) } },
|
|
]
|
|
}
|
|
|
|
const rows = await prisma.task.findMany({
|
|
where,
|
|
include: TASK_INCLUDE,
|
|
orderBy: [{ archivedAt: 'desc' }, { id: 'desc' }],
|
|
take: limit + 1,
|
|
})
|
|
const hasMore = rows.length > limit
|
|
const page = rows.slice(0, limit)
|
|
const last = page[page.length - 1]
|
|
const nextCursor = hasMore && last ? `${last.archivedAt.toISOString()}|${last.id}` : null
|
|
res.json({ items: page.map(shape), nextCursor })
|
|
} 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, markDate } = req.body || {}
|
|
const cleanTitle = String(title || '').trim()
|
|
if (!cleanTitle) return res.status(400).json({ error: '任务标题不能为空' })
|
|
if (cleanTitle.length > 30) return res.status(400).json({ error: '任务标题不能超过 30 字' })
|
|
const mark = parseMarkDate(markDate)
|
|
|
|
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 taskData = {
|
|
uuid: req.body?.uuid || genUuid(),
|
|
projectId: project.id,
|
|
columnId: firstCol.id,
|
|
parentId: pid,
|
|
title: cleanTitle,
|
|
description: String(description || ''),
|
|
kind: kind ? String(kind).trim().slice(0, 20) : null,
|
|
markDate: mark,
|
|
status: firstCol.boundStatus || 'todo',
|
|
assigneeId: finalAssignee,
|
|
creatorId: userId,
|
|
extras: [],
|
|
members: {
|
|
create: withUuid((Array.isArray(memberIds) ? memberIds : [])
|
|
.map(Number).filter((id) => memberSet.has(id))
|
|
.map((uid) => ({ userId: uid }))),
|
|
},
|
|
tags: { create: withUuid((Array.isArray(tags) ? tags : []).filter(Boolean).map((n) => ({ name: String(n).slice(0, 20) }))) },
|
|
}
|
|
// 项目内自增编号 (展示 `code-number`): 服务端分配, 并发冲突时重取 max+1 重试
|
|
const { value: row } = await withTaskNumber(project.id, (number) =>
|
|
prisma.task.create({ data: { ...taskData, number }, 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) }
|
|
})
|
|
|
|
// 时间标记: 接受 YYYY-MM-DD (或完整 ISO), 存 DateTime; 非法输入 400 由调用方处理
|
|
function parseMarkDate(v) {
|
|
if (v === null || v === undefined || v === '') return null
|
|
const d = new Date(v)
|
|
if (Number.isNaN(d.getTime())) return undefined // 非法
|
|
return d
|
|
}
|
|
|
|
// 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 > 30) return res.status(400).json({ error: '任务标题不能超过 30 字' })
|
|
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.markDate !== undefined) {
|
|
const mark = parseMarkDate(body.markDate)
|
|
if (mark === undefined) return res.status(400).json({ error: '时间标记格式非法' })
|
|
data.markDate = mark
|
|
}
|
|
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: withUuid(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: withUuid(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
|