Skip to content

Commit 2df746c

Browse files
committed
fix: tasks REST assignee end-to-end on numeric assigneeId
The REST tasks API had a 3-way mismatch that silently broke the entire assignee feature for any client going through HTTP (the MCP path was correct): 1. createTaskSchema validated 'assignee: string' but the handler destructured 'assigneeId' from req.body — so the value was always undefined. 2. UI sent 'assignee: <slug-string>' which Zod silently dropped (extra key stripped, no error). 3. Even if the value reached the store, TaskRecord.assigneeId is a numeric FK to team_members.id, but the production /team endpoint returned the config-user slug as 'id' (string), and the team_members table itself was never populated by any production code path. Fix the whole pipeline on numeric IDs: * TeamStore: add upsertBySlug() — idempotent insert/update by slug. * /api/projects/:id/team: source members from config users or .team/{slug}.md files, upsert each into team_members, and return { id: number, slug, name, email }. This finally wires the team_members table into production. * validation.ts: createTaskSchema/updateTaskSchema take assigneeId (number, nullable, optional). taskListSchema takes assigneeId (coerced number). * tasks.ts handler: pass assigneeId straight through, no string coercion. * UI Task type: assignee: string → assigneeId: number | null. * UI TeamMember: id: string → id: number, plus slug: string. * TaskForm / QuickCreateDialog / [taskId].tsx / list.tsx / board.tsx / summary.tsx / new.tsx / groupConfig.ts: Select stores numeric value, all filtering, sorting, grouping and team lookups go through numeric ids. The URL filter param key remains 'assignee' (Number(filters.assignee) at point of use) so existing bookmarks stay functional. * P19.14: end-to-end test that writes .team/qa-bot.md, hits /team to trigger the upsert, creates a task with the resulting numeric id, round-trips on GET, filters via ?assigneeId=N, and exercises null clear / restore via PUT. * P3 'Create task with all optional fields': drop the dead 'assignee: alice' argument that the schema was silently stripping anyway. All 1580 Jest tests + all 499 sandbox tests pass.
1 parent 584f488 commit 2df746c

16 files changed

Lines changed: 206 additions & 86 deletions

File tree

demo-projects/test-sandbox/tests/03-tasks.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -487,7 +487,6 @@ test('Create task with all optional fields', async () => {
487487
tags: ['full', 'test'],
488488
dueDate: now + 86400000,
489489
estimate: 8,
490-
assignee: 'alice',
491490
order: 42,
492491
});
493492
assertOk(res);

demo-projects/test-sandbox/tests/19-coverage-gaps.ts

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
import {
1515
group, test, runPhase,
16-
get, post, del,
16+
get, post, put, del,
1717
mcpCall,
1818
assert, assertEqual, assertExists, assertOk, assertStatus, assertMcpOk,
1919
printSummary, runStandalone, wait,
@@ -680,6 +680,83 @@ test('Cleanup cross-graph file-link fixtures', async () => {
680680
await del(`/tasks/${linkedTaskId}`);
681681
});
682682

683+
// ─── 19.14 Tasks assignee end-to-end (numeric assigneeId) ────────
684+
//
685+
// Verifies the full pipeline:
686+
// .team/{slug}.md → GET /team (upserts into team_members table, returns numeric id)
687+
// POST /tasks { assigneeId } → store
688+
// GET /tasks/:id → assigneeId round-trips
689+
// GET /tasks?assigneeId=N → filter
690+
// Replaces the historically broken `assignee: <slug>` REST field which was
691+
// silently dropped because the validation schema accepted it but the handler
692+
// read a different key.
693+
694+
group('19.14 Tasks assignee end-to-end (numeric assigneeId)');
695+
696+
let assigneeMemberId: number;
697+
let assigneeTaskId: number;
698+
const TEAM_FILE = projectPath('.team', 'qa-bot.md');
699+
700+
test('Setup: write .team/qa-bot.md and trigger /team upsert', async () => {
701+
// Standalone mode reads team members from `.team/{slug}.md` in the project dir.
702+
const { mkdirSync } = await import('fs');
703+
mkdirSync(projectPath('.team'), { recursive: true });
704+
writeFileSync(TEAM_FILE, '---\nname: QA Bot\nemail: qa@test.dev\n---\n# QA Bot\n', 'utf-8');
705+
706+
const res = await get('/team');
707+
assertOk(res);
708+
const members = res.data.results ?? res.data;
709+
assert(Array.isArray(members), 'team is array');
710+
const qa = members.find((m: any) => m.slug === 'qa-bot');
711+
assertExists(qa, 'qa-bot in team listing');
712+
assert(typeof qa.id === 'number' && qa.id > 0, 'team member id is positive number');
713+
assertEqual(qa.name, 'QA Bot', 'name');
714+
assigneeMemberId = qa.id;
715+
});
716+
717+
test('POST /tasks with numeric assigneeId persists the assignment', async () => {
718+
const res = await post('/tasks', {
719+
title: 'Assigned Task',
720+
description: 'has an assignee',
721+
priority: 'medium',
722+
assigneeId: assigneeMemberId,
723+
});
724+
assertOk(res);
725+
assertEqual(res.data.assigneeId, assigneeMemberId, 'assigneeId in create response');
726+
assigneeTaskId = res.data.id;
727+
});
728+
729+
test('GET /tasks/:id round-trips assigneeId', async () => {
730+
const res = await get(`/tasks/${assigneeTaskId}`);
731+
assertOk(res);
732+
assertEqual(res.data.assigneeId, assigneeMemberId, 'assigneeId on read');
733+
});
734+
735+
test('GET /tasks?assigneeId=N filters by numeric assigneeId', async () => {
736+
const res = await get(`/tasks?assigneeId=${assigneeMemberId}`);
737+
assertOk(res);
738+
const items = res.data.results ?? res.data;
739+
assert(Array.isArray(items) && items.length > 0, 'at least one task in filter');
740+
assert(items.every((t: any) => t.assigneeId === assigneeMemberId), 'all results have matching assigneeId');
741+
});
742+
743+
test('PUT /tasks/:id with assigneeId=null unassigns', async () => {
744+
const res = await put(`/tasks/${assigneeTaskId}`, { assigneeId: null });
745+
assertOk(res);
746+
assertEqual(res.data.assigneeId, null, 'assigneeId cleared on update');
747+
});
748+
749+
test('PUT /tasks/:id with assigneeId restores assignment', async () => {
750+
const res = await put(`/tasks/${assigneeTaskId}`, { assigneeId: assigneeMemberId });
751+
assertOk(res);
752+
assertEqual(res.data.assigneeId, assigneeMemberId, 'assigneeId restored');
753+
});
754+
755+
test('Cleanup assignee fixtures', async () => {
756+
await del(`/tasks/${assigneeTaskId}`);
757+
try { unlinkSync(TEAM_FILE); } catch { /* ignore */ }
758+
});
759+
683760
// ─── Run ─────────────────────────────────────────────────────────
684761

685762
export async function run() {

src/api/rest/index.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -405,17 +405,26 @@ export function createRestApp(projectManager: ProjectManager, options?: RestAppO
405405
);
406406
if (!hasAnyAccess) return res.status(403).json({ error: 'Access denied' });
407407
}
408-
// When auth is configured, team = users from config (no .team/ files needed)
408+
// Source members from config (auth) or .team/ markdown (no auth), then sync
409+
// them into the team_members table so the rest of the system has stable
410+
// numeric IDs to reference (tasks.assigneeId is a FK into team_members.id).
411+
type SourceMember = { slug: string; name: string; email: string | null };
412+
let source: SourceMember[];
409413
if (hasUsers) {
410-
const members = Object.entries(users).map(([id, u]) => ({ id, name: u.name, email: u.email }));
411-
return res.json({ results: members });
414+
source = Object.entries(users).map(([slug, u]) => ({ slug, name: u.name, email: u.email ?? null }));
415+
} else {
416+
const p = req.project!;
417+
const ws = p.workspaceId ? projectManager.getWorkspace(p.workspaceId) : undefined;
418+
const baseDir = ws ? ws.config.mirrorDir : p.config.projectDir;
419+
source = scanTeamDir(path.join(baseDir, '.team')).map(m => ({ slug: m.id, name: m.name, email: m.email || null }));
412420
}
413-
// No auth — read from .team/ directory
414-
const p = req.project!;
415-
const ws = p.workspaceId ? projectManager.getWorkspace(p.workspaceId) : undefined;
416-
const baseDir = ws ? ws.config.mirrorDir : p.config.projectDir;
417-
const members = scanTeamDir(path.join(baseDir, '.team'));
418-
res.json({ results: members });
421+
422+
const teamStore = req.project!.storeManager.store.team;
423+
const results = source.map(m => {
424+
const rec = teamStore.upsertBySlug({ slug: m.slug, name: m.name, email: m.email ?? undefined });
425+
return { id: rec.id, slug: rec.slug, name: rec.name, email: rec.email ?? '' };
426+
});
427+
res.json({ results });
419428
});
420429

421430
// Middleware: require a specific manager to be enabled, or return 404

src/api/rest/tasks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ export function createTasksRouter(_users?: Record<string, unknown>): Router {
8585
tags,
8686
dueDate,
8787
estimate,
88-
assigneeId: assigneeId != null ? Number(assigneeId) : undefined,
88+
assigneeId,
8989
order,
9090
});
9191
});

src/api/rest/validation.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import {
88
MAX_SEARCH_QUERY_LEN,
99
MAX_SEARCH_TOP_K,
1010
MAX_DESCRIPTION_LEN,
11-
MAX_ASSIGNEE_LEN,
1211
MAX_SKILL_STEP_LEN,
1312
MAX_SKILL_STEPS_COUNT,
1413
MAX_SKILL_TRIGGER_LEN,
@@ -95,7 +94,7 @@ export const createTaskSchema = z.object({
9594
tags: z.array(z.string().max(MAX_TAG_LEN)).max(MAX_TAGS_COUNT).optional().default([]),
9695
dueDate: z.number().nullable().optional(),
9796
estimate: z.number().nullable().optional(),
98-
assignee: z.string().max(MAX_ASSIGNEE_LEN).nullable().optional(),
97+
assigneeId: z.number().int().positive().nullable().optional(),
9998
order: z.number().int().optional(),
10099
});
101100

@@ -108,7 +107,7 @@ export const updateTaskSchema = z.object({
108107
order: z.number().int().optional(),
109108
dueDate: z.number().nullable().optional(),
110109
estimate: z.number().nullable().optional(),
111-
assignee: z.string().max(MAX_ASSIGNEE_LEN).nullable().optional(),
110+
assigneeId: z.number().int().positive().nullable().optional(),
112111
version: z.number().int().positive().optional(),
113112
});
114113

@@ -156,13 +155,13 @@ export const taskSearchSchema = z.object({
156155
});
157156

158157
export const taskListSchema = z.object({
159-
status: z.enum(['backlog', 'todo', 'in_progress', 'review', 'done', 'cancelled']).optional(),
160-
priority: z.enum(['critical', 'high', 'medium', 'low']).optional(),
161-
tag: z.string().max(MAX_TAG_LEN).optional(),
162-
filter: z.string().max(500).optional(),
163-
assignee: z.string().max(MAX_ASSIGNEE_LEN).optional(),
164-
limit: z.coerce.number().int().positive().max(1000).optional(),
165-
offset: z.coerce.number().int().min(0).max(100_000).optional().default(0),
158+
status: z.enum(['backlog', 'todo', 'in_progress', 'review', 'done', 'cancelled']).optional(),
159+
priority: z.enum(['critical', 'high', 'medium', 'low']).optional(),
160+
tag: z.string().max(MAX_TAG_LEN).optional(),
161+
filter: z.string().max(500).optional(),
162+
assigneeId: z.coerce.number().int().positive().optional(),
163+
limit: z.coerce.number().int().positive().max(1000).optional(),
164+
offset: z.coerce.number().int().min(0).max(100_000).optional().default(0),
166165
});
167166

168167
// ---------------------------------------------------------------------------

src/store/sqlite/stores/team.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,20 @@ export class SqliteTeamStore implements TeamStore {
8484
return row ? this.toRecord(row) : null;
8585
}
8686

87+
upsertBySlug(data: TeamMemberCreate): TeamMemberRecord {
88+
const existing = this.getBySlug(data.slug);
89+
if (existing) {
90+
// Only patch fields that differ to avoid bumping updated_at unnecessarily.
91+
const patch: TeamMemberPatch = {};
92+
if (data.name !== existing.name) patch.name = data.name;
93+
if ((data.email ?? null) !== existing.email) patch.email = data.email;
94+
if ((data.role ?? null) !== existing.role) patch.role = data.role;
95+
if (Object.keys(patch).length > 0) return this.update(existing.id, patch);
96+
return existing;
97+
}
98+
return this.create(data);
99+
}
100+
87101
list(pagination?: PaginationOptions): { results: TeamMemberRecord[]; total: number } {
88102
const limit = pagination?.limit ?? 50;
89103
const offset = pagination?.offset ?? 0;

src/store/types/team.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,5 +33,13 @@ export interface TeamStore extends MetaMixin {
3333
delete(memberId: number): void;
3434
get(memberId: number): TeamMemberRecord | null;
3535
getBySlug(slug: string): TeamMemberRecord | null;
36+
/**
37+
* Idempotent insert/update by slug. If a member with the slug exists, updates
38+
* name/email/role from data; otherwise creates a new row. Returns the resulting
39+
* record (with numeric id). Used to sync external sources (config users, .team/
40+
* markdown files) into the database so the rest of the system has stable
41+
* numeric IDs to reference.
42+
*/
43+
upsertBySlug(data: TeamMemberCreate): TeamMemberRecord;
3644
list(pagination?: PaginationOptions): { results: TeamMemberRecord[]; total: number };
3745
}

ui/src/content/help/guides/task-tools.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ Tasks here are tightly integrated with your project's knowledge graph:
4242
| `tags` | string[] | Free-form | For filtering |
4343
| `dueDate` | number | Unix timestamp in milliseconds | Optional deadline |
4444
| `estimate` | number | Hours | Optional effort estimate |
45-
| `assignee` | string \| null | Team member ID | Optional assignee from `users:` config |
45+
| `assigneeId` | number \| null | Numeric team member id (from `/api/projects/:id/team`) | Optional assignee |
4646
| `completedAt` | number | Unix timestamp (auto-managed) | Set on done/cancelled, cleared on reopen |
4747
| `createdAt` | number | Unix timestamp (auto) | Set at creation |
4848
| `updatedAt` | number | Unix timestamp (auto) | Updated on every change |
@@ -68,7 +68,7 @@ Create a new task. Automatically embedded for semantic search.
6868
| `tags` | string[] | No | `[]` | Tags for filtering |
6969
| `dueDate` | number | No || Due date as Unix timestamp in milliseconds |
7070
| `estimate` | number | No || Estimated effort in hours |
71-
| `assignee` | string | No || Team member ID to assign the task to |
71+
| `assigneeId` | number | No || Numeric team member id to assign the task to |
7272

7373
**Returns:** `{ taskId }`
7474

@@ -86,7 +86,7 @@ Update an existing task. Only provided fields change. Re-embeds if title or desc
8686
| `tags` | string[] | No | Replace tags array (include all you want to keep) |
8787
| `dueDate` | number \| null | No | New due date (ms timestamp), or `null` to clear |
8888
| `estimate` | number \| null | No | New estimate (hours), or `null` to clear |
89-
| `assignee` | string \| null | No | Team member ID to assign, or `null` to unassign |
89+
| `assigneeId` | number \| null | No | Numeric team member id to assign, or `null` to unassign |
9090

9191
**Returns:** `{ taskId, updated: true }`
9292

@@ -113,7 +113,7 @@ Return full task details including all relations. This is the most complete view
113113
**Returns:**
114114
```
115115
{
116-
id, title, description, status, priority, tags, assignee,
116+
id, title, description, status, priority, tags, assigneeId,
117117
dueDate, estimate, completedAt, createdAt, updatedAt,
118118
subtasks: [{ id, title, status }],
119119
blockedBy: [{ id, title, status }],
@@ -134,10 +134,10 @@ List tasks with optional filters. Sorted by priority (critical → low) then due
134134
| `priority` | enum | No || Filter by priority |
135135
| `tag` | string | No || Filter by tag (exact match, case-insensitive) |
136136
| `filter` | string | No || Substring match on title or ID |
137-
| `assignee` | string | No || Filter by assignee (team member ID) |
137+
| `assigneeId` | number | No || Filter by numeric team member id |
138138
| `limit` | number | No | 50 | Maximum results |
139139

140-
**Returns:** `[{ id, title, description, status, priority, tags, dueDate, estimate, assignee, completedAt, createdAt, updatedAt }]`
140+
**Returns:** `[{ id, title, description, status, priority, tags, dueDate, estimate, assigneeId, completedAt, createdAt, updatedAt }]`
141141

142142
### tasks_search
143143

ui/src/entities/project/api.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,10 @@ export function getProjectStats(projectId: string) {
4141
}
4242

4343
export interface TeamMember {
44-
id: string;
44+
/** Numeric id from the team_members table — stable across slug renames. */
45+
id: number;
46+
/** Human-readable identifier (matches user config key or .team/{slug}.md filename). */
47+
slug: string;
4548
name: string;
4649
email: string;
4750
}

ui/src/entities/task/api.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,30 +14,30 @@ export interface Task {
1414
dueDate: number | null;
1515
estimate: number | null;
1616
completedAt: number | null;
17-
assignee: string | null;
17+
assigneeId: number | null;
1818
createdAt: number;
1919
updatedAt: number;
2020
version: number;
2121
createdBy?: string;
2222
updatedBy?: string;
2323
}
2424

25-
export function listTasks(projectId: string, params?: { status?: TaskStatus; priority?: TaskPriority; tag?: string; assignee?: string; limit?: number; offset?: number }) {
26-
return request<PaginatedResponse<Task>>(`/projects/${projectId}/tasks${qs({ status: params?.status, priority: params?.priority, tag: params?.tag, assignee: params?.assignee, limit: params?.limit, offset: params?.offset })}`).then(unwrapPaginated);
25+
export function listTasks(projectId: string, params?: { status?: TaskStatus; priority?: TaskPriority; tag?: string; assigneeId?: number; limit?: number; offset?: number }) {
26+
return request<PaginatedResponse<Task>>(`/projects/${projectId}/tasks${qs({ status: params?.status, priority: params?.priority, tag: params?.tag, assigneeId: params?.assigneeId, limit: params?.limit, offset: params?.offset })}`).then(unwrapPaginated);
2727
}
2828

2929
export function getTask(projectId: string, taskId: string) {
3030
return request<Task>(`/projects/${projectId}/tasks/${taskId}`);
3131
}
3232

33-
export function createTask(projectId: string, data: { title: string; description?: string; status?: TaskStatus; priority?: TaskPriority; tags?: string[]; dueDate?: number | null; estimate?: number | null; assignee?: string | null }) {
33+
export function createTask(projectId: string, data: { title: string; description?: string; status?: TaskStatus; priority?: TaskPriority; tags?: string[]; dueDate?: number | null; estimate?: number | null; assigneeId?: number | null }) {
3434
return request<Task>(`/projects/${projectId}/tasks`, {
3535
method: 'POST',
3636
body: JSON.stringify(data),
3737
});
3838
}
3939

40-
export function updateTask(projectId: string, taskId: string, data: Partial<Pick<Task, 'title' | 'description' | 'status' | 'priority' | 'tags' | 'dueDate' | 'estimate' | 'assignee'>>) {
40+
export function updateTask(projectId: string, taskId: string, data: Partial<Pick<Task, 'title' | 'description' | 'status' | 'priority' | 'tags' | 'dueDate' | 'estimate' | 'assigneeId'>>) {
4141
return request<Task>(`/projects/${projectId}/tasks/${taskId}`, {
4242
method: 'PUT',
4343
body: JSON.stringify(data),

0 commit comments

Comments
 (0)