feat(V0.2): 任务模块 —— 项目 / 列表与看板 / 列-状态绑定 / 一级子任务
Docker Build and Push / build-image (push) Failing after 3m51s

需求 142 条 (T-*), 设计见 docs/V0.2/任务模块/

数据层:
- 新增 6 张表: Project / ProjectMember / BoardColumn / Task / TaskMember / TaskTag
- 看板列是实体(可增删改排序), Task.columnId 外键 RESTRICT 防误删列丢任务
- Task.status(todo/doing/done) 与 columnId 双落库, 二者正交

领域层 server/task-domain/ (唯一口径, 前后端不重复实现):
- resolveStatus: 列决定状态(只看目标列, 无拖出回退); 状态与列不匹配时按列序取第一个绑定该状态的列自动归位, 无匹配列则不移动; 子任务完全豁免
- retention: 逐列 6 档保留窗口(永不显示/当天/3天/本周/本月/永久显示), 以 doneAt 判定
- copyTask: 跨项目深拷贝, 人员按目标项目成员过滤并回报丢弃名单

接口: /api/projects, /api/columns, /api/tasks (均校验项目成员)

前端:
- 底部 Tab 插入「任务」, 位于「打卡」之后; 默认首页仍为打卡
- 列表视图(子任务缩进+折叠) / 看板视图(长按拖拽换列、拖到卡片降为子任务)
- 列设置(列管理+状态绑定+保留选项) / 任务详情(扩展信息卡片默认收起) / 成员管理
- 视图记忆与上次项目存设备端

已验证: 95 文件语法 0 错误、相对导入 0 缺失、领域逻辑用例全部通过
This commit is contained in:
jif2.zhang
2026-10-24 11:21:16 +08:00
parent 42acf72f1e
commit 6c9cd3fd99
36 changed files with 6194 additions and 0 deletions
+328
View File
@@ -0,0 +1,328 @@
// 任务模块: 项目 / 成员 / 看板列
import { Router } from 'express'
import { prisma } from '../db.js'
import { authRequired } from '../middleware.js'
import { loadProjectForUser, loadColumns, loadMemberIds } from '../task-domain/access.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: 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, members: { create: { userId } } },
})
await createDefaultColumns(project.id)
return project
}
function projectSummary(p) {
return {
id: p.id,
name: p.name,
ownerId: p.ownerId,
isPersonal: p.isPersonal,
archived: p.archived,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
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: true } } },
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} 字` })
const project = await prisma.project.create({
data: { name, ownerId: userId, isPersonal: false, members: { create: { userId } } },
})
await createDefaultColumns(project.id)
const full = await prisma.project.findUnique({
where: { id: project.id },
include: { _count: { select: { members: true, tasks: true } } },
})
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,
},
})
} catch (e) { next(e) }
})
// PUT /api/projects/:id { name?, archived? }
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 } = 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
}
if (archived !== undefined) data.archived = !!archived
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: { 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
+284
View File
@@ -0,0 +1,284 @@
// 任务模块: 任务 / 子任务 / 跨项目拷贝
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 = [] } = 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 || ''),
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) }
})
// ---------- 单条任务 ----------
// 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)
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