用户问:「任务是带有子任务功能的,能否通过 caldav,把任务的子任务也同步给客户端?」
## 答案:能,但实测发现父子被分到了不同本
改前数据:
父 #12「轻舟的幼儿园发票」 category_id = 1(家庭事务)→ tasks-1.ics
子 #29「送财务报销」 category_id = NULL(收件箱) → tasks-inbox.ics
客户端渲染「家庭事务」时看不到子任务,渲染「收件箱」时又找不到父任务,
⇒ 不可能显示成层级,只能两条并列。
## 改法
### 1. 子任务跟随父分类(TaskDav::effectiveCat)
有效分类 = 自己的 category_id
↓ 为空且是子任务时
父任务的 category_id
⚠️ **不改数据库**,只在视图层算。
改库等于篡改用户数据,而且单向不可逆。
边界:
| 情况 | 有效分类 |
|---|---|
| 自己无分类 + 父有分类 | 跟父(父子同本) |
| 自己无分类 + 父也无分类 | 都进收件箱(父子同本) |
| 自己有分类 | 用自己的(尊重用户明确设定) |
`tasksIn()` 改为 SQL 联查 `p.category_id`(一次到位,避免 N+1),
再在内存里按有效分类过滤。
### 2. SUMMARY 去掉硬加的「└ 」前缀
原来给子任务 SUMMARY 加「└ 」。这会**污染用户数据** ——
客户端把标题写回来(PUT)时前缀被存进库,用户的标题就多了一坨。
层级改由标准属性表达:
- `RELATED-TO:task-12` —— RFC 5545 §3.8.4.5 标准父子
- `X-FAMILYBOOK-PARENT:轻舟的幼儿园发票` —— 展示用补充,不支持的客户端无视
### 3. 跨本时的降级提示
用户明确给子任务设了别的分类时(尊重其选择),父子仍会跨本。
那种情况在 DESCRIPTION 尾部补一句人话说明,避免客户端把
「有 RELATED-TO 但找不到父」当成数据损坏而不显示这条。
## 验证
改后分布:
| 本 | 总数 | 父 | 子 |
|---|---|---|---|
| 家庭事务 | 7 | 6 | 1(#29) |
| 工作相关 | 19 | 19 | 0 |
| 个人事务 | 2 | 2 | 0 |
| 党支部工作 | 1 | 1 | 0 |
| 轻舟相关 | 1 | 1 | 0 |
| 收件箱 | 2 | 2 | 0 |
- 总数仍 32 条,没丢没重
- **所有父子都在同一本里**
- HTTP 端到端 39 项全过(含跨本隔离、鉴权、日历本回归)
## 客户端支持度说明(服务端无法改变)
| 客户端 | 层级显示 |
|---|---|
| DAVx⁵ + Tasks.org | 认 RELATED-TO,显示缩进子项 |
| 苹果提醒事项 | 有原生子任务 UI,对 RELATED-TO 支持有限 |
| Thunderbird | 不显示层级,两条并列 |
数据是正确的,显示成什么样取决于客户端能力。
想在所有客户端都看到层级,只能靠前端自己渲染 —— 那是网页端的事。
500 lines
19 KiB
PHP
500 lines
19 KiB
PHP
<?php
|
||
/**
|
||
* TaskDAV —— 待办(VTODO)走的 CalDAV 扩展层(多本模型)。
|
||
*
|
||
* 2026-11-02 新增。用户原话:
|
||
* 「网页端的 caldav 功能,除了能够同步日程外,增加可以同步任务(待办)的功能」
|
||
*
|
||
* 2026-11-02 重构(第二版):
|
||
* 用户给了参照服务器 nas.yangyq.net/caldav/synccalendartest(DAViCal 1.1.9),
|
||
* 并指出「应该可以直接显示那个家庭的待办分类」。
|
||
* 参照实测:待办按分类拆成多个独立 calendar 集合,各自 supported-calendar-component-set
|
||
* 只含 VTODO。客户端里显示成多个并列的待办列表。
|
||
* ⇒ 本版从「一个家庭待办本」改为「每个 task_categories 一行 = 一个 VTODO 本」,
|
||
* 外加一个 Inbox 本收未分类任务(与参照的 home_todo 同构)。
|
||
*
|
||
* ## 本的组织(用户已定)
|
||
*
|
||
* /dav/tasks-<categoryId>.ics/ 每个分类一本,名字就是分类名
|
||
* /dav/tasks-inbox.ics/ 未分类(category_id 为空)的任务
|
||
*
|
||
* ⚠️ 为什么用 `tasks-<id>` 而不是裸 id:
|
||
* 真实日历本是 `/dav/<id>.ics/`,用裸 id 会与它们撞号
|
||
* (calendar_events 的 id 与 task_categories 的 id 是两套独立序列)。
|
||
* 加 `tasks-` 前缀后永远不会冲突,也不需要负数那种 trick。
|
||
*
|
||
* ## 权限口径
|
||
*
|
||
* 与网页端「任务」模块完全一致(User 可见 = Task::visibleTo)。
|
||
* 不在 DAV 层另写一套权限判断,否则"网页上不能改的,手机能改"会出问题。
|
||
*
|
||
* ## UID 方案
|
||
*
|
||
* `task-<id>`。前缀 `task-` 与日历的 UID 空间隔离(日历 UID 可能是
|
||
* `syncal-...`、`movie-100_familybook`,或客户端自造的任意串)。
|
||
*/
|
||
declare(strict_types=1);
|
||
|
||
namespace App;
|
||
|
||
final class TaskDav
|
||
{
|
||
/**
|
||
* 本 href 前缀:`/dav/tasks-<id>.ics/`。
|
||
*
|
||
* Inbox 本用 `tasks-inbox.ics`(id 段是字符串 `inbox` 而不是数字)。
|
||
*/
|
||
public const HREF_PREFIX = '/dav/tasks-';
|
||
|
||
/** Inbox(未分类)本的 id 段 */
|
||
public const INBOX_SEG = 'inbox';
|
||
|
||
/** Inbox 本的显示名(与参照服务器的 home_todo 一个意思) */
|
||
public const INBOX_NAME = '收件箱';
|
||
|
||
/** 资源名前缀(与日历 UID 空间隔离) */
|
||
public const UID_PREFIX = 'task-';
|
||
|
||
/** ctag 版本,改了导出格式就 +1,让客户端重拉 */
|
||
private const CTAG_VERSION = 2;
|
||
|
||
// ============================================================
|
||
// 本的识别
|
||
// ============================================================
|
||
|
||
/**
|
||
* 列出「这个人能看到的待办本」。
|
||
*
|
||
* @return array<int,array{seg:string, id:int, name:string, href:string, color:string}>
|
||
* id 为 -1 表示 Inbox
|
||
*/
|
||
public static function books(int $familyId, int $personId): array
|
||
{
|
||
$out = [];
|
||
|
||
// ---- 各分类本 ----
|
||
/* ⚠️ 不直接用 Task::categories():它带 used_count 子查询,
|
||
对 DAV 每次发现请求都算一遍是浪费。这里只要 id/name/position。 */
|
||
$st = Database::get()->prepare(
|
||
'SELECT id, name FROM task_categories WHERE family_id = ? ORDER BY position, id'
|
||
);
|
||
$st->execute([$familyId]);
|
||
foreach ($st->fetchAll() as $c) {
|
||
$cid = (int)$c['id'];
|
||
$out[] = [
|
||
'seg' => (string)$cid,
|
||
'id' => $cid,
|
||
'name' => (string)$c['name'],
|
||
'href' => self::HREF_PREFIX . $cid . '.ics/',
|
||
'color' => self::colorOf($cid, (string)$c['name']),
|
||
];
|
||
}
|
||
|
||
// ---- Inbox(未分类)----
|
||
/* ⚠️ 即使用户当前没有未分类任务,也要列出来 ——
|
||
否则客户端建不出"没选分类"的待办,且新任务会无处可去。
|
||
参照服务器也是恒定有 home_todo/Inbox 的。 */
|
||
$out[] = [
|
||
'seg' => self::INBOX_SEG,
|
||
'id' => -1,
|
||
'name' => self::INBOX_NAME,
|
||
'href' => self::HREF_PREFIX . self::INBOX_SEG . '.ics/',
|
||
'color' => '#8A8A8A',
|
||
];
|
||
|
||
return $out;
|
||
}
|
||
|
||
/**
|
||
* 一条任务的**有效分类**(决定它出现在哪个本里)。
|
||
*
|
||
* 2026-11-02 新增。用户问「任务的子任务能否同步给客户端」,
|
||
* 实测发现父子跨本(父分类=1,子分类=NULL)⇒ 客户端无法显示层级。
|
||
* 用户已定:**子任务跟随父的分类**。
|
||
*
|
||
* 规则:
|
||
* · 自己有分类 → 用自己的(尊重用户明确设定)
|
||
* · 自己无分类 + 有父 → 跟父的分类(保证父子同本,层级才可显示)
|
||
* · 都没有 → 收件箱(-1)
|
||
*
|
||
* ⚠️ **不改数据库**:这是视图层算出来的。改库就等于篡改用户数据,
|
||
* 而且单向不可逆(以后想让子任务独立分类就回不去了)。
|
||
*
|
||
* @param array $row tasks 行(可用 `parent_cat_id` 预联查好的父分类)
|
||
*/
|
||
public static function effectiveCat(array $row): int
|
||
{
|
||
$own = (int)($row['category_id'] ?? 0);
|
||
if ($own > 0) {
|
||
return $own;
|
||
}
|
||
$pid = (int)($row['parent_id'] ?? 0);
|
||
if ($pid > 0) {
|
||
// 优先用预联查的父分类(避免 N+1)
|
||
if (array_key_exists('parent_cat_id', $row)) {
|
||
$pc = (int)($row['parent_cat_id'] ?? 0);
|
||
return $pc > 0 ? $pc : -1;
|
||
}
|
||
$st = Database::get()->prepare(
|
||
'SELECT category_id FROM tasks WHERE id = ? LIMIT 1'
|
||
);
|
||
$st->execute([$pid]);
|
||
$v = $st->fetchColumn();
|
||
$pc = $v === false || $v === null ? 0 : (int)$v;
|
||
return $pc > 0 ? $pc : -1;
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
/**
|
||
* 从一个 href 或路径解析出待办本的段(`<id>` 或 `inbox`)。
|
||
*
|
||
* @return array{seg:string, sub:string}|null
|
||
* seg = 本段(如 `3` 或 `inbox`),sub = 资源段(如 `task-12`),
|
||
* 不是待办本则返回 null。
|
||
*/
|
||
public static function parse(string $href): ?array
|
||
{
|
||
$p = rtrim((string)parse_url($href, PHP_URL_PATH), '/');
|
||
$prefix = rtrim(self::HREF_PREFIX, '/'); // /dav/tasks-
|
||
if (strpos($p, $prefix) !== 0) {
|
||
return null;
|
||
}
|
||
$rest = substr($p, strlen($prefix)); // 如 `3.ics` 或 `3.ics/task-12.ics`
|
||
if ($rest === '') {
|
||
return null;
|
||
}
|
||
$parts = explode('/', $rest);
|
||
$bookSeg = (string)preg_replace('/\.ics$/', '', $parts[0]);
|
||
if ($bookSeg === '') {
|
||
return null;
|
||
}
|
||
$sub = '';
|
||
if (isset($parts[1]) && $parts[1] !== '') {
|
||
$sub = (string)preg_replace('/\.ics$/', '', $parts[1]);
|
||
}
|
||
return ['seg' => $bookSeg, 'sub' => $sub];
|
||
}
|
||
|
||
/** 这个路径是不是待办本(含子资源) */
|
||
public static function isTaskPath(string $href): bool
|
||
{
|
||
return self::parse($href) !== null;
|
||
}
|
||
|
||
/** 段 → 分类 id(Inbox 返回 -1;认不出返回 -2 表示非法) */
|
||
public static function categoryIdOfSeg(string $seg): int
|
||
{
|
||
if ($seg === self::INBOX_SEG) {
|
||
return -1;
|
||
}
|
||
if (preg_match('/^\d+$/', $seg) === 1) {
|
||
return (int)$seg;
|
||
}
|
||
return -2;
|
||
}
|
||
|
||
/**
|
||
* 校验这个段确实是**这个人能访问的**待办本。
|
||
*
|
||
* ⚠️ 必须校验:否则改一下 URL(`/dav/tasks-999.ics/`)就能探到
|
||
* 别的家庭的分类名,属于越权。
|
||
*/
|
||
public static function resolveBook(int $familyId, int $personId, string $seg): ?array
|
||
{
|
||
foreach (self::books($familyId, $personId) as $b) {
|
||
if ($b['seg'] === $seg) {
|
||
return $b;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** 本名配色(稳定:同一个分类每次都是同一色) */
|
||
private static function colorOf(int $id, string $name): string
|
||
{
|
||
$palette = ['#B0763A', '#7A4E8C', '#3A7AB0', '#4E8C5A', '#B05A5A', '#8C7A4E', '#5A6B8C'];
|
||
return $palette[$id % count($palette)];
|
||
}
|
||
|
||
// ============================================================
|
||
// 资源名
|
||
// ============================================================
|
||
|
||
/**
|
||
* 一条 tasks 行 → 资源名(href 最后一段,不含 .ics)。
|
||
*
|
||
* ⚠️ 与 CalDav::segmentOf() 同口径:一律用 UID。
|
||
* 客户端 PUT 到 `/dav/tasks-3.ics/task-5.ics` 后必须能按同名 GET 回来,
|
||
* 否则会表现成"写进去了又没了"(CalDav 那边踩过)。
|
||
*/
|
||
public static function segmentOf(array $t): string
|
||
{
|
||
return self::UID_PREFIX . (int)($t['id'] ?? 0);
|
||
}
|
||
|
||
/** 从资源段反解任务 id(`task-5` → 5;认不出返回 0) */
|
||
public static function idOfSegment(string $seg): int
|
||
{
|
||
$seg = trim($seg);
|
||
if ($seg === '') {
|
||
return 0;
|
||
}
|
||
if (preg_match('/^' . preg_quote(self::UID_PREFIX, '/') . '(\d+)/', $seg, $m)) {
|
||
return (int)$m[1];
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
// ============================================================
|
||
// 读取
|
||
// ============================================================
|
||
|
||
/**
|
||
* 某一本里的任务。
|
||
*
|
||
* @param int $catId 分类 id;-1 表示 Inbox(未分类)
|
||
*/
|
||
public static function tasksIn(int $familyId, int $personId, int $catId): array
|
||
{
|
||
/* ⚠️ 不能再用 SQL 直接按 `t.category_id` 过滤:
|
||
子任务要**跟随父的分类**(见 effectiveCat),
|
||
父分类在另一张行上,必须联查出来再在内存里判。
|
||
`parent_cat_id` 一次联查解决,避免逐条查父任务(N+1)。 */
|
||
$sql = 'SELECT t.*, b.name AS board_name, c.name AS cat_name,
|
||
p.category_id AS parent_cat_id,
|
||
pc.name AS parent_cat_name,
|
||
p.title AS parent_title
|
||
FROM tasks t
|
||
LEFT JOIN task_boards b ON b.id = t.board_id
|
||
LEFT JOIN task_categories c ON c.id = t.category_id
|
||
LEFT JOIN tasks p ON p.id = t.parent_id
|
||
LEFT JOIN task_categories pc ON pc.id = p.category_id
|
||
WHERE t.family_id = ?
|
||
ORDER BY t.status <> \'done\', t.due_at IS NULL, t.due_at, t.position, t.id';
|
||
|
||
$st = Database::get()->prepare($sql);
|
||
$st->execute([$familyId]);
|
||
|
||
$out = [];
|
||
foreach ($st->fetchAll() as $r) {
|
||
// 先按"有效分类"判它属不属于这一本
|
||
if (self::effectiveCat($r) !== $catId) {
|
||
continue;
|
||
}
|
||
if (!Task::visibleTo($r, $personId)) {
|
||
continue;
|
||
}
|
||
$r['uid'] = self::UID_PREFIX . (int)$r['id'];
|
||
/* ⚠️ 跨本父任务的提示:子任务跟自己的分类、而父在别的本时,
|
||
客户端会看到一条"有 RELATED-TO 但找不到那个父"的条目。
|
||
多给一个说明性字段,导出时写进 DESCRIPTION 尾部,
|
||
让用户至少知道"它的父在别的分类里",而不是以为数据坏了。 */
|
||
$r['parent_in_other_book'] = self::isParentInOtherBook($r);
|
||
$out[] = $r;
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
/**
|
||
* 这条任务的父是否在**别的本**里(跨本父子)。
|
||
*
|
||
* 用于导出时加一句提示。只在"子任务自己有分类、父分类不同"时成立 ——
|
||
* 那种情况是用户明确给子任务设了别的分类,我们尊重它,但要让客户端可见。
|
||
*/
|
||
private static function isParentInOtherBook(array $r): bool
|
||
{
|
||
$pid = (int)($r['parent_id'] ?? 0);
|
||
if ($pid <= 0) {
|
||
return false;
|
||
}
|
||
$own = (int)($r['category_id'] ?? 0);
|
||
if ($own <= 0) {
|
||
return false; // 自己没分类 → 已跟随父,必定同本
|
||
}
|
||
$pc = array_key_exists('parent_cat_id', $r)
|
||
? (int)($r['parent_cat_id'] ?? 0)
|
||
: 0;
|
||
$pcNorm = $pc > 0 ? $pc : -1;
|
||
return $pcNorm !== $own;
|
||
}
|
||
|
||
/** 单条任务(含权限)。取不到或无权看 → null。 */
|
||
public static function one(int $familyId, int $personId, int $taskId): ?array
|
||
{
|
||
if ($taskId <= 0) {
|
||
return null;
|
||
}
|
||
$st = Database::get()->prepare(
|
||
'SELECT t.*, b.name AS board_name, c.name AS cat_name
|
||
FROM tasks t
|
||
LEFT JOIN task_boards b ON b.id = t.board_id
|
||
LEFT JOIN task_categories c ON c.id = t.category_id
|
||
WHERE t.id = ? AND t.family_id = ?'
|
||
);
|
||
$st->execute([$taskId, $familyId]);
|
||
$r = $st->fetch();
|
||
if (!$r) {
|
||
return null;
|
||
}
|
||
if (!Task::visibleTo($r, $personId)) {
|
||
return null;
|
||
}
|
||
$r['uid'] = self::UID_PREFIX . (int)$r['id'];
|
||
return $r;
|
||
}
|
||
|
||
// ============================================================
|
||
// 导出
|
||
// ============================================================
|
||
|
||
/**
|
||
* 一条任务 → 资源(ics 正文 + ETag)。
|
||
*
|
||
* ⚠️ ETag 由内容哈希算出,不用"当前时间"。
|
||
* CalDav 那边为 ETag 潮汐踩过坑(客户端每轮全量重拉),这里从一开始就用正确做法。
|
||
*/
|
||
public static function resourceOf(array $t, string $calName = '待办'): array
|
||
{
|
||
$ics = Ics::exportTodos([$t], $calName);
|
||
return ['ics' => $ics, 'etag' => '"' . md5($ics) . '"'];
|
||
}
|
||
|
||
/** 整本导出 */
|
||
public static function ics(int $familyId, int $personId, int $catId, string $calName): string
|
||
{
|
||
return Ics::exportTodos(self::tasksIn($familyId, $personId, $catId), $calName);
|
||
}
|
||
|
||
/**
|
||
* ctag:这一本"变没变"的指纹。
|
||
*
|
||
* ⚠️ 必须覆盖任何会影响导出的变化,且不能随时间变。
|
||
* 用 COUNT + MAX(updated_at) + SUM(done_at) 三个一起,
|
||
* 单用 MAX(updated_at) 在同一秒改两条时会漏。
|
||
*/
|
||
public static function ctag(int $familyId, int $personId, int $catId): string
|
||
{
|
||
$sql = 'SELECT COUNT(*) AS n,
|
||
COALESCE(MAX(updated_at), \'\') AS mx,
|
||
COALESCE(SUM(UNIX_TIMESTAMP(COALESCE(done_at, \'1970-01-01\'))), 0) AS dn
|
||
FROM tasks WHERE family_id = ?';
|
||
$params = [$familyId];
|
||
if ($catId === -1) {
|
||
$sql .= ' AND category_id IS NULL';
|
||
} else {
|
||
$sql .= ' AND category_id = ?';
|
||
$params[] = $catId;
|
||
}
|
||
$st = Database::get()->prepare($sql);
|
||
$st->execute($params);
|
||
$r = $st->fetch() ?: [];
|
||
|
||
$seed = self::CTAG_VERSION . '|' . $catId . '|'
|
||
. (int)($r['n'] ?? 0) . '|'
|
||
. (string)($r['mx'] ?? '') . '|'
|
||
. (int)($r['dn'] ?? 0);
|
||
return '"' . md5($seed) . '"';
|
||
}
|
||
|
||
// ============================================================
|
||
// 写入
|
||
// ============================================================
|
||
|
||
/**
|
||
* 客户端 PUT 一条待办 → 落库。
|
||
*
|
||
* @param int $catId 本对应的分类 id(-1 = Inbox)
|
||
* @param int $taskId URL 段解析出的 id(0 = 新建)
|
||
* @param string $body text/calendar 正文
|
||
*
|
||
* @return array{id:int, created:bool}
|
||
* @throws \RuntimeException
|
||
*/
|
||
public static function put(int $familyId, int $personId, int $catId,
|
||
int $taskId, string $body): array
|
||
{
|
||
$parsed = Ics::parseTodos($body);
|
||
if (!$parsed) {
|
||
throw new \RuntimeException('正文里没有找到 VTODO。');
|
||
}
|
||
$n = Ics::normalizeTodo($parsed[0]);
|
||
|
||
if ($n['summary'] === '') {
|
||
throw new \RuntimeException('VTODO 缺少 SUMMARY(任务标题),拒绝写入。');
|
||
}
|
||
|
||
/* 只有"确实存在且有权访问"时才走更新分支。
|
||
否则(客户端 PUT 到 task-99999.ics 而它不存在)必须**新建** ——
|
||
RFC 4918 §9.7 规定 PUT 到不存在的 URI 就是创建资源。
|
||
这也兼容"客户端用自己编的 UID 当资源名"的场景。 */
|
||
$old = $taskId > 0 ? self::one($familyId, $personId, $taskId) : null;
|
||
|
||
if ($old !== null) {
|
||
if (!Task::canWrite($old, $personId)) {
|
||
throw new \RuntimeException('这条任务你没有写权限。');
|
||
}
|
||
/* ⚠️ Task::update() 会把没传的字段清空(它按 `$d['x'] ?? ''` 取值),
|
||
所以必须把原值补全再传 —— 否则客户端只改标题就会把备注/截止时间清掉。
|
||
这是"部分更新"最容易出的 bug。 */
|
||
Task::update($familyId, $taskId, [
|
||
'title' => $n['summary'],
|
||
'note' => $n['description'],
|
||
'status' => $n['status'],
|
||
'due_at' => $n['due'] ?? '',
|
||
'assignee_id' => (int)($old['assignee_id'] ?? 0),
|
||
'repeat_rule' => (string)($old['repeat_rule'] ?? ''),
|
||
'parent_id' => (int)($old['parent_id'] ?? 0),
|
||
'board_id' => (int)($old['board_id'] ?? 0),
|
||
// 分类跟随**本**:客户端把资源放进了哪一本,就归哪个分类
|
||
'category_id' => $catId === -1 ? 0 : $catId,
|
||
]);
|
||
self::fixDoneAt($familyId, $taskId, $n);
|
||
return ['id' => $taskId, 'created' => false];
|
||
}
|
||
|
||
// ---- 新建 ----
|
||
$id = Task::create($familyId, $personId, [
|
||
'title' => $n['summary'],
|
||
'note' => $n['description'],
|
||
'status' => $n['status'],
|
||
'due_at' => $n['due'] ?? '',
|
||
'category_id' => $catId === -1 ? 0 : $catId,
|
||
]);
|
||
if ($id <= 0) {
|
||
throw new \RuntimeException('创建任务失败。');
|
||
}
|
||
self::fixDoneAt($familyId, $id, $n);
|
||
return ['id' => $id, 'created' => true];
|
||
}
|
||
|
||
/**
|
||
* 用客户端给的 COMPLETED 时间覆盖 done_at。
|
||
*
|
||
* `Task::update()` 走到 done 时会自动 markDone(用"服务器当前时间"),
|
||
* 但客户端给的 COMPLETED 更准(它可能补录昨天完成的事)。
|
||
*/
|
||
private static function fixDoneAt(int $familyId, int $taskId, array $n): void
|
||
{
|
||
if ($n['status'] !== Task::DONE_STATUS || empty($n['completed'])) {
|
||
return;
|
||
}
|
||
Database::get()->prepare(
|
||
'UPDATE tasks SET done_at = ? WHERE id = ? AND family_id = ?'
|
||
)->execute([$n['completed'], $taskId, $familyId]);
|
||
}
|
||
|
||
/** 客户端 DELETE 一条待办 */
|
||
public static function delete(int $familyId, int $personId, int $taskId): bool
|
||
{
|
||
$t = self::one($familyId, $personId, $taskId);
|
||
if (!$t) {
|
||
return false;
|
||
}
|
||
if (!Task::canWrite($t, $personId)) {
|
||
return false;
|
||
}
|
||
Task::destroy($familyId, $taskId);
|
||
return true;
|
||
}
|
||
} |