Plan 06 — studio-api Migration Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended,逐 task fresh subagent) or superpowers:executing-plans. 这是 Phase 1 最大 caller migration plan(9 task);建议 subagent 模式,每 task 完成后 user review SQL 写法 + transaction wrap 是否到位再进下一 task。

Goal: 把 studio-api 6 个 task endpoint(close / reopen / postpone + 新 progress)和 3 个读路径(contacts list / leads KPI / dashboard attempt workload)收口到 @retaintive/common/domainbuildTaskActionSQL() / buildCloseAllOpenSQL() raw-SQL adapter。Phase 1 ship 后,studio-api 写 task 不再自拼 SQL,所有 mutation 走统一 state machine + Policy Guard。

Architecture: studio-api 是 Hono on AWS Lambda,数据访问层用 @neondatabase/serverlessneon-http driver(raw SQL tagged-template / sql.query() / sql.transaction(),不用 Drizzle)。所以本 plan 用 Plan 02 Task 6 交付的 buildTaskActionSQL() adapter,它返回 { sql: string; params: unknown[] }[],caller 必须用 sql.transaction(statements.map(s => sql(s.sql, s.params))) 包裹(B2 invariant)。

Tech Stack: TypeScript 5 / Hono 4 / @neondatabase/serverless neon-http / Zod 3 / Vitest 4 / AWS Lambda(Node 20)

Spec source: docs/product-design/v2/unified-pipeline/implementation-plan/normative-spec.md §4a-4i + §5.0 invariants + §5.2 integration tests

Dependency:

  • Plan 01 merged(@retaintive/common@1.1.0 schema delta:task_progress_events 表 + tasks 新字段)
  • Plan 02 merged(@retaintive/common@1.2.0./domain export:buildTaskActionSQL / buildCloseAllOpenSQL)
  • 测试 Neon 已 apply Plan 01 migration SQL(否则 WHERE status IN ('pending','open') 跑得通但 task_progress_events 表不存在)

关键 invariant 复读(每 task 都要记):

  • B2 — sql.transaction(statements.map(s => sql(s.sql, s.params))) 必须包裹 buildTaskActionSQL() 返回的 statements;禁止 for (const stmt of statements) await sql.query(stmt.sql, stmt.params)(后者非原子,timeline 写一半失败 = 数据不一致)
  • 过渡期 status 字符串用 IN ('pending', 'open')(Plan 01 transitional schema 期间;Plan 07 final cutover 后 sweep 成 status = 'open')
  • Phase 1 不删 contacts.action_needed 字段,只改读路径(SELECT c.action_needed 暂留,Phase 2 才 DROP)
  • B3 dashboard attempt workload 改算 task_progress_events,不算 close_result = 'attempted'

File Structure

FileResponsibilityAction
apps/api/package.jsonbump @retaintive/common peer 1.0.x → 1.2.0Modify
apps/api/src/lib/check-silent-reject.ts共用 resultChecks 处理 helper(close / reopen / postpone / progress 调用),spec §3.1 line 251Create
apps/api/src/routes/tasks/close.ts改用 buildTaskActionSQL({ action: 'close' }) + sql.transaction() 包裹 + checkSilentReject() + 拒绝 progress closeResult + 删 FOLLOW_UP_TASK_TYPE 常量Modify
apps/api/src/routes/tasks/reopen.ts改用 buildTaskActionSQL({ action: 'reopen' }) + sql.transaction()Modify
apps/api/src/routes/tasks/postpone.ts改用 buildTaskActionSQL({ action: 'update' }) + sql.transaction()Modify
apps/api/src/routes/tasks/progress.ts新建 POST /v2/tasks/:taskId/progress endpointCreate
apps/api/src/routes/tasks/index.ts注册 progressRouteModify
apps/api/src/routes/tasks/types.tstask_type: string + action_needed: boolean / action_needed_reason fields from TaskRowModify
apps/api/src/routes/tasks/list.tssweep status = 'pending'status IN ('pending', 'open')(8 处);删 t.task_type / t.action_needed / t.action_needed_reason SELECTModify
apps/api/src/routes/tasks/events.tssweep status = 'pending'(如有)→ IN ('pending','open')Modify
apps/api/src/routes/v3/contacts.ts加 EXISTS 子查询 has_open_task;c.action_needed SELECT 保留(Phase 2 删)Modify
apps/api/src/routes/v3/leads.tsKPI action_needed 改 EXISTS 子查询Modify
apps/api/src/routes/v3/dashboard-multi-store.tstasks_overdue filter sweep status = 'pending'IN ('pending', 'open')Modify
apps/api/src/routes/v3/dashboard-staff.ts(及其他 dashboard-*.ts)B3 Attempt workload metric 改算 task_progress_events(COUNT(*) FROM task_progress_events WHERE store_id=$1 AND occurred_at >= NOW() - INTERVAL '7 days')Modify

测试文件(每个改造的 route 一个):

Test file覆盖
apps/api/src/__tests__/routes/tasks-close.test.tsclose 8 scenarios(happy / already-closed / progress closeResult 拒绝 / closeNote required / stale AI / cross-store / transaction wrapper)
apps/api/src/__tests__/routes/tasks-progress.test.tsprogress 9 scenarios(callId 幂等 / closed task / DNC / callback_requested nextDueAt 必填 / channel enum / B2 transaction wrapper)
apps/api/src/__tests__/routes/tasks-reopen.test.tsreopen 5 scenarios(含 B2 transaction wrapper)
apps/api/src/__tests__/routes/tasks-postpone.test.tspostpone 3 scenarios
apps/api/src/__tests__/routes/v3-contacts-action-needed.test.tsEXISTS 子查询 vs 旧 action_needed 等价 + transitional IN ('pending','open') 覆盖
apps/api/src/__tests__/routes/v3-leads-kpi.test.tsleads KPI action_needed EXISTS 等价
apps/api/src/__tests__/routes/dashboard-attempt-workload.test.tstask_progress_events count + tenant isolation
apps/api/src/__tests__/integration/tasks-mutations.integration.test.ts跑真实 Neon:close / reopen / progress 全 round-trip,verify SQL transaction rollback 行为

Task 1: Bump @retaintive/common 1.0.x → 1.2.0 + verify domain export 可 import

Files:

  • Modify: apps/api/package.json

Why this task first: Task 2-9 全 import @retaintive/common/domainbuildTaskActionSQL。先 bump dep 让后续 task TypeScript 编译过。

Step 1: Map what to verify

#Check为什么
1package.json dependencies / peerDependencies@retaintive/common 版本 ≥ 1.2.0Plan 02 ship 后 1.2.0 含 ./domain export
2import { buildTaskActionSQL } from '@retaintive/common/domain' 编译过adapter 路径正确
3bun installnode_modules/@retaintive/common/dist/domain/index.js 存在dist 真有 domain 子模块

Step 2: Implementation

// apps/api/package.json
{
  "dependencies": {
    "@retaintive/common": "^1.2.0",  // 从 "*" 或 "^1.0.x" bump
    "...": "..."
  }
}

Monorepo 用 workspace 引用的话,workspace:^1.2.0* 视 turbo / bun workspaces 设定 — 本 apps/api 当前是 "*",可能要确认 packages/common 已 publish 1.2.0(本 monorepo 不是直接消费 retaintive/callytics-common,而是 npm consume),具体看 lockfile 当前怎么 resolve。

cd /Users/maxwsy/workspace/studio-website-monorepo && bun install
# Verify dist
ls node_modules/@retaintive/common/dist/domain/  # 应含 index.js + index.d.ts
# Quick smoke
echo 'import { buildTaskActionSQL } from "@retaintive/common/domain"; console.log(typeof buildTaskActionSQL);' > /tmp/smoke.ts
bun run /tmp/smoke.ts  # 期望输出 "function"

Step 3: Test write + run

无独立 test 文件;Step 2 的 smoke import 算 verification。Type check:

cd /Users/maxwsy/workspace/studio-website-monorepo/apps/api && bun run typecheck

Expected: PASS(无 unresolved import error)。

Step 4: Commit

git add apps/api/package.json
git commit -m "chore(api): bump @retaintive/common to 1.2.0 (domain modules)

Plan 06 Task 1 — 引入 buildTaskActionSQL / buildCloseAllOpenSQL adapter
为 Phase 1 close/reopen/postpone/progress 改造做准备。

Spec: normative-spec.md §3.2
"

Task 1.5: Create apps/api/src/lib/check-silent-reject.ts

Why: normative-spec §3.1 line 251 — caller MUST 检查 result.resultChecks,0-row RETURNING = silent business reject(duplicate / stale_proposal / task_not_open / task_not_closed),不能让 transaction "通过" 而真改 0 行。close / reopen / postpone / progress 都要这逻辑 — 抽 helper。Codex audit 2026-06-02 抓的 #1 issue。

Files:

  • Create: apps/api/src/lib/check-silent-reject.ts

Step 1: Implementation

// apps/api/src/lib/check-silent-reject.ts
import type { ApplyResult } from '@retaintive/common/domain';
import { ConflictError, NotFoundError } from '../errors';  // 用 studio-api 现有 error types
import { logger } from '../logger';

interface CheckOpts {
  /** record_progress 的 idempotency_key duplicate 是预期 retry,只 log 不 throw */
  allowDuplicate?: boolean;
}

/**
 * 校验 sql.transaction() 返回的每个 statement result row count,
 * 跟 ApplyResult.resultChecks 比对。0 row = silent business reject。
 *
 * pattern source: normative-spec.md §3.1 line 251 contract
 */
export function checkSilentReject(
  resultChecks: ApplyResult['resultChecks'] | undefined,
  txResults: unknown[],  // neon-http sql.transaction 返回 row arrays
  ctx: { taskId: string; userId: string },
  action: 'close' | 'reopen' | 'postpone' | 'record_progress',
  opts: CheckOpts = {},
): void {
  for (const check of resultChecks ?? []) {
    const stmtResult = txResults[check.statementIndex];
    const rowCount = Array.isArray(stmtResult) ? stmtResult.length : 0;
    if (rowCount === 0) {
      logger.warn(`${action} silent reject — 0 rows`, {
        ...ctx,
        reason: check.zeroRowsReason,
      });
      if (opts.allowDuplicate && check.zeroRowsReason === 'duplicate') {
        continue;  // record_progress retry,预期幂等
      }
      // throw map per zeroRowsReason
      throw makeErrorFor(check.zeroRowsReason, action);
    }
  }
}

function makeErrorFor(reason: string | undefined, action: string): Error {
  switch (reason) {
    case 'task_not_open':
      return new ConflictError(`Task is not open (cannot ${action})`);
    case 'task_not_closed':
      return new ConflictError(`Task is not closed (cannot ${action})`);
    case 'stale_proposal':
      return new ConflictError('Stale request — please refresh and try again');
    case 'duplicate':
      return new ConflictError('Duplicate request');
    default:
      return new ConflictError(`Operation rejected: ${reason ?? 'unknown'}`);
  }
}

Step 2: Commit

cd /Users/maxwsy/workspace/studio-website-monorepo/apps/api
git add src/lib/check-silent-reject.ts
git commit -m "feat(api): shared checkSilentReject helper

Phase 1 Plan 06 Task 1.5(Codex audit 2026-06-02 抓的 #1)。

close / reopen / postpone / record_progress 共用 — 校验 sql.transaction
返回的 row count 跟 ApplyResult.resultChecks 比对,0-row = silent business
reject(spec §3.1 line 251)。

之前每个 route 直接 await sql.transaction(...) 不读 resultChecks,
silent failure:duplicate / stale_proposal / task_not_open 都通过。
"

Task 2: close.ts — buildTaskActionSQL + sql.transaction wrap + 拒绝 progress closeResult

Files:

  • Modify: apps/api/src/routes/tasks/close.ts
  • Create: apps/api/src/__tests__/routes/tasks-close.test.ts

Step 1: Map test scenarios

#ScenarioExpectedWhy
1Staff close open task with closeResult='converted'200 + UPDATE + timeline 通过 sql.transaction() 原子 commitHappy path
2Close already-closed task409 ConflictErrorbuildTaskActionSQL 返回 reject task_not_open;route handler map 409
3Close with closeResult='no_answer'400 + 提示 "Use POST /v2/tasks/:taskId/progress instead"spec §4a:close API 拒绝 progress closeResult,引导用 progress endpoint
4Close with closeResult='left_voicemail' / 'callback_later' / 'attempted'同上 4004 个 progress closeResult 全拒绝
5Close with closeResult='other' 无 closeNote400 ValidationErrorPolicy Guard close_note_required
6AI close with stale aiRunStartedAt(task.updated_at > aiRunStartedAt)409 + reject reason stale_proposal(staff path 不传 aiRunStartedAt 不触发,Phase 1 不暴露给 staff)§3.6 human authority guard via SQL conditional WHERE
7Close taskId 跨 store(taskId 属于另一 storeId)404 NotFoundErrorPolicy Guard hallucinated_task_id
8B2 transaction wrapper — route must call exactly one sql.transaction() with all Orchestrator statements200 + one transaction call; no loop of sql.query()caller 责任是使用现有 neon-http transaction wrapper; rollback behavior 由 DB integration 覆盖

Sample test code style(scenario 1 + 3 + 8 — 给 implementer 参考):

// apps/api/src/__tests__/routes/tasks-close.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { testApp, seedTask, seedContact, getTaskRow } from '../helpers/test-app.ts';

describe('PATCH /v2/tasks/close', () => {
  beforeEach(async () => { await resetTestDb(); });

  it('closes open task atomically (UPDATE + timeline in same transaction)', async () => {
    const { taskId, contactPhone, storeId } = await seedTask({ status: 'open', typeCategory: 'lead_follow_up' });
    const res = await testApp.request(`/v2/tasks/close?taskId=${taskId}`, {
      method: 'PATCH',
      body: JSON.stringify({ storeId, staffName: 'Alice', closeResult: 'converted', note: '' }),
      headers: { 'Content-Type': 'application/json' },
    });
    expect(res.status).toBe(200);
    const updated = await getTaskRow(taskId);
    expect(updated.status).toBe('closed');
    // Verify timeline row written
    const timeline = await getTimelineRows(taskId);
    expect(timeline).toHaveLength(1);
    expect(timeline[0].event_type).toBe('task.status_changed');
  });

  it('rejects close with progress closeResult — routes user to progress endpoint', async () => {
    const { taskId, storeId } = await seedTask({ status: 'open' });
    const res = await testApp.request(`/v2/tasks/close?taskId=${taskId}`, {
      method: 'PATCH',
      body: JSON.stringify({ storeId, staffName: 'Alice', closeResult: 'no_answer', note: '' }),
      headers: { 'Content-Type': 'application/json' },
    });
    expect(res.status).toBe(400);
    const body = await res.json();
    expect(body.error).toMatch(/progress endpoint/i);
  });

  it('uses one sql.transaction for UPDATE + timeline (B2 wrapper)', async () => {
    const { taskId, contactPhone, storeId } = await seedTask({ status: 'open' });
    const sqlSpy = vi.spyOn(getNeonClient(), 'transaction');
    const res = await testApp.request(`/v2/tasks/close?taskId=${taskId}`, {
      method: 'PATCH',
      body: JSON.stringify({ storeId, staffName: 'Alice', closeResult: 'converted', note: '' }),
      headers: { 'Content-Type': 'application/json' },
    });
    expect(res.status).toBe(200);
    expect(sqlSpy).toHaveBeenCalledTimes(1);
    expect(sqlSpy.mock.calls[0]![0]).toHaveLength(1); // Orchestrator returns complete SQL unit(s)
    sqlSpy.mockRestore();
  });
});

Step 2: Implementation

Critical contract:

  • 删 line 22-27 FOLLOW_UP_TASK_TYPE / FOLLOW_UP_TYPE_CATEGORY / FOLLOW_UP_STATUS / FOLLOW_UP_PRIORITY / FOLLOW_UP_SOURCE 常量(no_answer / left_voicemail 不再走 close)
  • 删 line 199-218 的 auto follow-up INSERT INTO tasks ... INTERVAL '${daysToFollowUp} days'(改走 progress endpoint)
  • buildContactTimelineInsertSQL import 保留(其他 route 仍可能用),本 file 不再直接调用 — Orchestrator 内部自动写 timeline,跟 UPDATE 同 transaction
// apps/api/src/routes/tasks/close.ts
import { Hono } from 'hono';
import { z } from 'zod';
import { buildTaskActionSQL } from '@retaintive/common/domain';
import { getNeonClient } from '../../services/neon.ts';
import { getAuthorizedStoreNeon } from '../../utils/store-authorization-neon.ts';
import { logger } from '../../utils/logger.ts';
import {
  ValidationError, NotFoundError, ForbiddenError, ConflictError,
} from '../../utils/errors.ts';
import type { CloseTaskResult } from './types.ts';

const PROGRESS_CLOSE_RESULTS = new Set([
  'no_answer', 'left_voicemail', 'callback_later', 'attempted',
]);

const querySchema = z.object({ taskId: z.string().uuid() });
const bodySchema = z.object({
  storeId: z.string().min(1),
  staffName: z.string().min(1).max(200),
  closeResult: z.string().min(1).max(100),
  note: z.string().max(1000).optional().default(''),
});

export const closeTaskRoute = new Hono()
  .patch('/close', async (c) => {
    const auth = c.get('auth');
    const userId = auth.userId;

    const queryParsed = querySchema.safeParse(c.req.query());
    if (!queryParsed.success) throw new ValidationError(queryParsed.error.message);
    const { taskId } = queryParsed.data;

    const body = await c.req.json();
    const bodyParsed = bodySchema.safeParse(body);
    if (!bodyParsed.success) throw new ValidationError(bodyParsed.error.message);
    const { storeId, staffName, closeResult, note } = bodyParsed.data;

    /*
     * Phase 1: close API does NOT accept progress closeResult values.
     * Frontend (Phase 1.5) should call POST /v2/tasks/:taskId/progress instead.
     * Temporary fallback until UI ships — return 400 with a helpful message.
     */
    if (PROGRESS_CLOSE_RESULTS.has(closeResult)) {
      throw new ValidationError(
        `closeResult='${closeResult}' is a progress event, not a close outcome. ` +
        `Use POST /v2/tasks/${taskId}/progress instead.`,
      );
    }

    const storeAuth = await getAuthorizedStoreNeon(userId, storeId);
    if (storeAuth.status === 'not_found') throw new NotFoundError('Store');
    if (storeAuth.status === 'forbidden') throw new ForbiddenError('No access to this store');
    if (storeAuth.role === 'VIEWER') throw new ForbiddenError('Viewers cannot modify tasks');

    const sql = getNeonClient();

    /*
     * Phase 1: route delegates to @retaintive/common/domain buildTaskActionSQL.
     * It handles Policy Guard (hallucination / state / DNC-allowed-for-close /
     * closeNote required) and returns transactional statements (UPDATE + timeline).
     */
    const result = await buildTaskActionSQL(
      {
        action: 'close',
        payload: { taskId, closeResult: closeResult as any, closeNote: note },
      },
      {
        storeId,
        actor: { type: 'staff', id: userId, name: staffName },
        db: sql as any,  // adapter accepts NeonHttpClient internally for SELECTs
      } as any,
    );

    if (result.status === 'reject') {
      switch (result.reason) {
        case 'hallucinated_task_id':
          throw new NotFoundError('Task');
        case 'task_not_open':
          throw new ConflictError('Task is already closed');
        case 'close_note_required':
          throw new ValidationError('closeNote required when closeResult=other');
        case 'stale_proposal':
          throw new ConflictError('Task was modified by another actor');
        case 'invalid_close_result':
          throw new ValidationError(`invalid closeResult: ${result.details}`);
        case 'store_mismatch':
          throw new NotFoundError('Task');  // 不泄露跨 store 存在
        default:
          throw new ValidationError(result.details ?? `rejected: ${result.reason}`);
      }
    }

    /*
     * B2 — sql.transaction wraps all statements (UPDATE + timeline INSERT)
     * into a single Postgres BEGIN/COMMIT. NEVER replace with a for-loop
     * of sql.query() — that is non-atomic and breaks audit consistency.
     *
     * neon-http sql.transaction 返回 each statement 的 result rows array,
     * 我们用它 + result.resultChecks 检测 silent business reject(spec §3.1
     * line 251):0-row RETURNING = stale_proposal / task_not_open / 等。
     */
    const txResults = await sql.transaction(result.statements.map(stmt => sql(stmt.sql, stmt.params)));

    for (const check of result.resultChecks ?? []) {
      const stmtResult = txResults[check.statementIndex];
      const rowCount = Array.isArray(stmtResult) ? stmtResult.length : 0;
      if (rowCount === 0) {
        // 0 rows = business invariant violated(stale snapshot / wrong state)。
        // close endpoint 这里翻译成 409 Conflict — 前端 staff 手动 close
        // 时碰到 race 才会触发(AI close 已在 Lambda path,不走 studio-api)。
        logger.warn('Close silent reject — 0 rows', {
          taskId, userId, reason: check.zeroRowsReason,
        });
        throw new ConflictError(
          check.zeroRowsReason === 'task_not_open'
            ? 'Task is already closed by another user'
            : 'Stale request — please refresh and try again',
        );
      }
    }

    logger.info('Task closed', { taskId, staffName, closeResult, userId });

    return c.json({
      data: {
        task_id: taskId,
        status: 'closed' as const,
        closed_at: new Date().toISOString(),
      } satisfies CloseTaskResult,
    });
  });

注意点:

  1. result.statements{ sql: string; params: unknown[] }[],sql(stmt.sql, stmt.params) 是 neon-http tagged-template 的调用形式
  2. handler 不返回 RETURNING 拿到的 task row(Orchestrator 内部 query 已 verify),直接构造 response shape;若前端依赖 closed_at 精确值,改成 transaction 后 SELECT 一次或让 adapter 返回 echo
  3. getAuthorizedStoreNeon 是 studio-api 现有 auth gate,保留 — Orchestrator 的 Policy Guard 是补充层

Step 3: Test write + run

Implementer 按 case map 8 scenarios 写测试。

cd /Users/maxwsy/workspace/studio-website-monorepo/apps/api && bun test src/__tests__/routes/tasks-close.test.ts

Expected: 8/8 PASS。

Step 4: Commit

git add apps/api/src/routes/tasks/close.ts apps/api/src/__tests__/routes/tasks-close.test.ts
git commit -m "feat(api): tasks/close — buildTaskActionSQL + sql.transaction (B2) + 拒绝 progress closeResult

Plan 06 Task 2.

- 改用 @retaintive/common/domain buildTaskActionSQL 单接口
- B2 — sql.transaction(statements.map(...)) 包裹 UPDATE + timeline,原子提交
- 拒绝 closeResult ∈ {no_answer, left_voicemail, callback_later, attempted}
  改走 POST /v2/tasks/:taskId/progress (Phase 1 临时 fallback)
- 删 FOLLOW_UP_TASK_TYPE 常量 + auto follow-up INSERT(过渡到 progress endpoint)
- Policy Guard reject 映射:hallucinated_task_id → 404 / task_not_open → 409 /
  close_note_required → 400 / stale_proposal → 409

Spec: normative-spec.md §4a + §5.0 invariant 'closeResult 纯 outcome'
"

Task 3: reopen.ts — buildTaskActionSQL action='reopen' + sql.transaction wrap

Files:

  • Modify: apps/api/src/routes/tasks/reopen.ts
  • Create: apps/api/src/__tests__/routes/tasks-reopen.test.ts

Step 1: Map test scenarios

#ScenarioExpectedWhy
1Reopen closed task200 + status='open' + close fields cleared + timeline task.status_changed 原子写Happy
2Reopen open task409 ConflictErrortask_not_closed
3Reopen taskId 跨 store404hallucinated_task_id
4Reopen DNC contact 的 task400 dnc rejectspec §3.6:reopen 不在 DNC allowed 列(只 close 允许)
5B2 transaction wrapper — route uses exactly one sql.transaction()200 + one transaction call containing all returned statements; no loop of sql.query()同 close,caller 只测 wrapper wiring

Step 2: Implementation

跟 close.ts 同 pattern。改动 vs 现有 reopen.ts:

  • 删 line 91-106 自拼的 UPDATE tasks SET status='pending' 整段
  • 删 line 117-127 的 existing-row 二次 SELECT(Orchestrator 内部已做)
  • 删 line 136-158 自拼的 buildContactTimelineInsertSQL(Orchestrator 内部写)
  • handler 改成调 buildTaskActionSQL({ action: 'reopen', payload: { taskId, reason } })
// apps/api/src/routes/tasks/reopen.ts (核心改动 — 省略 import / Zod schema)
const result = await buildTaskActionSQL(
  { action: 'reopen', payload: { taskId } },
  {
    storeId,
    actor: { type: 'staff', id: userId },
    db: sql as any,
  } as any,
);

if (result.status === 'reject') {
  switch (result.reason) {
    case 'hallucinated_task_id': throw new NotFoundError('Task');
    case 'task_not_closed': throw new ConflictError('Task is already open');
    case 'dnc': throw new ValidationError('Cannot reopen task for DNC contact');
    case 'store_mismatch': throw new NotFoundError('Task');
    default: throw new ValidationError(result.details ?? `rejected: ${result.reason}`);
  }
}

// B2 — sql.transaction wrap + resultChecks(spec §3.1 line 251)
const txResults = await sql.transaction(result.statements.map(stmt => sql(stmt.sql, stmt.params)));
checkSilentReject(result.resultChecks, txResults, { taskId, userId }, 'reopen');

return c.json({
  data: {
    task_id: taskId,
    status: 'open' as const,
    updated_at: new Date().toISOString(),
  } satisfies ReopenTaskResult,
});

注意 response shape:现有 reopen 返回 status: 'pending',Phase 1 transitional 期间 schema 允许 'pending' | 'open',但 final cutover 后只有 'open'。本 handler 直接返 'open'(写入的就是 'open')。前端如果硬编码读 'pending',需要 Phase 1.5 一并改 — issue 标 follow-up。

Step 3: Test write + run

按 case map 5 scenarios 写,运行同 Task 2 pattern。

Step 4: Commit

git add apps/api/src/routes/tasks/reopen.ts apps/api/src/__tests__/routes/tasks-reopen.test.ts
git commit -m "feat(api): tasks/reopen — buildTaskActionSQL action='reopen' + sql.transaction (B2)

Plan 06 Task 3.

Spec: normative-spec.md §4c
"

Task 4: postpone.ts — buildTaskActionSQL action='update' + sql.transaction wrap

Files:

  • Modify: apps/api/src/routes/tasks/postpone.ts
  • Create: apps/api/src/__tests__/routes/tasks-postpone.test.ts

Step 1: Map test scenarios

#ScenarioExpectedWhy
1Postpone open task to future dueAt200 + UPDATE due_at + timeline task.due_at_changed(实际 Orchestrator 用 task.updated)Happy
2Postpone closed task409 ConflictErrortask_not_open
3Postpone with dueAt in past400 ValidationError(route-level check)现有逻辑保留
4B2 transaction wrapper — route uses exactly one sql.transaction()200 + one transaction call containing all returned statements; no loop of sql.query()hard invariant

Step 2: Implementation

// apps/api/src/routes/tasks/postpone.ts (核心改动)
// 保留现有 route-level dueAt future check
if (newDueAt.getTime() <= Date.now()) {
  throw new ValidationError('dueAt must be in the future');
}

const result = await buildTaskActionSQL(
  {
    action: 'update',
    payload: { taskId, dueAt: newDueAt },  // 只改 dueAt
  },
  {
    storeId,
    actor: { type: 'staff', id: userId },
    db: sql as any,
  } as any,
);

if (result.status === 'reject') {
  switch (result.reason) {
    case 'hallucinated_task_id': throw new NotFoundError('Task');
    case 'task_not_open': throw new ConflictError('Cannot postpone a closed task');
    case 'dnc': throw new ValidationError('Cannot postpone task for DNC contact');
    case 'store_mismatch': throw new NotFoundError('Task');
    default: throw new ValidationError(result.details ?? `rejected: ${result.reason}`);
  }
}

// B2 — sql.transaction wrap + resultChecks(spec §3.1 line 251)
const txResults = await sql.transaction(result.statements.map(stmt => sql(stmt.sql, stmt.params)));
checkSilentReject(result.resultChecks, txResults, { taskId, userId }, 'postpone');

return c.json({
  data: {
    task_id: taskId,
    due_at: newDueAt.toISOString(),
    updated_at: new Date().toISOString(),
  },
});

Step 3 + 4: Test + commit(同 Task 3 pattern)

git add apps/api/src/routes/tasks/postpone.ts apps/api/src/__tests__/routes/tasks-postpone.test.ts
git commit -m "feat(api): tasks/postpone — buildTaskActionSQL action='update' + sql.transaction (B2)

Plan 06 Task 4.

Spec: normative-spec.md §4c
"

Task 5: 新建 progress.ts — POST /v2/tasks/:taskId/progress endpoint

Files:

  • Create: apps/api/src/routes/tasks/progress.ts
  • Modify: apps/api/src/routes/tasks/index.ts(注册 route)
  • Create: apps/api/src/__tests__/routes/tasks-progress.test.ts

Step 1: Map test scenarios

#ScenarioExpectedWhy
1Record no_answer with callId200 + progress event INSERT + tasks.attempt_count += 1 + nextDueAt = NOW + 60min + timeline task.progress_recorded 原子写Happy phone path
2Record same (taskId, callId, progressType) 第二次200 { ok: true, deduped: true } —— CTE 保护 attempt_count 不翻倍spec §3.2 B8 + §5.3 idempotency
3Record on closed task409 ConflictError task_not_openPolicy Guard state check
4Record on DNC contact400 ValidationError dncspec §3.6 — record_progress 不在 DNC allowed
5Record callback_requestednextDueAt body field400 ValidationErrorcallback_requested + follow_up_scheduled 的 nextDueAtOverride 必填(否则 dueAt 不变也无意义)— route 层 Zod 检查
6Record with messageId(SMS path)同 happy + idempotency_key 格式 progress:taskId:message:M1:text_sentspec §3.2 4 source pattern
7Record with neither callId nor messageId(manual path)200 + idempotency_key 含 manual:actorId:occurredAt:progressTypeManual evidence
8Cross-store taskId404 NotFoundErrorPolicy Guard hallucinated_task_id
9B2 transaction wrapper — route uses exactly one sql.transaction()200 + one transaction call containing progress SQL unit(s); no loop of sql.query()hard invariant

Step 2: Implementation

// apps/api/src/routes/tasks/progress.ts (NEW FILE)
/**
 * POST /v2/tasks/:taskId/progress — Record progress event
 *
 * Records a non-terminal touch on an open task. Examples: no_answer,
 * left_voicemail, text_sent, callback_requested, follow_up_scheduled,
 * customer_considering.
 *
 * Phase 1 ship: 这是新端点,替代过去通过 close API + closeResult ∈ progress_set
 * 触发的"关旧建新 follow-up"模式。现在 progress 单独追踪,task 保持 open,
 * attempt_count 累加,dueAt 按 progressType 自动推后。
 *
 * Data: writes to task_progress_events + UPDATE tasks (attempt_count + due_at)
 *       + contact_timeline (task.progress_recorded)
 *       全部在单个 sql.transaction() 内原子提交 (B2)
 */
import { Hono } from 'hono';
import { z } from 'zod';
import { buildTaskActionSQL } from '@retaintive/common/domain';
import { getNeonClient } from '../../services/neon.ts';
import { getAuthorizedStoreNeon } from '../../utils/store-authorization-neon.ts';
import { logger } from '../../utils/logger.ts';
import {
  ValidationError, NotFoundError, ForbiddenError, ConflictError,
} from '../../utils/errors.ts';

const PROGRESS_TYPES = [
  'no_answer', 'left_voicemail', 'text_sent',
  'callback_requested', 'follow_up_scheduled', 'customer_considering',
] as const;
const CHANNELS = ['phone', 'sms', 'voicemail', 'email'] as const;

// progressType 需要 nextDueAt — Orchestrator 内部 computeNextDueAt 对这两类返回
// currentDueAt 不变;route-level 提示用户必传 override 避免 dueAt 不动 = bug
const REQUIRES_NEXT_DUE_AT = new Set([
  'callback_requested', 'follow_up_scheduled',
]);

const querySchema = z.object({ taskId: z.string().uuid() });
const bodySchema = z.object({
  storeId: z.string().min(1),
  progressType: z.enum(PROGRESS_TYPES),
  channel: z.enum(CHANNELS),
  callId: z.string().optional(),
  messageId: z.string().optional(),
  note: z.string().max(1000).optional(),
  nextDueAt: z.string().datetime().optional(),
}).refine(
  (b) => !REQUIRES_NEXT_DUE_AT.has(b.progressType) || !!b.nextDueAt,
  { message: 'nextDueAt is required for callback_requested / follow_up_scheduled' },
);

export const progressRoute = new Hono()
  .post('/:taskId/progress', async (c) => {
    const auth = c.get('auth');
    const userId = auth.userId;
    const staffName = auth.staffName ?? auth.userId;  // 视现有 auth shape

    const queryParsed = querySchema.safeParse({ taskId: c.req.param('taskId') });
    if (!queryParsed.success) throw new ValidationError(queryParsed.error.message);
    const { taskId } = queryParsed.data;

    const body = await c.req.json();
    const bodyParsed = bodySchema.safeParse(body);
    if (!bodyParsed.success) throw new ValidationError(bodyParsed.error.message);
    const { storeId, progressType, channel, callId, messageId, note, nextDueAt } = bodyParsed.data;

    const storeAuth = await getAuthorizedStoreNeon(userId, storeId);
    if (storeAuth.status === 'not_found') throw new NotFoundError('Store');
    if (storeAuth.status === 'forbidden') throw new ForbiddenError('No access to this store');
    if (storeAuth.role === 'VIEWER') throw new ForbiddenError('Viewers cannot modify tasks');

    const sql = getNeonClient();

    const result = await buildTaskActionSQL(
      {
        action: 'record_progress',
        payload: {
          taskId,
          progressType,
          channel,
          callId,
          messageId,
          note,
          nextDueAtOverride: nextDueAt ? new Date(nextDueAt) : undefined,
        },
      },
      {
        storeId,
        actor: { type: 'staff', id: userId, name: staffName },
        db: sql as any,
      } as any,
    );

    if (result.status === 'reject') {
      switch (result.reason) {
        case 'hallucinated_task_id': throw new NotFoundError('Task');
        case 'task_not_open': throw new ConflictError('Task is not open');
        case 'dnc': throw new ValidationError('Cannot record progress for DNC contact');
        case 'invalid_progress_type': throw new ValidationError(`invalid progressType: ${result.details}`);
        case 'duplicate':
          /*
           * Idempotency hit — ON CONFLICT idempotency_key DO NOTHING.
           * Return ok+deduped so client retries are silently safe.
           */
          return c.json({ data: { ok: true, deduped: true } });
        case 'store_mismatch': throw new NotFoundError('Task');
        default: throw new ValidationError(result.details ?? `rejected: ${result.reason}`);
      }
    }

    /*
     * B2 — sql.transaction wraps:
     *   1. CTE: INSERT task_progress_events ON CONFLICT (idempotency_key) DO NOTHING
     *           + UPDATE tasks SET attempt_count = attempt_count + 1 (only if INSERT fired)
     *   2. INSERT contact_timeline (task.progress_recorded)
     * Plus resultChecks(spec §3.1 line 251)— idempotency_key duplicate 是
     * 预期 retry,log warn 但不 throw(progress 是 idempotent operation)。
     */
    const txResults = await sql.transaction(result.statements.map(stmt => sql(stmt.sql, stmt.params)));
    checkSilentReject(result.resultChecks, txResults, { taskId, userId }, 'record_progress', { allowDuplicate: true });

    logger.info('Progress recorded', { taskId, progressType, channel, userId });

    return c.json({ data: { ok: true } });
  });

修改 apps/api/src/routes/tasks/index.ts 注册新 route:

// apps/api/src/routes/tasks/index.ts (添加)
import { progressRoute } from './progress.ts';

const tasks = new Hono()
  // ... existing routes
  .route('/', progressRoute);  // POST /v2/tasks/:taskId/progress

Step 3: Test write + run

按 9 scenarios 写测试。关键 test #2 (idempotency) 是 spec §5.3 invariant 直接验证:

it('idempotent same (taskId, callId, progressType) — attempt_count not double-bumped', async () => {
  const { taskId, storeId } = await seedTask({ status: 'open', attempt_count: 0 });
  const body = {
    storeId,
    progressType: 'no_answer',
    channel: 'phone',
    callId: 'CALL_X',
  };
  const r1 = await testApp.request(`/v2/tasks/${taskId}/progress`, {
    method: 'POST', body: JSON.stringify(body),
    headers: { 'Content-Type': 'application/json' },
  });
  const r2 = await testApp.request(`/v2/tasks/${taskId}/progress`, {
    method: 'POST', body: JSON.stringify(body),
    headers: { 'Content-Type': 'application/json' },
  });
  expect(r1.status).toBe(200);
  expect(r2.status).toBe(200);
  const r2body = await r2.json();
  expect(r2body.data.deduped).toBe(true);

  const task = await getTaskRow(taskId);
  expect(task.attempt_count).toBe(1);  // ← CTE 保护:不翻倍

  const events = await getProgressEvents(taskId);
  expect(events).toHaveLength(1);  // ← 只 1 行 event
});
cd /Users/maxwsy/workspace/studio-website-monorepo/apps/api && bun test src/__tests__/routes/tasks-progress.test.ts

Expected: 8/8 PASS,特别 #2 必须 PASS(否则 B8 CTE 没 wire 通)。

Step 4: Commit

git add apps/api/src/routes/tasks/progress.ts apps/api/src/routes/tasks/index.ts apps/api/src/__tests__/routes/tasks-progress.test.ts
git commit -m "feat(api): 新建 POST /v2/tasks/:taskId/progress endpoint

Plan 06 Task 5.

- 替代过去 close API + closeResult ∈ progress_set 的关旧建新 follow-up 模式
- 6 个 progressType (no_answer / left_voicemail / text_sent /
  callback_requested / follow_up_scheduled / customer_considering)
- 4 个 channel (phone / sms / voicemail / email)
- B2 — sql.transaction 包裹 CTE + timeline INSERT 原子
- B8 — CTE 保护 attempt_count 不在 ON CONFLICT DO NOTHING 时翻倍
- callback_requested / follow_up_scheduled 必须传 nextDueAt
  (否则 Orchestrator 不动 dueAt = 半 bug)

Spec: normative-spec.md §4b + §3.2 B8
"

Task 6: status 字符串 sweep — list.ts / events.ts / dashboard-multi-store.ts(过渡期 IN ('pending','open'))

Files:

  • Modify: apps/api/src/routes/tasks/list.ts(8 处 status = 'pending')
  • Modify: apps/api/src/routes/tasks/events.ts(如有 status filter)
  • Modify: apps/api/src/routes/v3/dashboard-multi-store.ts(1 处 status = 'pending')
  • Modify: apps/api/src/routes/tasks/types.ts(TaskRow.status enum 加 'open')
  • Create: 无新 test 文件(Task 7 contacts/leads 一起覆盖)

Step 1: Map test scenarios

不需要新独立 test 文件,本 task 是机械 sweep。验证手段:

  • TypeScript 编译过(TaskRow.status enum 含 'open')
  • 集成 test(Task 9)跑过(过渡期 seed 既有 'pending' 又有 'open' 的 task,verify list / dashboard / contacts 都正确返回两类)

Step 2: Implementation — 机械替换 + TaskRow 改 type

apps/api/src/routes/tasks/types.ts 改动:

// 改 line 18:
status: 'pending' | 'open' | 'closed';  // transitional;Plan 07 cutover 后收缩到 'open' | 'closed'

// 删 line 16:
// task_type: string;   ← 删除

// 删 line 20-21:
// action_needed: boolean;
// action_needed_reason: string | null;

apps/api/src/routes/tasks/list.ts 机械替换:

LineBeforeAfter
99"t.status = 'pending'""t.status IN ('pending', 'open')"
101"t.status = 'pending'""t.status IN ('pending', 'open')"
103"t.status = 'pending'""t.status IN ('pending', 'open')"
117"t.status = 'pending'""t.status IN ('pending', 'open')"
127"t.status = 'pending'""t.status IN ('pending', 'open')"
132"t.status = 'pending'""t.status IN ('pending', 'open')"
164t.task_type,删除该 line + 后续 SELECT 中所有 task_type 引用
168t.action_needed,删除
169t.action_needed_reason,删除
239CASE WHEN t.status = 'pending' THEN 0CASE WHEN t.status IN ('pending', 'open') THEN 0
253WHERE status = 'pending'WHERE status IN ('pending', 'open')
254WHERE status = 'pending' AND due_at < NOW()WHERE status IN ('pending', 'open') AND due_at < NOW()
255WHERE status = 'pending' AND due_at BETWEEN ...WHERE status IN ('pending', 'open') AND due_at BETWEEN ...
256WHERE status = 'pending' AND (due_at IS NULL OR due_at >= ...)WHERE status IN ('pending', 'open') AND ...
261, 269, 273同 pattern,status = 'pending'status IN ('pending', 'open')全部替换

apps/api/src/routes/v3/dashboard-multi-store.ts line 191:

-- Before:
COUNT(*) FILTER (WHERE status = 'pending' AND due_at < NOW())::int AS tasks_overdue
-- After:
COUNT(*) FILTER (WHERE status IN ('pending', 'open') AND due_at < NOW())::int AS tasks_overdue

apps/api/src/routes/tasks/events.ts: grep status filter 后视情况改(当前已 grep 无 hit,只读路径 SELECT 不带 status filter — 不动)。

Step 3: Test write + run

无新 test。Type check + 集成 test(Task 9)是 verification:

cd /Users/maxwsy/workspace/studio-website-monorepo/apps/api && bun run typecheck && bun test

Expected: PASS(TaskRow.status type 改后,所有消费 row.status 的代码若硬编码 'pending' 会编译报错 — 必须修)。

关键 grep verify — commit 前跑:

cd /Users/maxwsy/workspace/studio-website-monorepo/apps/api && grep -rn "status = 'pending'\|status='pending'" src/routes/ | grep -v "// transitional"

Expected: 0 hit(全部已切到 IN ('pending', 'open'))。

Step 4: Commit

git add apps/api/src/routes/tasks/list.ts apps/api/src/routes/tasks/types.ts apps/api/src/routes/v3/dashboard-multi-store.ts
git commit -m "refactor(api): sweep status='pending' → IN ('pending','open') + 删 task_type/action_needed

Plan 06 Task 6.

- 过渡期(Plan 01 transitional schema)所有读路径用 IN ('pending', 'open')
  覆盖既有 'pending' 行 + 新写入 'open' 行
- TaskRow.status type 加 'open'(过渡期 3-value;Plan 07 cutover 后收缩)
- 删 TaskRow.task_type / action_needed / action_needed_reason 字段
  (tasks 表字段退役;contacts.action_needed 字段保留待 Phase 2)
- list.ts 8 处 + dashboard-multi-store.ts 1 处 status sweep

Spec: normative-spec.md §4h + §6.2 transitional + §4i task_type 字段消费端清理
"

Task 7: contacts.ts + leads.ts — has_open_task EXISTS 子查询替代 action_needed 读路径

Files:

  • Modify: apps/api/src/routes/v3/contacts.ts(line ~112 SELECT clause)
  • Modify: apps/api/src/routes/v3/leads.ts(line ~209 KPI summary)
  • Create: apps/api/src/__tests__/routes/v3-contacts-action-needed.test.ts
  • Create: apps/api/src/__tests__/routes/v3-leads-kpi.test.ts

Step 1: Map test scenarios

v3/contacts.ts EXISTS 子查询等价 test:

#SetupExpectedWhy
1Contact A 有 1 个 status='open' task → queryhas_open_task=true (or compat action_needed=true)EXISTS 替代 action_needed=true
2Contact B 无 task → queryhas_open_task=false无 task
3Contact C 有 1 个 status='closed' task → queryhas_open_task=falseclosed 不算
4Transitional 覆盖 — Contact D 有 1 个 status='pending' task(legacy)+ 1 个 status='open' task → queryhas_open_task=true(不漏 pending)IN ('pending', 'open') covers transitional
5Tenant isolation — Contact E 在 store_A,Contact F 同 phone 在 store_B,只 store_B 有 open task → query store_Ahas_open_task=false for store_Astore_id WHERE 隔离

v3/leads.ts KPI etc:

#SetupExpectedWhy
13 contacts:A 有 open task,B 有 closed task,C 无 tasksummary.action_needed = 1(只算 A)EXISTS 等价旧 action_needed=true
2Same phone 在 store_A 和 store_B → query store_A only count store_A 的 open tasksKPI 不算 cross-storetenant isolation
3Contact A 只有 legacy status='pending' task,Contact B 有 new status='open' tasksummary.action_needed = 2leads KPI 也必须用 IN ('pending','open'),不能只 contacts list 覆盖 transitional

Step 2: Implementation

apps/api/src/routes/v3/contacts.ts line 109-118 — 加 has_open_task 子查询:

// Before (line 109-118):
SELECT
  c.phone, c.first_name, c.last_name, c.lifecycle_stage,
  c.lead_status, c.purchase_intent, c.has_card_on_file,
  c.do_not_contact, c.action_needed, c.has_open_complaint,
  c.last_activity_at, c.created_at,
  COALESCE(cs.call_count, 0)::integer AS call_count,
  COALESCE(ss.sms_count, 0)::integer AS sms_count
FROM contacts c
LEFT JOIN call_stats cs ON cs.phone = c.phone
LEFT JOIN sms_stats ss ON ss.phone = c.phone
WHERE ${conditions.join(' AND ')}

// After:
SELECT
  c.phone, c.first_name, c.last_name, c.lifecycle_stage,
  c.lead_status, c.purchase_intent, c.has_card_on_file,
  c.do_not_contact,
  c.action_needed,                              -- 字段保留,Phase 2DROP
  /*
   * Phase 1: has_open_task EXISTS subquery is the new source of truth
   * for "this contact has work pending". Frontend should consume
   * has_open_task; c.action_needed kept for backward compat until
   * Phase 2 ships its DROP COLUMN migration.
   *
   * Transitional: status IN ('pending', 'open') covers both legacy
   * 'pending' rows (pre-Plan 01) and new 'open' rows (post-Plan 01).
   */
  EXISTS (
    SELECT 1 FROM tasks t
    WHERE t.contact_phone = c.phone
      AND t.store_id = c.store_id
      AND t.status IN ('pending', 'open')
  ) AS has_open_task,
  c.has_open_complaint,
  c.last_activity_at, c.created_at,
  COALESCE(cs.call_count, 0)::integer AS call_count,
  COALESCE(ss.sms_count, 0)::integer AS sms_count
FROM contacts c
LEFT JOIN call_stats cs ON cs.phone = c.phone
LEFT JOIN sms_stats ss ON ss.phone = c.phone
WHERE ${conditions.join(' AND ')}

记得也改 ContactRow interface 加 has_open_task: boolean

apps/api/src/routes/v3/leads.ts line 207-214 KPI:

// Before:
const summaryQuery = `
  SELECT
    COUNT(*)::integer AS total,
    COUNT(*) FILTER (WHERE c.action_needed = true)::integer AS action_needed,
    COUNT(*) FILTER (WHERE c.created_at >= NOW() - INTERVAL '1 day')::integer AS new_today,
    COUNT(*) FILTER (WHERE c.last_activity_at < NOW() - INTERVAL '5 days')::integer AS stale_count
  FROM contacts c
  WHERE ${whereClause}
`;

// After:
const summaryQuery = `
  SELECT
    COUNT(*)::integer AS total,
    /*
     * Phase 1: action_needed KPI changes source — old denormalized
     * c.action_needed boolean replaced by an EXISTS subquery against
     * tasks. Transitional IN ('pending', 'open') covers Plan 01 window.
     * COUNT(DISTINCT c.phone) because a phone may have multiple
     * (phone, store_id) contact rows in legacy data.
     */
    COUNT(DISTINCT c.phone) FILTER (WHERE EXISTS (
      SELECT 1 FROM tasks t
      WHERE t.contact_phone = c.phone
        AND t.store_id = c.store_id
        AND t.status IN ('pending', 'open')
    ))::integer AS action_needed,
    COUNT(*) FILTER (WHERE c.created_at >= NOW() - INTERVAL '1 day')::integer AS new_today,
    COUNT(*) FILTER (WHERE c.last_activity_at < NOW() - INTERVAL '5 days')::integer AS stale_count
  FROM contacts c
  WHERE ${whereClause}
`;

LeadRow interface 中 action_needed: boolean | null 字段 — Phase 1 留(仍 SELECT 行级 c.action_needed 给前端 fallback),Phase 2 一起删。

Step 3: Test write + run

按 case map 写 5 + 3 = 8 test。重点验证 transitional IN ('pending', 'open') 覆盖(用户给的 case map 第 3 条核心要求)。

cd /Users/maxwsy/workspace/studio-website-monorepo/apps/api && bun test src/__tests__/routes/v3-contacts-action-needed.test.ts src/__tests__/routes/v3-leads-kpi.test.ts

Expected: PASS。

Step 4: Commit

git add apps/api/src/routes/v3/contacts.ts apps/api/src/routes/v3/leads.ts apps/api/src/__tests__/routes/v3-contacts-action-needed.test.ts apps/api/src/__tests__/routes/v3-leads-kpi.test.ts
git commit -m "feat(api): v3/contacts + v3/leads — EXISTS 子查询替代 action_needed 读路径

Plan 06 Task 7.

- contacts list 加 has_open_task: EXISTS(SELECT 1 FROM tasks ...)
- leads KPI summary.action_needed 改 EXISTS 子查询(COUNT DISTINCT phone)
- 过渡期用 status IN ('pending', 'open') 覆盖 Plan 01 transitional window
- c.action_needed 字段 SELECT 保留(Phase 2 才 DROP COLUMN);
  c.action_needed_reason 同保留

Spec: normative-spec.md §4g
"

Task 8: dashboard attempt workload 改算 task_progress_events(B3)

Files:

  • Modify: apps/api/src/routes/v3/dashboard-staff.ts(或具体哪个 dashboard 文件含 attempt workload — 视具体 file 决定)
  • 若 dashboard-analytics.ts / dashboard-overview.ts 也有 attempt metric,同样改
  • Create: apps/api/src/__tests__/routes/dashboard-attempt-workload.test.ts

Step 1: Locate which dashboard file 含 attempt workload

grep -rn "attempt\|close_result = 'attempted'" apps/api/src/routes/v3/dashboard-*.ts

当前现状(2026-06-02 grep):

  • dashboard-analytics.ts:119 cancel_attemptscall subcategory 计算(不是 task close_result),保留不动
  • 其他 dashboard-*.ts 暂无 close_result = 'attempted' 用法

说明:Phase 1 之前,studio-api dashboard 没有暴露 "attempt workload" metric(用 task close_result = attempted)。本 Task 8 主要是为 Phase 1 后新增的 workload metric 提供正确 query 实现。如果 dashboard PM 没要求新 metric,可以 defer 到 Phase 2(标 DEFERRED 在 doc 里)。

但 spec §4h B3 要求"口径迁移",意思是 现有 metric 若以 closeResult='attempted' 为口径要改。grep 没 hit 说明 studio-api 这部分本来就用 calls 的 cancel_attempts 而不是 tasks。Task 8 简化为:文档化口径 + 预埋一个 getAttemptWorkload() helper function 给 Phase 1.5 dashboard 用,但本 plan 不强制 wire 进 endpoint

Step 2: Map test scenarios(helper function only)

#ScenarioExpectedWhy
1Seed 5 progress events (3 by staff,2 by system,跨 7 天) → queryattempts=5, tasks_touched=correct, active_staff=N(staff distinct)spec §4h B3 SQL contract
2Seed 1 progress event in store_A,1 in store_B → query store_A不泄露 store_BTenant isolation
3Seed progress event 10 天前 → query NOW - 7 days不计入(超出窗口)Window filter

Step 3: Implementation — 新建 helper

apps/api/src/routes/v3/dashboard-helpers/attempt-workload.ts(新建):

/**
 * Attempt workload metric (Phase 1 口径迁移 — spec §4h B3).
 *
 * Phase 1 之前若 dashboard 用 tasks.close_result = 'attempted' 算 attempt
 * workload,Phase 1 这种 close_result 不存在了(progress 拆出来到 task_progress_events)。
 * 这里给出新口径的 helper。
 *
 * studio-api 当前 grep 无 close_result='attempted' 用法 — 本 helper 是预埋,
 * Phase 1.5 dashboard 改造时可直接消费。
 */
import { getNeonClient } from '../../../services/neon.ts';

export interface AttemptWorkloadRow {
  attempts: number;
  tasks_touched: number;
  active_staff: number;
}

export async function getAttemptWorkload(
  storeId: string,
  windowDays = 7,
): Promise<AttemptWorkloadRow> {
  const sql = getNeonClient();
  const rows = await sql.query(
    `SELECT
       COUNT(*)::int AS attempts,
       COUNT(DISTINCT task_id)::int AS tasks_touched,
       COUNT(DISTINCT actor_id) FILTER (WHERE actor_type = 'staff')::int AS active_staff
     FROM task_progress_events
     WHERE store_id = $1
       AND occurred_at >= NOW() - INTERVAL '${windowDays} days'`,
    [storeId],
  ) as unknown as AttemptWorkloadRow[];
  return rows[0] ?? { attempts: 0, tasks_touched: 0, active_staff: 0 };
}

注意 INTERVAL '${windowDays} days' 是 string interpolation 不是参数化 — windowDays 必须是 number(不可 SQL injection),route 调用方应 hardcode 或 enum,不接受 user input。

Step 4: Test write + run

// apps/api/src/__tests__/routes/dashboard-attempt-workload.test.ts
import { describe, it, expect } from 'vitest';
import { getAttemptWorkload } from '../../routes/v3/dashboard-helpers/attempt-workload.ts';
import { seedProgressEvent, resetTestDb } from '../helpers/test-db.ts';

describe('getAttemptWorkload', () => {
  it('counts progress events by store + actor distinct', async () => {
    await resetTestDb();
    const storeA = 'STORE_A';
    const taskA = 'task-A';
    const taskB = 'task-B';
    await seedProgressEvent({ taskId: taskA, storeId: storeA, actorType: 'staff', actorId: 'alice', progressType: 'no_answer', occurredAtDaysAgo: 0 });
    await seedProgressEvent({ taskId: taskA, storeId: storeA, actorType: 'staff', actorId: 'alice', progressType: 'left_voicemail', occurredAtDaysAgo: 1 });
    await seedProgressEvent({ taskId: taskB, storeId: storeA, actorType: 'staff', actorId: 'bob', progressType: 'no_answer', occurredAtDaysAgo: 2 });
    await seedProgressEvent({ taskId: taskB, storeId: storeA, actorType: 'system', actorId: 'cron', progressType: 'follow_up_scheduled', occurredAtDaysAgo: 3 });
    await seedProgressEvent({ taskId: taskA, storeId: storeA, actorType: 'system', actorId: 'cron', progressType: 'follow_up_scheduled', occurredAtDaysAgo: 4 });

    const r = await getAttemptWorkload(storeA, 7);
    expect(r.attempts).toBe(5);
    expect(r.tasks_touched).toBe(2);
    expect(r.active_staff).toBe(2);  // alice + bob distinct
  });

  it('tenant-isolates by store_id', async () => {
    await resetTestDb();
    await seedProgressEvent({ taskId: 'tA', storeId: 'STORE_A', actorType: 'staff', actorId: 'alice', progressType: 'no_answer' });
    await seedProgressEvent({ taskId: 'tB', storeId: 'STORE_B', actorType: 'staff', actorId: 'bob', progressType: 'no_answer' });
    const r = await getAttemptWorkload('STORE_A', 7);
    expect(r.attempts).toBe(1);
    expect(r.active_staff).toBe(1);
  });

  it('excludes events outside the window', async () => {
    await resetTestDb();
    await seedProgressEvent({ taskId: 'tA', storeId: 'STORE_A', actorType: 'staff', actorId: 'alice', progressType: 'no_answer', occurredAtDaysAgo: 10 });
    const r = await getAttemptWorkload('STORE_A', 7);
    expect(r.attempts).toBe(0);
  });
});
cd /Users/maxwsy/workspace/studio-website-monorepo/apps/api && bun test src/__tests__/routes/dashboard-attempt-workload.test.ts

Expected: 3/3 PASS。

Step 5: Commit

git add apps/api/src/routes/v3/dashboard-helpers/attempt-workload.ts apps/api/src/__tests__/routes/dashboard-attempt-workload.test.ts
git commit -m "feat(api): dashboard helper getAttemptWorkload — 算 task_progress_events (B3)

Plan 06 Task 8.

- 新口径:Attempt workload = COUNT(*) FROM task_progress_events
  WHERE store_id=$1 AND occurred_at >= NOW() - INTERVAL '7 days'
- 返回 attempts / tasks_touched / active_staff (distinct staff actor_id)
- 本 plan 不强 wire 进现有 dashboard route(grep 无 close_result='attempted'
  当前用法);预埋给 Phase 1.5 dashboard 改造消费

Spec: normative-spec.md §4h B3
"

Task 9: E2E integration test + final PR

Files:

  • Create: apps/api/src/__tests__/integration/tasks-mutations.integration.test.ts

Step 1: Map E2E scenarios(连真实测试 Neon)

#ScenarioExpected覆盖哪条 invariant
1Seed open task → POST close(closeResult='converted')→ SELECT tasks.status + timeline rowsstatus='closed' + 1 timeline row(task.status_changed,actor_type='staff')spec §5.0 invariant 1 + B2 atomicity
2Seed open task → POST progress(no_answer, callId='C1')→ POST progress(同 callId,同 progressType)→ SELECT task.attempt_count + progress eventsattempt_count=1(不翻倍),event 表 1 行spec §5.0 + B8 CTE invariant
3Seed open task in store_A → POST close from store_B's auth context → expect 404NotFoundErrorspec §5.0 tenant isolation
4Seed open task + DNC contact → POST progress → expect 400 dncrejectspec §3.6 DNC + I6 reopen 拒绝
5Seed closed task → POST reopen → SELECT statusstatus='open' + close fields clearedspec §5.2 reopen
6Seed 3 open tasks (different typeCategory) for same contact → POST progress on each → all 3 attempt_count++ + 3 timeline rowseach task 独立 progress 不互扰spec §5.0 + §5.2
7List endpoint GET /v2/tasks?storeId=X → returns mix of status='pending' (legacy seed) + status='open' (new seed) → all returned + summary.pending_count 累加正确transitional IN ('pending', 'open') 工作spec §6.2 transitional + §4h sweep

Step 2: Implementation outline

测试 setup 通过 apps/api/scripts/integration-test.ts 已有的 Neon test DB connection。Reset 步骤 + seed helpers 沿用现有 __tests__/integration/neon-services.integration.test.ts 的 pattern。

// apps/api/src/__tests__/integration/tasks-mutations.integration.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { getNeonClient } from '../../services/neon.ts';
import {
  resetIntegrationDb,
  seedContact, seedTask, seedTimelineRow,
  callApi,
} from '../helpers/integration-helpers.ts';

describe('Phase 1 task mutations — E2E', () => {
  beforeEach(async () => { await resetIntegrationDb(); });

  it('close → UPDATE + timeline atomic', async () => {
    const { phone, storeId } = await seedContact({ doNotContact: false });
    const taskId = await seedTask({ phone, storeId, status: 'open', typeCategory: 'lead_follow_up' });
    const res = await callApi('PATCH', `/v2/tasks/close?taskId=${taskId}`, {
      storeId, staffName: 'Alice', closeResult: 'converted', note: '',
    });
    expect(res.status).toBe(200);
    const sql = getNeonClient();
    const [row] = await sql.query(`SELECT status FROM tasks WHERE task_id=$1`, [taskId]);
    expect(row.status).toBe('closed');
    const [tl] = await sql.query(`SELECT event_type FROM contact_timeline WHERE entity_id=$1`, [taskId]);
    expect(tl.event_type).toBe('task.status_changed');
  });

  it('progress idempotency — same (callId, progressType) twice → attempt_count not doubled (B8 CTE)', async () => {
    const { phone, storeId } = await seedContact({ doNotContact: false });
    const taskId = await seedTask({ phone, storeId, status: 'open', attempt_count: 0 });
    const body = { storeId, progressType: 'no_answer', channel: 'phone', callId: 'CALL_X' };
    await callApi('POST', `/v2/tasks/${taskId}/progress`, body);
    const r2 = await callApi('POST', `/v2/tasks/${taskId}/progress`, body);
    expect((await r2.json()).data.deduped).toBe(true);
    const sql = getNeonClient();
    const [row] = await sql.query(`SELECT attempt_count FROM tasks WHERE task_id=$1`, [taskId]);
    expect(row.attempt_count).toBe(1);
  });

  // ... 其他 5 scenarios
});

Step 3: Test run

cd /Users/maxwsy/workspace/studio-website-monorepo/apps/api && bun run integration-test:neon

Expected: 7/7 PASS。

Step 4: Final verification + PR

# 全 suite
cd /Users/maxwsy/workspace/studio-website-monorepo/apps/api && bun run check && bun test && bun run integration-test:neon

# Grep verify
grep -rn "status = 'pending'" apps/api/src/routes/ | grep -v "// transitional"
# Expected: 0 hit

grep -rn "buildContactTimelineInsertSQL" apps/api/src/routes/tasks/{close,reopen,postpone}.ts
# Expected: 0 hit (Orchestrator 内部已封)

grep -rn "task_type" apps/api/src/routes/tasks/
# Expected: 0 hit (字段退役)

如果全 pass,commit + push:

git add apps/api/src/__tests__/integration/tasks-mutations.integration.test.ts
git commit -m "test(api): Phase 1 E2E integration — close/progress/reopen 7 scenarios

Plan 06 Task 9.

- 跑真实测试 Neon
- 验 spec §5.0 invariants 全 hold:
  - tasks 二态(过渡期 pending 也接受)
  - progress 不 close task(attempt_count + 1,status 不变)
  - B8 CTE — 重复 progress 不翻倍 attempt_count
  - B2 — sql.transaction UPDATE + timeline 原子
  - Tenant isolation — 跨 store taskId 404
  - DNC hard stop — record_progress + reopen 拒绝

Spec: normative-spec.md §5.0 + §5.2 + §5.3
"

PR 用 pr skill 写中文 description:

git push -u origin <feature-branch>

# 用 pr skill 生成 body 后:
gh pr create --title "feat(api): Phase 1 — studio-api task mutations 收口到 @retaintive/common/domain" \
  --body-file /tmp/pr-body.md

Self-Review Checklist

Spec coverage

  • ✅ §4a close.ts → Task 2(buildTaskActionSQL + sql.transaction + 拒绝 progress closeResult + 删 FOLLOW_UP 常量 + 删 auto follow-up INSERT)
  • ✅ §4b progress endpoint → Task 5(新建 POST /v2/tasks/:taskId/progress)
  • ✅ §4c reopen.ts → Task 3
  • ✅ §4c postpone.ts → Task 4(action='update')
  • ✅ §4g contacts.ts / leads.ts EXISTS → Task 7
  • ✅ §4h status 字符串 sweep → Task 6
  • ✅ §4h B3 dashboard attempt workload helper → Task 8
  • ✅ §4i task_type / action_needed 字段消费端清理 → Task 6(types.ts)+ Task 2(close.ts 删 INSERT)+ Task 7(SELECT 保留 c.action_needed Phase 2 删)
  • ✅ §5.0 invariant 'progress 不 close task' → Task 5 test #2 + Task 9 scenario #2
  • ✅ §5.0 invariant 'closeResult 纯 outcome' → Task 2 test #3-4
  • ✅ §5.0 invariant 'tenant isolation' → Task 2 test #7 + Task 9 scenario #3
  • ✅ §5.0 invariant 'tasks 二态'(过渡期 3-value 兼容) → Task 6 IN ('pending', 'open')

B2 transaction wrap invariant

  • ✅ Task 2(close): sql.transaction(result.statements.map(s => sql(s.sql, s.params))) — 强制 + atomicity test scenario #8
  • ✅ Task 3(reopen): 同
  • ✅ Task 4(postpone): 同
  • ✅ Task 5(progress): 同

Type consistency

  • buildTaskActionSQL() signature 跟 Plan 02 Task 6 一致:(action: TaskAction, ctx: ActionContext) => Promise<ApplyResultSQL>
  • ✅ Import path @retaintive/common/domain(Plan 02 Task 7 barrel)
  • ApplyResultSQL reject reason 字符串与 Plan 02 types.ts RejectReason union 完全 match(close.ts switch case 引用 13 个 reason 中的 6 个)
  • ✅ Task 5 ProgressEvidence 4 source 跟 Plan 02 Task 2 buildProgressIdempotencyKey 一致(adapter 内部已用,handler 不需要直接调)

No placeholders

  • ✅ 每 task 给具体 file path + line range / new file 标 Create + 完整 path
  • ✅ Test scenarios 全表格列清(无 "write tests for the above")
  • ✅ SQL 写法完整给(EXISTS 子查询 / CTE / status IN 三种 pattern 都有 sample)
  • ✅ 关键 invariant test 给 sample code(Task 2 #8 atomicity / Task 5 #2 idempotency / Task 8 #1 attempt workload)

Execution order dependency

  • Task 1 必须先(bump dep,后续 task 编译依赖)
  • Task 2-5 可并行(独立 endpoint)— 但 Task 5 progress endpoint 的 contract 解释了 Task 2 为何拒绝 progress closeResult,逻辑上 Task 5 在 Task 2 前 explain 上下文更佳;实操可并行
  • Task 6(sweep)依赖 Task 2-5 完成(否则 close/reopen/postpone 还会写 'pending')
  • Task 7 与 Task 6 可并行(改读路径,不影响写路径)
  • Task 8 与 Task 7 可并行(dashboard helper 独立)
  • Task 9 E2E 最后跑(verify 整 chain)

Outstanding

  • ⚠️ auth.staffName 当前 studio-api auth shape 是否含 staffName 字段需 verify(Task 5 progress.ts 引用)— 若无,改用 getAuthorizedStoreNeon 返回的 user info,或要求 body 传 staffName(跟 close 一致)
  • ⚠️ Phase 1.5 前端改造 issue(Status Update 按钮 / Progress UI / status='pending' 硬编码消费)— 不在本 plan,follow-up 单独开
  • ⚠️ Task 8 attempt workload helper 没 wire 进 dashboard endpoint(grep 无现有 attempt metric 用 close_result='attempted')— 预埋,等 PM 决定 Phase 1.5 是否暴露

LoC estimate

TaskLoC
Task 1~5(package.json bump)
Task 2 close.ts~150 改 + ~250 test
Task 3 reopen.ts~120 改 + ~120 test
Task 4 postpone.ts~80 改 + ~100 test
Task 5 progress.ts~180 新 + ~280 test
Task 6 sweep~50 改(8+1 处)+ types.ts ~5
Task 7 contacts/leads~80 改 + ~220 test
Task 8 dashboard helper~50 新 + ~150 test
Task 9 E2E~350 test

总计 ~2200-2500 LoC(含 production code + test)。9 task 拆分匹配规模,subagent 模式每 task 1 fresh session 可控。


Execution Handoff

Option 1: Subagent-Driven(推荐)— 每 Task 一 fresh subagent,review 之间 iterate。9 task 拆分明确,每 task 单独 commit + review。

Option 2: Inlinesuperpowers:executing-plans 跑全 9 task,checkpoint 之间停。Plan 06 是 Phase 1 最大 caller plan,LoC ~2200+,inline 模式 context 易 drift,不推荐

前置 dependency check(开工前 verify):

# Plan 01 schema migration 在测试 Neon 已 apply?
psql $TEST_NEON_URL -c "\d task_progress_events" | head -5  # 表存在 → OK
psql $TEST_NEON_URL -c "SELECT executor_type, attempt_count, source_call_id FROM tasks LIMIT 1"  # 新字段存在 → OK

# Plan 02 published?
cd /Users/maxwsy/workspace/callytics-common && cat package.json | grep version  # 1.2.0 → OK
ls dist/domain/  # index.js + index.d.ts → OK

# studio-api 当前 @retaintive/common 版本
cd /Users/maxwsy/workspace/studio-website-monorepo && grep '"@retaintive/common"' apps/api/package.json

任一 check 不通过 → 不能开工,先 unblock dependency。


Plan 06 结束 = Phase 1 studio-api caller migration 完成;Phase 1 整体只剩 contacts-analyzer / lead-processor / message-processor STOP 三个 Lambda caller(Plans 03-05)+ final schema cutover(Plan 07)。