Nyk commited on
Commit
64eac97
·
1 Parent(s): c25e0fa

feat: add workspace discoverability and multi-project task support

Browse files
README.md CHANGED
@@ -365,6 +365,32 @@ See [`.env.example`](.env.example) for the complete list. Key variables:
365
  > OPENCLAW_MEMORY_DIR=/home/you/clawd-agents
366
  > ```
367
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  ## Deployment
369
 
370
  ```bash
 
365
  > OPENCLAW_MEMORY_DIR=/home/you/clawd-agents
366
  > ```
367
 
368
+ ### Workspace Creation Flow
369
+
370
+ To add a new workspace/client instance in the UI:
371
+
372
+ 1. Open `Workspaces` from the left navigation.
373
+ 2. Expand `Show Create Client Instance`.
374
+ 3. Fill tenant/workspace fields (`slug`, `display_name`, optional ports/gateway owner).
375
+ 4. Click `Create + Queue`.
376
+ 5. Approve/run the generated provisioning job in the same panel.
377
+
378
+ `Workspaces` and `Super Admin` currently point to the same provisioning control plane.
379
+
380
+ ### Projects and Ticket Prefixes
381
+
382
+ Mission Control supports multi-project task organization per workspace:
383
+
384
+ - Create/manage projects via Task Board → `Projects`.
385
+ - Each project has its own ticket prefix and counter.
386
+ - New tasks receive project-scoped ticket refs like `PA-001`, `PA-002`.
387
+ - Task board supports filtering by project.
388
+
389
+ ### Memory Scope Clarification
390
+
391
+ - **Agent profile → Memory tab**: per-agent working memory stored in Mission Control DB (`working_memory`).
392
+ - **Memory Browser page**: workspace/local filesystem memory tree under `OPENCLAW_MEMORY_DIR`.
393
+
394
  ## Deployment
395
 
396
  ```bash
src/app/[[...panel]]/page.tsx CHANGED
@@ -261,6 +261,8 @@ function ContentRouter({ tab }: { tab: string }) {
261
  return <OfficePanel />
262
  case 'super-admin':
263
  return <SuperAdminPanel />
 
 
264
  default:
265
  return <Dashboard />
266
  }
 
261
  return <OfficePanel />
262
  case 'super-admin':
263
  return <SuperAdminPanel />
264
+ case 'workspaces':
265
+ return <SuperAdminPanel />
266
  default:
267
  return <Dashboard />
268
  }
src/app/api/agents/[id]/memory/route.ts CHANGED
@@ -6,8 +6,8 @@ import { logger } from '@/lib/logger';
6
  /**
7
  * GET /api/agents/[id]/memory - Get agent's working memory
8
  *
9
- * Working memory is stored as WORKING.md content in the database
10
- * Each agent has their own working memory space for temporary notes
11
  */
12
  export async function GET(
13
  request: NextRequest,
 
6
  /**
7
  * GET /api/agents/[id]/memory - Get agent's working memory
8
  *
9
+ * Working memory is stored in the agents.working_memory DB column.
10
+ * This endpoint is per-agent scratchpad memory (not the global Memory Browser filesystem view).
11
  */
12
  export async function GET(
13
  request: NextRequest,
src/app/api/projects/[id]/route.ts ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import { getDatabase } from '@/lib/db'
3
+ import { requireRole } from '@/lib/auth'
4
+ import { mutationLimiter } from '@/lib/rate-limit'
5
+ import { logger } from '@/lib/logger'
6
+
7
+ function normalizePrefix(input: string): string {
8
+ const normalized = input.trim().toUpperCase().replace(/[^A-Z0-9]/g, '')
9
+ return normalized.slice(0, 12)
10
+ }
11
+
12
+ function toProjectId(raw: string): number {
13
+ const id = Number.parseInt(raw, 10)
14
+ return Number.isFinite(id) ? id : NaN
15
+ }
16
+
17
+ export async function GET(
18
+ request: NextRequest,
19
+ { params }: { params: Promise<{ id: string }> }
20
+ ) {
21
+ const auth = requireRole(request, 'viewer')
22
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
23
+
24
+ try {
25
+ const db = getDatabase()
26
+ const workspaceId = auth.user.workspace_id ?? 1
27
+ const { id } = await params
28
+ const projectId = toProjectId(id)
29
+ if (Number.isNaN(projectId)) return NextResponse.json({ error: 'Invalid project ID' }, { status: 400 })
30
+
31
+ const project = db.prepare(`
32
+ SELECT id, workspace_id, name, slug, description, ticket_prefix, ticket_counter, status, created_at, updated_at
33
+ FROM projects
34
+ WHERE id = ? AND workspace_id = ?
35
+ `).get(projectId, workspaceId)
36
+ if (!project) return NextResponse.json({ error: 'Project not found' }, { status: 404 })
37
+
38
+ return NextResponse.json({ project })
39
+ } catch (error) {
40
+ logger.error({ err: error }, 'GET /api/projects/[id] error')
41
+ return NextResponse.json({ error: 'Failed to fetch project' }, { status: 500 })
42
+ }
43
+ }
44
+
45
+ export async function PATCH(
46
+ request: NextRequest,
47
+ { params }: { params: Promise<{ id: string }> }
48
+ ) {
49
+ const auth = requireRole(request, 'operator')
50
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
51
+
52
+ const rateCheck = mutationLimiter(request)
53
+ if (rateCheck) return rateCheck
54
+
55
+ try {
56
+ const db = getDatabase()
57
+ const workspaceId = auth.user.workspace_id ?? 1
58
+ const { id } = await params
59
+ const projectId = toProjectId(id)
60
+ if (Number.isNaN(projectId)) return NextResponse.json({ error: 'Invalid project ID' }, { status: 400 })
61
+
62
+ const current = db.prepare(`SELECT * FROM projects WHERE id = ? AND workspace_id = ?`).get(projectId, workspaceId) as any
63
+ if (!current) return NextResponse.json({ error: 'Project not found' }, { status: 404 })
64
+ if (current.slug === 'general' && current.workspace_id === workspaceId && current.id === projectId) {
65
+ const body = await request.json()
66
+ if (body?.status === 'archived') {
67
+ return NextResponse.json({ error: 'Default project cannot be archived' }, { status: 400 })
68
+ }
69
+ }
70
+
71
+ const body = await request.json()
72
+ const updates: string[] = []
73
+ const paramsList: Array<string | number | null> = []
74
+
75
+ if (typeof body?.name === 'string') {
76
+ const name = body.name.trim()
77
+ if (!name) return NextResponse.json({ error: 'Project name cannot be empty' }, { status: 400 })
78
+ updates.push('name = ?')
79
+ paramsList.push(name)
80
+ }
81
+ if (typeof body?.description === 'string') {
82
+ updates.push('description = ?')
83
+ paramsList.push(body.description.trim() || null)
84
+ }
85
+ if (typeof body?.ticket_prefix === 'string' || typeof body?.ticketPrefix === 'string') {
86
+ const raw = String(body.ticket_prefix ?? body.ticketPrefix)
87
+ const prefix = normalizePrefix(raw)
88
+ if (!prefix) return NextResponse.json({ error: 'Invalid ticket prefix' }, { status: 400 })
89
+ const conflict = db.prepare(`
90
+ SELECT id FROM projects
91
+ WHERE workspace_id = ? AND ticket_prefix = ? AND id != ?
92
+ `).get(workspaceId, prefix, projectId)
93
+ if (conflict) return NextResponse.json({ error: 'Ticket prefix already in use' }, { status: 409 })
94
+ updates.push('ticket_prefix = ?')
95
+ paramsList.push(prefix)
96
+ }
97
+ if (typeof body?.status === 'string') {
98
+ const status = body.status === 'archived' ? 'archived' : 'active'
99
+ updates.push('status = ?')
100
+ paramsList.push(status)
101
+ }
102
+
103
+ if (updates.length === 0) return NextResponse.json({ error: 'No fields to update' }, { status: 400 })
104
+
105
+ updates.push('updated_at = unixepoch()')
106
+ db.prepare(`
107
+ UPDATE projects
108
+ SET ${updates.join(', ')}
109
+ WHERE id = ? AND workspace_id = ?
110
+ `).run(...paramsList, projectId, workspaceId)
111
+
112
+ const project = db.prepare(`
113
+ SELECT id, workspace_id, name, slug, description, ticket_prefix, ticket_counter, status, created_at, updated_at
114
+ FROM projects
115
+ WHERE id = ? AND workspace_id = ?
116
+ `).get(projectId, workspaceId)
117
+
118
+ return NextResponse.json({ project })
119
+ } catch (error) {
120
+ logger.error({ err: error }, 'PATCH /api/projects/[id] error')
121
+ return NextResponse.json({ error: 'Failed to update project' }, { status: 500 })
122
+ }
123
+ }
124
+
125
+ export async function DELETE(
126
+ request: NextRequest,
127
+ { params }: { params: Promise<{ id: string }> }
128
+ ) {
129
+ const auth = requireRole(request, 'admin')
130
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
131
+
132
+ const rateCheck = mutationLimiter(request)
133
+ if (rateCheck) return rateCheck
134
+
135
+ try {
136
+ const db = getDatabase()
137
+ const workspaceId = auth.user.workspace_id ?? 1
138
+ const { id } = await params
139
+ const projectId = toProjectId(id)
140
+ if (Number.isNaN(projectId)) return NextResponse.json({ error: 'Invalid project ID' }, { status: 400 })
141
+
142
+ const current = db.prepare(`SELECT * FROM projects WHERE id = ? AND workspace_id = ?`).get(projectId, workspaceId) as any
143
+ if (!current) return NextResponse.json({ error: 'Project not found' }, { status: 404 })
144
+ if (current.slug === 'general') {
145
+ return NextResponse.json({ error: 'Default project cannot be deleted' }, { status: 400 })
146
+ }
147
+
148
+ const mode = new URL(request.url).searchParams.get('mode') || 'archive'
149
+ if (mode !== 'delete') {
150
+ db.prepare(`UPDATE projects SET status = 'archived', updated_at = unixepoch() WHERE id = ? AND workspace_id = ?`).run(projectId, workspaceId)
151
+ return NextResponse.json({ success: true, mode: 'archive' })
152
+ }
153
+
154
+ const fallback = db.prepare(`
155
+ SELECT id FROM projects
156
+ WHERE workspace_id = ? AND slug = 'general'
157
+ LIMIT 1
158
+ `).get(workspaceId) as { id: number } | undefined
159
+ if (!fallback) return NextResponse.json({ error: 'Default project missing' }, { status: 500 })
160
+
161
+ const tx = db.transaction(() => {
162
+ db.prepare(`
163
+ UPDATE tasks
164
+ SET project_id = ?
165
+ WHERE workspace_id = ? AND project_id = ?
166
+ `).run(fallback.id, workspaceId, projectId)
167
+
168
+ db.prepare(`DELETE FROM projects WHERE id = ? AND workspace_id = ?`).run(projectId, workspaceId)
169
+ })
170
+ tx()
171
+
172
+ return NextResponse.json({ success: true, mode: 'delete' })
173
+ } catch (error) {
174
+ logger.error({ err: error }, 'DELETE /api/projects/[id] error')
175
+ return NextResponse.json({ error: 'Failed to delete project' }, { status: 500 })
176
+ }
177
+ }
src/app/api/projects/[id]/tasks/route.ts ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import { getDatabase } from '@/lib/db'
3
+ import { requireRole } from '@/lib/auth'
4
+ import { logger } from '@/lib/logger'
5
+
6
+ function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
7
+ if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined
8
+ return `${prefix}-${String(num).padStart(3, '0')}`
9
+ }
10
+
11
+ export async function GET(
12
+ request: NextRequest,
13
+ { params }: { params: Promise<{ id: string }> }
14
+ ) {
15
+ const auth = requireRole(request, 'viewer')
16
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
17
+
18
+ try {
19
+ const db = getDatabase()
20
+ const workspaceId = auth.user.workspace_id ?? 1
21
+ const { id } = await params
22
+ const projectId = Number.parseInt(id, 10)
23
+ if (!Number.isFinite(projectId)) {
24
+ return NextResponse.json({ error: 'Invalid project ID' }, { status: 400 })
25
+ }
26
+
27
+ const project = db.prepare(`
28
+ SELECT id, workspace_id, name, slug, description, ticket_prefix, ticket_counter, status, created_at, updated_at
29
+ FROM projects
30
+ WHERE id = ? AND workspace_id = ?
31
+ `).get(projectId, workspaceId)
32
+ if (!project) return NextResponse.json({ error: 'Project not found' }, { status: 404 })
33
+
34
+ const tasks = db.prepare(`
35
+ SELECT t.*, p.name as project_name, p.ticket_prefix as project_prefix
36
+ FROM tasks t
37
+ LEFT JOIN projects p ON p.id = t.project_id AND p.workspace_id = t.workspace_id
38
+ WHERE t.workspace_id = ? AND t.project_id = ?
39
+ ORDER BY t.created_at DESC
40
+ `).all(workspaceId, projectId)
41
+
42
+ return NextResponse.json({
43
+ project,
44
+ tasks: tasks.map((task: any) => ({
45
+ ...task,
46
+ tags: task.tags ? JSON.parse(task.tags) : [],
47
+ metadata: task.metadata ? JSON.parse(task.metadata) : {},
48
+ ticket_ref: formatTicketRef(task.project_prefix, task.project_ticket_no),
49
+ }))
50
+ })
51
+ } catch (error) {
52
+ logger.error({ err: error }, 'GET /api/projects/[id]/tasks error')
53
+ return NextResponse.json({ error: 'Failed to fetch project tasks' }, { status: 500 })
54
+ }
55
+ }
src/app/api/projects/route.ts ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import { getDatabase } from '@/lib/db'
3
+ import { requireRole } from '@/lib/auth'
4
+ import { mutationLimiter } from '@/lib/rate-limit'
5
+ import { logger } from '@/lib/logger'
6
+
7
+ function slugify(input: string): string {
8
+ return input
9
+ .trim()
10
+ .toLowerCase()
11
+ .replace(/[^a-z0-9]+/g, '-')
12
+ .replace(/^-+|-+$/g, '')
13
+ .slice(0, 64)
14
+ }
15
+
16
+ function normalizePrefix(input: string): string {
17
+ const normalized = input.trim().toUpperCase().replace(/[^A-Z0-9]/g, '')
18
+ return normalized.slice(0, 12)
19
+ }
20
+
21
+ export async function GET(request: NextRequest) {
22
+ const auth = requireRole(request, 'viewer')
23
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
24
+
25
+ try {
26
+ const db = getDatabase()
27
+ const workspaceId = auth.user.workspace_id ?? 1
28
+ const includeArchived = new URL(request.url).searchParams.get('includeArchived') === '1'
29
+
30
+ const projects = db.prepare(`
31
+ SELECT id, workspace_id, name, slug, description, ticket_prefix, ticket_counter, status, created_at, updated_at
32
+ FROM projects
33
+ WHERE workspace_id = ?
34
+ ${includeArchived ? '' : "AND status = 'active'"}
35
+ ORDER BY name COLLATE NOCASE ASC
36
+ `).all(workspaceId)
37
+
38
+ return NextResponse.json({ projects })
39
+ } catch (error) {
40
+ logger.error({ err: error }, 'GET /api/projects error')
41
+ return NextResponse.json({ error: 'Failed to fetch projects' }, { status: 500 })
42
+ }
43
+ }
44
+
45
+ export async function POST(request: NextRequest) {
46
+ const auth = requireRole(request, 'operator')
47
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
48
+
49
+ const rateCheck = mutationLimiter(request)
50
+ if (rateCheck) return rateCheck
51
+
52
+ try {
53
+ const db = getDatabase()
54
+ const workspaceId = auth.user.workspace_id ?? 1
55
+ const body = await request.json()
56
+
57
+ const name = String(body?.name || '').trim()
58
+ const description = typeof body?.description === 'string' ? body.description.trim() : ''
59
+ const prefixInput = String(body?.ticket_prefix || body?.ticketPrefix || '').trim()
60
+ const slugInput = String(body?.slug || '').trim()
61
+
62
+ if (!name) return NextResponse.json({ error: 'Project name is required' }, { status: 400 })
63
+
64
+ const slug = slugInput ? slugify(slugInput) : slugify(name)
65
+ const ticketPrefix = normalizePrefix(prefixInput || name.slice(0, 5))
66
+ if (!slug) return NextResponse.json({ error: 'Invalid project slug' }, { status: 400 })
67
+ if (!ticketPrefix) return NextResponse.json({ error: 'Invalid ticket prefix' }, { status: 400 })
68
+
69
+ const exists = db.prepare(`
70
+ SELECT id FROM projects
71
+ WHERE workspace_id = ? AND (slug = ? OR ticket_prefix = ?)
72
+ LIMIT 1
73
+ `).get(workspaceId, slug, ticketPrefix) as { id: number } | undefined
74
+ if (exists) {
75
+ return NextResponse.json({ error: 'Project slug or ticket prefix already exists' }, { status: 409 })
76
+ }
77
+
78
+ const result = db.prepare(`
79
+ INSERT INTO projects (workspace_id, name, slug, description, ticket_prefix, status, created_at, updated_at)
80
+ VALUES (?, ?, ?, ?, ?, 'active', unixepoch(), unixepoch())
81
+ `).run(workspaceId, name, slug, description || null, ticketPrefix)
82
+
83
+ const project = db.prepare(`
84
+ SELECT id, workspace_id, name, slug, description, ticket_prefix, ticket_counter, status, created_at, updated_at
85
+ FROM projects
86
+ WHERE id = ?
87
+ `).get(Number(result.lastInsertRowid))
88
+
89
+ return NextResponse.json({ project }, { status: 201 })
90
+ } catch (error) {
91
+ logger.error({ err: error }, 'POST /api/projects error')
92
+ return NextResponse.json({ error: 'Failed to create project' }, { status: 500 })
93
+ }
94
+ }
src/app/api/tasks/[id]/route.ts CHANGED
@@ -6,6 +6,20 @@ import { mutationLimiter } from '@/lib/rate-limit';
6
  import { logger } from '@/lib/logger';
7
  import { validateBody, updateTaskSchema } from '@/lib/validation';
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  function hasAegisApproval(
10
  db: ReturnType<typeof getDatabase>,
11
  taskId: number,
@@ -40,7 +54,12 @@ export async function GET(
40
  return NextResponse.json({ error: 'Invalid task ID' }, { status: 400 });
41
  }
42
 
43
- const stmt = db.prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?');
 
 
 
 
 
44
  const task = stmt.get(taskId, workspaceId) as Task;
45
 
46
  if (!task) {
@@ -48,11 +67,7 @@ export async function GET(
48
  }
49
 
50
  // Parse JSON fields
51
- const taskWithParsedData = {
52
- ...task,
53
- tags: task.tags ? JSON.parse(task.tags) : [],
54
- metadata: task.metadata ? JSON.parse(task.metadata) : {}
55
- };
56
 
57
  return NextResponse.json({ task: taskWithParsedData });
58
  } catch (error) {
@@ -101,6 +116,7 @@ export async function PUT(
101
  description,
102
  status,
103
  priority,
 
104
  assigned_to,
105
  due_date,
106
  estimated_hours,
@@ -114,6 +130,7 @@ export async function PUT(
114
  // Build dynamic update query
115
  const fieldsToUpdate = [];
116
  const updateParams: any[] = [];
 
117
 
118
  if (title !== undefined) {
119
  fieldsToUpdate.push('title = ?');
@@ -137,6 +154,36 @@ export async function PUT(
137
  fieldsToUpdate.push('priority = ?');
138
  updateParams.push(priority);
139
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  if (assigned_to !== undefined) {
141
  fieldsToUpdate.push('assigned_to = ?');
142
  updateParams.push(assigned_to);
@@ -223,6 +270,10 @@ export async function PUT(
223
  if (priority && priority !== currentTask.priority) {
224
  changes.push(`priority: ${currentTask.priority} → ${priority}`);
225
  }
 
 
 
 
226
 
227
  // Log activity if there were meaningful changes
228
  if (changes.length > 0) {
@@ -247,14 +298,13 @@ export async function PUT(
247
  }
248
 
249
  // Fetch updated task
250
- const updatedTask = db
251
- .prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?')
252
- .get(taskId, workspaceId) as Task;
253
- const parsedTask = {
254
- ...updatedTask,
255
- tags: updatedTask.tags ? JSON.parse(updatedTask.tags) : [],
256
- metadata: updatedTask.metadata ? JSON.parse(updatedTask.metadata) : {}
257
- };
258
 
259
  // Broadcast to SSE clients
260
  eventBus.broadcast('task.updated', parsedTask);
 
6
  import { logger } from '@/lib/logger';
7
  import { validateBody, updateTaskSchema } from '@/lib/validation';
8
 
9
+ function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
10
+ if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined
11
+ return `${prefix}-${String(num).padStart(3, '0')}`
12
+ }
13
+
14
+ function mapTaskRow(task: any): Task & { tags: string[]; metadata: Record<string, unknown> } {
15
+ return {
16
+ ...task,
17
+ tags: task.tags ? JSON.parse(task.tags) : [],
18
+ metadata: task.metadata ? JSON.parse(task.metadata) : {},
19
+ ticket_ref: formatTicketRef(task.project_prefix, task.project_ticket_no),
20
+ }
21
+ }
22
+
23
  function hasAegisApproval(
24
  db: ReturnType<typeof getDatabase>,
25
  taskId: number,
 
54
  return NextResponse.json({ error: 'Invalid task ID' }, { status: 400 });
55
  }
56
 
57
+ const stmt = db.prepare(`
58
+ SELECT t.*, p.name as project_name, p.ticket_prefix as project_prefix
59
+ FROM tasks t
60
+ LEFT JOIN projects p ON p.id = t.project_id AND p.workspace_id = t.workspace_id
61
+ WHERE t.id = ? AND t.workspace_id = ?
62
+ `);
63
  const task = stmt.get(taskId, workspaceId) as Task;
64
 
65
  if (!task) {
 
67
  }
68
 
69
  // Parse JSON fields
70
+ const taskWithParsedData = mapTaskRow(task);
 
 
 
 
71
 
72
  return NextResponse.json({ task: taskWithParsedData });
73
  } catch (error) {
 
116
  description,
117
  status,
118
  priority,
119
+ project_id,
120
  assigned_to,
121
  due_date,
122
  estimated_hours,
 
130
  // Build dynamic update query
131
  const fieldsToUpdate = [];
132
  const updateParams: any[] = [];
133
+ let nextProjectTicketNo: number | null = null;
134
 
135
  if (title !== undefined) {
136
  fieldsToUpdate.push('title = ?');
 
154
  fieldsToUpdate.push('priority = ?');
155
  updateParams.push(priority);
156
  }
157
+ if (project_id !== undefined) {
158
+ const project = db.prepare(`
159
+ SELECT id FROM projects
160
+ WHERE id = ? AND workspace_id = ? AND status = 'active'
161
+ `).get(project_id, workspaceId) as { id: number } | undefined
162
+ if (!project) {
163
+ return NextResponse.json({ error: 'Project not found or archived' }, { status: 400 })
164
+ }
165
+ if (project_id !== currentTask.project_id) {
166
+ db.prepare(`
167
+ UPDATE projects
168
+ SET ticket_counter = ticket_counter + 1, updated_at = unixepoch()
169
+ WHERE id = ? AND workspace_id = ?
170
+ `).run(project_id, workspaceId)
171
+ const row = db.prepare(`
172
+ SELECT ticket_counter FROM projects
173
+ WHERE id = ? AND workspace_id = ?
174
+ `).get(project_id, workspaceId) as { ticket_counter: number } | undefined
175
+ if (!row || !row.ticket_counter) {
176
+ return NextResponse.json({ error: 'Failed to allocate project ticket number' }, { status: 500 })
177
+ }
178
+ nextProjectTicketNo = row.ticket_counter
179
+ }
180
+ fieldsToUpdate.push('project_id = ?');
181
+ updateParams.push(project_id);
182
+ if (nextProjectTicketNo !== null) {
183
+ fieldsToUpdate.push('project_ticket_no = ?');
184
+ updateParams.push(nextProjectTicketNo);
185
+ }
186
+ }
187
  if (assigned_to !== undefined) {
188
  fieldsToUpdate.push('assigned_to = ?');
189
  updateParams.push(assigned_to);
 
270
  if (priority && priority !== currentTask.priority) {
271
  changes.push(`priority: ${currentTask.priority} → ${priority}`);
272
  }
273
+
274
+ if (project_id !== undefined && project_id !== currentTask.project_id) {
275
+ changes.push(`project: ${currentTask.project_id || 'none'} → ${project_id}`);
276
+ }
277
 
278
  // Log activity if there were meaningful changes
279
  if (changes.length > 0) {
 
298
  }
299
 
300
  // Fetch updated task
301
+ const updatedTask = db.prepare(`
302
+ SELECT t.*, p.name as project_name, p.ticket_prefix as project_prefix
303
+ FROM tasks t
304
+ LEFT JOIN projects p ON p.id = t.project_id AND p.workspace_id = t.workspace_id
305
+ WHERE t.id = ? AND t.workspace_id = ?
306
+ `).get(taskId, workspaceId) as Task;
307
+ const parsedTask = mapTaskRow(updatedTask);
 
308
 
309
  // Broadcast to SSE clients
310
  eventBus.broadcast('task.updated', parsedTask);
src/app/api/tasks/route.ts CHANGED
@@ -6,6 +6,43 @@ import { mutationLimiter } from '@/lib/rate-limit';
6
  import { logger } from '@/lib/logger';
7
  import { validateBody, createTaskSchema, bulkUpdateTaskStatusSchema } from '@/lib/validation';
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  function hasAegisApproval(db: ReturnType<typeof getDatabase>, taskId: number, workspaceId: number): boolean {
10
  const review = db.prepare(`
11
  SELECT status FROM quality_reviews
@@ -18,7 +55,7 @@ function hasAegisApproval(db: ReturnType<typeof getDatabase>, taskId: number, wo
18
 
19
  /**
20
  * GET /api/tasks - List all tasks with optional filtering
21
- * Query params: status, assigned_to, priority, limit, offset
22
  */
23
  export async function GET(request: NextRequest) {
24
  const auth = requireRole(request, 'viewer');
@@ -33,40 +70,48 @@ export async function GET(request: NextRequest) {
33
  const status = searchParams.get('status');
34
  const assigned_to = searchParams.get('assigned_to');
35
  const priority = searchParams.get('priority');
 
36
  const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200);
37
  const offset = parseInt(searchParams.get('offset') || '0');
38
 
39
  // Build dynamic query
40
- let query = 'SELECT * FROM tasks WHERE workspace_id = ?';
 
 
 
 
 
 
41
  const params: any[] = [workspaceId];
42
 
43
  if (status) {
44
- query += ' AND status = ?';
45
  params.push(status);
46
  }
47
 
48
  if (assigned_to) {
49
- query += ' AND assigned_to = ?';
50
  params.push(assigned_to);
51
  }
52
 
53
  if (priority) {
54
- query += ' AND priority = ?';
55
  params.push(priority);
56
  }
 
 
 
 
 
57
 
58
- query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
59
  params.push(limit, offset);
60
 
61
  const stmt = db.prepare(query);
62
  const tasks = stmt.all(...params) as Task[];
63
 
64
  // Parse JSON fields
65
- const tasksWithParsedData = tasks.map(task => ({
66
- ...task,
67
- tags: task.tags ? JSON.parse(task.tags) : [],
68
- metadata: task.metadata ? JSON.parse(task.metadata) : {}
69
- }));
70
 
71
  // Get total count for pagination
72
  let countQuery = 'SELECT COUNT(*) as total FROM tasks WHERE workspace_id = ?';
@@ -83,6 +128,10 @@ export async function GET(request: NextRequest) {
83
  countQuery += ' AND priority = ?';
84
  countParams.push(priority);
85
  }
 
 
 
 
86
  const countRow = db.prepare(countQuery).get(...countParams) as { total: number };
87
 
88
  return NextResponse.json({ tasks: tasksWithParsedData, total: countRow.total, page: Math.floor(offset / limit) + 1, limit });
@@ -115,6 +164,7 @@ export async function POST(request: NextRequest) {
115
  description,
116
  status = 'inbox',
117
  priority = 'medium',
 
118
  assigned_to,
119
  created_by = user?.username || 'system',
120
  due_date,
@@ -130,31 +180,47 @@ export async function POST(request: NextRequest) {
130
  }
131
 
132
  const now = Math.floor(Date.now() / 1000);
133
-
134
- const stmt = db.prepare(`
135
- INSERT INTO tasks (
136
- title, description, status, priority, assigned_to, created_by,
137
- created_at, updated_at, due_date, estimated_hours, tags, metadata, workspace_id
138
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
139
- `);
140
-
141
- const dbResult = stmt.run(
142
- title,
143
- description,
144
- status,
145
- priority,
146
- assigned_to,
147
- created_by,
148
- now,
149
- now,
150
- due_date,
151
- estimated_hours,
152
- JSON.stringify(tags),
153
- JSON.stringify(metadata),
154
- workspaceId
155
- );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
 
157
- const taskId = dbResult.lastInsertRowid as number;
158
 
159
  // Log activity
160
  db_helpers.logActivity('task_created', 'task', taskId, created_by, `Created task: ${title}`, {
@@ -183,12 +249,14 @@ export async function POST(request: NextRequest) {
183
  }
184
 
185
  // Fetch the created task
186
- const createdTask = db.prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?').get(taskId, workspaceId) as Task;
187
- const parsedTask = {
188
- ...createdTask,
189
- tags: JSON.parse(createdTask.tags || '[]'),
190
- metadata: JSON.parse(createdTask.metadata || '{}')
191
- };
 
 
192
 
193
  // Broadcast to SSE clients
194
  eventBus.broadcast('task.created', parsedTask);
 
6
  import { logger } from '@/lib/logger';
7
  import { validateBody, createTaskSchema, bulkUpdateTaskStatusSchema } from '@/lib/validation';
8
 
9
+ function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
10
+ if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined
11
+ return `${prefix}-${String(num).padStart(3, '0')}`
12
+ }
13
+
14
+ function mapTaskRow(task: any): Task & { tags: string[]; metadata: Record<string, unknown> } {
15
+ return {
16
+ ...task,
17
+ tags: task.tags ? JSON.parse(task.tags) : [],
18
+ metadata: task.metadata ? JSON.parse(task.metadata) : {},
19
+ ticket_ref: formatTicketRef(task.project_prefix, task.project_ticket_no),
20
+ }
21
+ }
22
+
23
+ function resolveProjectId(db: ReturnType<typeof getDatabase>, workspaceId: number, requestedProjectId?: number): number {
24
+ if (typeof requestedProjectId === 'number' && Number.isFinite(requestedProjectId)) {
25
+ const project = db.prepare(`
26
+ SELECT id FROM projects
27
+ WHERE id = ? AND workspace_id = ? AND status = 'active'
28
+ LIMIT 1
29
+ `).get(requestedProjectId, workspaceId) as { id: number } | undefined
30
+ if (project) return project.id
31
+ }
32
+
33
+ const fallback = db.prepare(`
34
+ SELECT id FROM projects
35
+ WHERE workspace_id = ? AND status = 'active'
36
+ ORDER BY CASE WHEN slug = 'general' THEN 0 ELSE 1 END, id ASC
37
+ LIMIT 1
38
+ `).get(workspaceId) as { id: number } | undefined
39
+
40
+ if (!fallback) {
41
+ throw new Error('No active project available in workspace')
42
+ }
43
+ return fallback.id
44
+ }
45
+
46
  function hasAegisApproval(db: ReturnType<typeof getDatabase>, taskId: number, workspaceId: number): boolean {
47
  const review = db.prepare(`
48
  SELECT status FROM quality_reviews
 
55
 
56
  /**
57
  * GET /api/tasks - List all tasks with optional filtering
58
+ * Query params: status, assigned_to, priority, project_id, limit, offset
59
  */
60
  export async function GET(request: NextRequest) {
61
  const auth = requireRole(request, 'viewer');
 
70
  const status = searchParams.get('status');
71
  const assigned_to = searchParams.get('assigned_to');
72
  const priority = searchParams.get('priority');
73
+ const projectIdParam = Number.parseInt(searchParams.get('project_id') || '', 10);
74
  const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200);
75
  const offset = parseInt(searchParams.get('offset') || '0');
76
 
77
  // Build dynamic query
78
+ let query = `
79
+ SELECT t.*, p.name as project_name, p.ticket_prefix as project_prefix
80
+ FROM tasks t
81
+ LEFT JOIN projects p
82
+ ON p.id = t.project_id AND p.workspace_id = t.workspace_id
83
+ WHERE t.workspace_id = ?
84
+ `;
85
  const params: any[] = [workspaceId];
86
 
87
  if (status) {
88
+ query += ' AND t.status = ?';
89
  params.push(status);
90
  }
91
 
92
  if (assigned_to) {
93
+ query += ' AND t.assigned_to = ?';
94
  params.push(assigned_to);
95
  }
96
 
97
  if (priority) {
98
+ query += ' AND t.priority = ?';
99
  params.push(priority);
100
  }
101
+
102
+ if (Number.isFinite(projectIdParam)) {
103
+ query += ' AND t.project_id = ?';
104
+ params.push(projectIdParam);
105
+ }
106
 
107
+ query += ' ORDER BY t.created_at DESC LIMIT ? OFFSET ?';
108
  params.push(limit, offset);
109
 
110
  const stmt = db.prepare(query);
111
  const tasks = stmt.all(...params) as Task[];
112
 
113
  // Parse JSON fields
114
+ const tasksWithParsedData = tasks.map(mapTaskRow);
 
 
 
 
115
 
116
  // Get total count for pagination
117
  let countQuery = 'SELECT COUNT(*) as total FROM tasks WHERE workspace_id = ?';
 
128
  countQuery += ' AND priority = ?';
129
  countParams.push(priority);
130
  }
131
+ if (Number.isFinite(projectIdParam)) {
132
+ countQuery += ' AND project_id = ?';
133
+ countParams.push(projectIdParam);
134
+ }
135
  const countRow = db.prepare(countQuery).get(...countParams) as { total: number };
136
 
137
  return NextResponse.json({ tasks: tasksWithParsedData, total: countRow.total, page: Math.floor(offset / limit) + 1, limit });
 
164
  description,
165
  status = 'inbox',
166
  priority = 'medium',
167
+ project_id,
168
  assigned_to,
169
  created_by = user?.username || 'system',
170
  due_date,
 
180
  }
181
 
182
  const now = Math.floor(Date.now() / 1000);
183
+ const createTaskTx = db.transaction(() => {
184
+ const resolvedProjectId = resolveProjectId(db, workspaceId, project_id)
185
+ db.prepare(`
186
+ UPDATE projects
187
+ SET ticket_counter = ticket_counter + 1, updated_at = unixepoch()
188
+ WHERE id = ? AND workspace_id = ?
189
+ `).run(resolvedProjectId, workspaceId)
190
+ const row = db.prepare(`
191
+ SELECT ticket_counter FROM projects
192
+ WHERE id = ? AND workspace_id = ?
193
+ `).get(resolvedProjectId, workspaceId) as { ticket_counter: number } | undefined
194
+ if (!row || !row.ticket_counter) throw new Error('Failed to allocate project ticket number')
195
+
196
+ const insertStmt = db.prepare(`
197
+ INSERT INTO tasks (
198
+ title, description, status, priority, project_id, project_ticket_no, assigned_to, created_by,
199
+ created_at, updated_at, due_date, estimated_hours, tags, metadata, workspace_id
200
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
201
+ `)
202
+
203
+ const dbResult = insertStmt.run(
204
+ title,
205
+ description,
206
+ status,
207
+ priority,
208
+ resolvedProjectId,
209
+ row.ticket_counter,
210
+ assigned_to,
211
+ created_by,
212
+ now,
213
+ now,
214
+ due_date,
215
+ estimated_hours,
216
+ JSON.stringify(tags),
217
+ JSON.stringify(metadata),
218
+ workspaceId
219
+ )
220
+ return Number(dbResult.lastInsertRowid)
221
+ })
222
 
223
+ const taskId = createTaskTx()
224
 
225
  // Log activity
226
  db_helpers.logActivity('task_created', 'task', taskId, created_by, `Created task: ${title}`, {
 
249
  }
250
 
251
  // Fetch the created task
252
+ const createdTask = db.prepare(`
253
+ SELECT t.*, p.name as project_name, p.ticket_prefix as project_prefix
254
+ FROM tasks t
255
+ LEFT JOIN projects p
256
+ ON p.id = t.project_id AND p.workspace_id = t.workspace_id
257
+ WHERE t.id = ? AND t.workspace_id = ?
258
+ `).get(taskId, workspaceId) as Task;
259
+ const parsedTask = mapTaskRow(createdTask);
260
 
261
  // Broadcast to SSE clients
262
  eventBus.broadcast('task.created', parsedTask);
src/components/layout/header-bar.tsx CHANGED
@@ -43,6 +43,7 @@ export function HeaderBar() {
43
  alerts: 'Alert Rules',
44
  gateways: 'Gateway Manager',
45
  users: 'Users',
 
46
  'gateway-config': 'Gateway Config',
47
  settings: 'Settings',
48
  }
 
43
  alerts: 'Alert Rules',
44
  gateways: 'Gateway Manager',
45
  users: 'Users',
46
+ workspaces: 'Workspaces',
47
  'gateway-config': 'Gateway Config',
48
  settings: 'Settings',
49
  }
src/components/layout/nav-rail.tsx CHANGED
@@ -61,6 +61,7 @@ const navGroups: NavGroup[] = [
61
  { id: 'gateways', label: 'Gateways', icon: <GatewaysIcon />, priority: false },
62
  { id: 'gateway-config', label: 'Config', icon: <GatewayConfigIcon />, priority: false, requiresGateway: true },
63
  { id: 'integrations', label: 'Integrations', icon: <IntegrationsIcon />, priority: false },
 
64
  { id: 'super-admin', label: 'Super Admin', icon: <SuperAdminIcon />, priority: false },
65
  { id: 'settings', label: 'Settings', icon: <SettingsIcon />, priority: false },
66
  ],
 
61
  { id: 'gateways', label: 'Gateways', icon: <GatewaysIcon />, priority: false },
62
  { id: 'gateway-config', label: 'Config', icon: <GatewayConfigIcon />, priority: false, requiresGateway: true },
63
  { id: 'integrations', label: 'Integrations', icon: <IntegrationsIcon />, priority: false },
64
+ { id: 'workspaces', label: 'Workspaces', icon: <SuperAdminIcon />, priority: false },
65
  { id: 'super-admin', label: 'Super Admin', icon: <SuperAdminIcon />, priority: false },
66
  { id: 'settings', label: 'Settings', icon: <SettingsIcon />, priority: false },
67
  ],
src/components/panels/agent-detail-tabs.tsx CHANGED
@@ -669,7 +669,10 @@ export function TasksTab({ agent }: { agent: Agent }) {
669
  <Link href={`/tasks?taskId=${task.id}`} className="font-medium text-foreground hover:text-primary transition-colors">
670
  {task.title}
671
  </Link>
672
- <div className="text-xs text-muted-foreground mt-1">Task #{task.id}</div>
 
 
 
673
  {task.description && (
674
  <p className="text-foreground/80 text-sm mt-1">{task.description}</p>
675
  )}
 
669
  <Link href={`/tasks?taskId=${task.id}`} className="font-medium text-foreground hover:text-primary transition-colors">
670
  {task.title}
671
  </Link>
672
+ <div className="text-xs text-muted-foreground mt-1">
673
+ {task.ticket_ref || `Task #${task.id}`}
674
+ {task.project_name ? ` · ${task.project_name}` : ''}
675
+ </div>
676
  {task.description && (
677
  <p className="text-foreground/80 text-sm mt-1">{task.description}</p>
678
  )}
src/components/panels/task-board-panel.tsx CHANGED
@@ -24,6 +24,11 @@ interface Task {
24
  tags?: string[]
25
  metadata?: any
26
  aegisApproved?: boolean
 
 
 
 
 
27
  }
28
 
29
  interface Agent {
@@ -50,6 +55,14 @@ interface Comment {
50
  replies?: Comment[]
51
  }
52
 
 
 
 
 
 
 
 
 
53
  const statusColumns = [
54
  { key: 'inbox', title: 'Inbox', color: 'bg-secondary text-foreground' },
55
  { key: 'assigned', title: 'Assigned', color: 'bg-blue-500/20 text-blue-400' },
@@ -72,11 +85,14 @@ export function TaskBoardPanel() {
72
  const pathname = usePathname()
73
  const searchParams = useSearchParams()
74
  const [agents, setAgents] = useState<Agent[]>([])
 
 
75
  const [loading, setLoading] = useState(true)
76
  const [error, setError] = useState<string | null>(null)
77
  const [aegisMap, setAegisMap] = useState<Record<number, boolean>>({})
78
  const [draggedTask, setDraggedTask] = useState<Task | null>(null)
79
  const [showCreateModal, setShowCreateModal] = useState(false)
 
80
  const [editingTask, setEditingTask] = useState<Task | null>(null)
81
  const dragCounter = useRef(0)
82
  const selectedTaskIdFromUrl = Number.parseInt(searchParams.get('taskId') || '', 10)
@@ -103,23 +119,31 @@ export function TaskBoardPanel() {
103
  aegisApproved: Boolean(aegisMap[t.id])
104
  }))
105
 
106
- // Fetch tasks and agents
107
  const fetchData = useCallback(async () => {
108
  try {
109
  setLoading(true)
110
  setError(null)
111
 
112
- const [tasksResponse, agentsResponse] = await Promise.all([
113
- fetch('/api/tasks'),
114
- fetch('/api/agents')
 
 
 
 
 
 
 
115
  ])
116
 
117
- if (!tasksResponse.ok || !agentsResponse.ok) {
118
  throw new Error('Failed to fetch data')
119
  }
120
 
121
  const tasksData = await tasksResponse.json()
122
  const agentsData = await agentsResponse.json()
 
123
 
124
  const tasksList = tasksData.tasks || []
125
  const taskIds = tasksList.map((task: Task) => task.id)
@@ -146,12 +170,13 @@ export function TaskBoardPanel() {
146
  storeSetTasks(tasksList)
147
  setAegisMap(newAegisMap)
148
  setAgents(agentsData.agents || [])
 
149
  } catch (err) {
150
  setError(err instanceof Error ? err.message : 'An error occurred')
151
  } finally {
152
  setLoading(false)
153
  }
154
- }, [storeSetTasks])
155
 
156
  useEffect(() => {
157
  fetchData()
@@ -321,8 +346,28 @@ export function TaskBoardPanel() {
321
  <div className="h-full flex flex-col">
322
  {/* Header */}
323
  <div className="flex justify-between items-center p-4 border-b border-border flex-shrink-0">
324
- <h2 className="text-xl font-bold text-foreground">Task Board</h2>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
  <div className="flex gap-2">
 
 
 
 
 
 
326
  <button
327
  onClick={() => setShowCreateModal(true)}
328
  className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-smooth text-sm font-medium"
@@ -403,6 +448,11 @@ export function TaskBoardPanel() {
403
  {task.title}
404
  </h4>
405
  <div className="flex items-center gap-2">
 
 
 
 
 
406
  {task.aegisApproved && (
407
  <span className="text-[10px] px-2 py-0.5 rounded bg-emerald-700 text-emerald-100">
408
  Aegis Approved
@@ -439,6 +489,12 @@ export function TaskBoardPanel() {
439
  <span className="font-medium">{formatTaskTimestamp(task.created_at)}</span>
440
  </div>
441
 
 
 
 
 
 
 
442
  {task.tags && task.tags.length > 0 && (
443
  <div className="flex flex-wrap gap-1 mt-2">
444
  {task.tags.slice(0, 3).map((tag, index) => (
@@ -490,6 +546,7 @@ export function TaskBoardPanel() {
490
  <TaskDetailModal
491
  task={selectedTask}
492
  agents={agents}
 
493
  onClose={() => {
494
  setSelectedTask(null)
495
  updateTaskUrl(null)
@@ -507,6 +564,7 @@ export function TaskBoardPanel() {
507
  {showCreateModal && (
508
  <CreateTaskModal
509
  agents={agents}
 
510
  onClose={() => setShowCreateModal(false)}
511
  onCreated={fetchData}
512
  />
@@ -517,10 +575,18 @@ export function TaskBoardPanel() {
517
  <EditTaskModal
518
  task={editingTask}
519
  agents={agents}
 
520
  onClose={() => setEditingTask(null)}
521
  onUpdated={() => { fetchData(); setEditingTask(null) }}
522
  />
523
  )}
 
 
 
 
 
 
 
524
  </div>
525
  )
526
  }
@@ -529,16 +595,21 @@ export function TaskBoardPanel() {
529
  function TaskDetailModal({
530
  task,
531
  agents,
 
532
  onClose,
533
  onUpdate,
534
  onEdit
535
  }: {
536
  task: Task
537
  agents: Agent[]
 
538
  onClose: () => void
539
  onUpdate: () => void
540
  onEdit: (task: Task) => void
541
  }) {
 
 
 
542
  const [comments, setComments] = useState<Comment[]>([])
543
  const [loadingComments, setLoadingComments] = useState(false)
544
  const [commentText, setCommentText] = useState('')
@@ -722,6 +793,18 @@ function TaskDetailModal({
722
 
723
  {activeTab === 'details' && (
724
  <div id="tabpanel-details" role="tabpanel" aria-label="Details" className="grid grid-cols-2 gap-4 text-sm mt-4">
 
 
 
 
 
 
 
 
 
 
 
 
725
  <div>
726
  <span className="text-muted-foreground">Status:</span>
727
  <span className="text-foreground ml-2">{task.status}</span>
@@ -897,10 +980,12 @@ function TaskDetailModal({
897
  // Create Task Modal Component (placeholder)
898
  function CreateTaskModal({
899
  agents,
 
900
  onClose,
901
  onCreated
902
  }: {
903
  agents: Agent[]
 
904
  onClose: () => void
905
  onCreated: () => void
906
  }) {
@@ -908,6 +993,7 @@ function CreateTaskModal({
908
  title: '',
909
  description: '',
910
  priority: 'medium' as Task['priority'],
 
911
  assigned_to: '',
912
  tags: '',
913
  })
@@ -923,6 +1009,7 @@ function CreateTaskModal({
923
  headers: { 'Content-Type': 'application/json' },
924
  body: JSON.stringify({
925
  ...formData,
 
926
  tags: formData.tags ? formData.tags.split(',').map(t => t.trim()) : [],
927
  assigned_to: formData.assigned_to || undefined
928
  })
@@ -988,25 +1075,41 @@ function CreateTaskModal({
988
  <option value="critical">Critical</option>
989
  </select>
990
  </div>
991
-
992
  <div>
993
- <label htmlFor="create-assignee" className="block text-sm text-muted-foreground mb-1">Assign to</label>
994
  <select
995
- id="create-assignee"
996
- value={formData.assigned_to}
997
- onChange={(e) => setFormData(prev => ({ ...prev, assigned_to: e.target.value }))}
998
  className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
999
  >
1000
- <option value="">Unassigned</option>
1001
- {agents.map(agent => (
1002
- <option key={agent.name} value={agent.name}>
1003
- {agent.name} ({agent.role})
1004
  </option>
1005
  ))}
1006
  </select>
1007
  </div>
1008
  </div>
1009
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1010
  <div>
1011
  <label htmlFor="create-tags" className="block text-sm text-muted-foreground mb-1">Tags (comma-separated)</label>
1012
  <input
@@ -1045,11 +1148,13 @@ function CreateTaskModal({
1045
  function EditTaskModal({
1046
  task,
1047
  agents,
 
1048
  onClose,
1049
  onUpdated
1050
  }: {
1051
  task: Task
1052
  agents: Agent[]
 
1053
  onClose: () => void
1054
  onUpdated: () => void
1055
  }) {
@@ -1058,6 +1163,7 @@ function EditTaskModal({
1058
  description: task.description || '',
1059
  priority: task.priority,
1060
  status: task.status,
 
1061
  assigned_to: task.assigned_to || '',
1062
  tags: task.tags ? task.tags.join(', ') : '',
1063
  })
@@ -1073,6 +1179,7 @@ function EditTaskModal({
1073
  headers: { 'Content-Type': 'application/json' },
1074
  body: JSON.stringify({
1075
  ...formData,
 
1076
  tags: formData.tags ? formData.tags.split(',').map(t => t.trim()) : [],
1077
  assigned_to: formData.assigned_to || undefined
1078
  })
@@ -1156,6 +1263,22 @@ function EditTaskModal({
1156
  </div>
1157
  </div>
1158
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1159
  <div>
1160
  <label htmlFor="edit-assignee" className="block text-sm text-muted-foreground mb-1">Assign to</label>
1161
  <select
@@ -1206,3 +1329,165 @@ function EditTaskModal({
1206
  </div>
1207
  )
1208
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  tags?: string[]
25
  metadata?: any
26
  aegisApproved?: boolean
27
+ project_id?: number
28
+ project_ticket_no?: number
29
+ project_name?: string
30
+ project_prefix?: string
31
+ ticket_ref?: string
32
  }
33
 
34
  interface Agent {
 
55
  replies?: Comment[]
56
  }
57
 
58
+ interface Project {
59
+ id: number
60
+ name: string
61
+ slug: string
62
+ ticket_prefix: string
63
+ status: 'active' | 'archived'
64
+ }
65
+
66
  const statusColumns = [
67
  { key: 'inbox', title: 'Inbox', color: 'bg-secondary text-foreground' },
68
  { key: 'assigned', title: 'Assigned', color: 'bg-blue-500/20 text-blue-400' },
 
85
  const pathname = usePathname()
86
  const searchParams = useSearchParams()
87
  const [agents, setAgents] = useState<Agent[]>([])
88
+ const [projects, setProjects] = useState<Project[]>([])
89
+ const [projectFilter, setProjectFilter] = useState<string>('all')
90
  const [loading, setLoading] = useState(true)
91
  const [error, setError] = useState<string | null>(null)
92
  const [aegisMap, setAegisMap] = useState<Record<number, boolean>>({})
93
  const [draggedTask, setDraggedTask] = useState<Task | null>(null)
94
  const [showCreateModal, setShowCreateModal] = useState(false)
95
+ const [showProjectManager, setShowProjectManager] = useState(false)
96
  const [editingTask, setEditingTask] = useState<Task | null>(null)
97
  const dragCounter = useRef(0)
98
  const selectedTaskIdFromUrl = Number.parseInt(searchParams.get('taskId') || '', 10)
 
119
  aegisApproved: Boolean(aegisMap[t.id])
120
  }))
121
 
122
+ // Fetch tasks, agents, and projects
123
  const fetchData = useCallback(async () => {
124
  try {
125
  setLoading(true)
126
  setError(null)
127
 
128
+ const tasksQuery = new URLSearchParams()
129
+ if (projectFilter !== 'all') {
130
+ tasksQuery.set('project_id', projectFilter)
131
+ }
132
+ const tasksUrl = tasksQuery.toString() ? `/api/tasks?${tasksQuery.toString()}` : '/api/tasks'
133
+
134
+ const [tasksResponse, agentsResponse, projectsResponse] = await Promise.all([
135
+ fetch(tasksUrl),
136
+ fetch('/api/agents'),
137
+ fetch('/api/projects')
138
  ])
139
 
140
+ if (!tasksResponse.ok || !agentsResponse.ok || !projectsResponse.ok) {
141
  throw new Error('Failed to fetch data')
142
  }
143
 
144
  const tasksData = await tasksResponse.json()
145
  const agentsData = await agentsResponse.json()
146
+ const projectsData = await projectsResponse.json()
147
 
148
  const tasksList = tasksData.tasks || []
149
  const taskIds = tasksList.map((task: Task) => task.id)
 
170
  storeSetTasks(tasksList)
171
  setAegisMap(newAegisMap)
172
  setAgents(agentsData.agents || [])
173
+ setProjects(projectsData.projects || [])
174
  } catch (err) {
175
  setError(err instanceof Error ? err.message : 'An error occurred')
176
  } finally {
177
  setLoading(false)
178
  }
179
+ }, [projectFilter, storeSetTasks])
180
 
181
  useEffect(() => {
182
  fetchData()
 
346
  <div className="h-full flex flex-col">
347
  {/* Header */}
348
  <div className="flex justify-between items-center p-4 border-b border-border flex-shrink-0">
349
+ <div className="flex items-center gap-3">
350
+ <h2 className="text-xl font-bold text-foreground">Task Board</h2>
351
+ <select
352
+ value={projectFilter}
353
+ onChange={(e) => setProjectFilter(e.target.value)}
354
+ className="h-9 px-3 bg-surface-1 text-foreground border border-border rounded-md text-sm"
355
+ >
356
+ <option value="all">All Projects</option>
357
+ {projects.map((project) => (
358
+ <option key={project.id} value={String(project.id)}>
359
+ {project.name} ({project.ticket_prefix})
360
+ </option>
361
+ ))}
362
+ </select>
363
+ </div>
364
  <div className="flex gap-2">
365
+ <button
366
+ onClick={() => setShowProjectManager(true)}
367
+ className="px-4 py-2 bg-secondary text-muted-foreground rounded-md hover:bg-surface-2 transition-smooth text-sm font-medium"
368
+ >
369
+ Projects
370
+ </button>
371
  <button
372
  onClick={() => setShowCreateModal(true)}
373
  className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-smooth text-sm font-medium"
 
448
  {task.title}
449
  </h4>
450
  <div className="flex items-center gap-2">
451
+ {task.ticket_ref && (
452
+ <span className="text-[10px] px-2 py-0.5 rounded bg-primary/20 text-primary">
453
+ {task.ticket_ref}
454
+ </span>
455
+ )}
456
  {task.aegisApproved && (
457
  <span className="text-[10px] px-2 py-0.5 rounded bg-emerald-700 text-emerald-100">
458
  Aegis Approved
 
489
  <span className="font-medium">{formatTaskTimestamp(task.created_at)}</span>
490
  </div>
491
 
492
+ {task.project_name && (
493
+ <div className="text-xs text-muted-foreground mt-1">
494
+ Project: {task.project_name}
495
+ </div>
496
+ )}
497
+
498
  {task.tags && task.tags.length > 0 && (
499
  <div className="flex flex-wrap gap-1 mt-2">
500
  {task.tags.slice(0, 3).map((tag, index) => (
 
546
  <TaskDetailModal
547
  task={selectedTask}
548
  agents={agents}
549
+ projects={projects}
550
  onClose={() => {
551
  setSelectedTask(null)
552
  updateTaskUrl(null)
 
564
  {showCreateModal && (
565
  <CreateTaskModal
566
  agents={agents}
567
+ projects={projects}
568
  onClose={() => setShowCreateModal(false)}
569
  onCreated={fetchData}
570
  />
 
575
  <EditTaskModal
576
  task={editingTask}
577
  agents={agents}
578
+ projects={projects}
579
  onClose={() => setEditingTask(null)}
580
  onUpdated={() => { fetchData(); setEditingTask(null) }}
581
  />
582
  )}
583
+
584
+ {showProjectManager && (
585
+ <ProjectManagerModal
586
+ onClose={() => setShowProjectManager(false)}
587
+ onChanged={fetchData}
588
+ />
589
+ )}
590
  </div>
591
  )
592
  }
 
595
  function TaskDetailModal({
596
  task,
597
  agents,
598
+ projects,
599
  onClose,
600
  onUpdate,
601
  onEdit
602
  }: {
603
  task: Task
604
  agents: Agent[]
605
+ projects: Project[]
606
  onClose: () => void
607
  onUpdate: () => void
608
  onEdit: (task: Task) => void
609
  }) {
610
+ const resolvedProjectName =
611
+ task.project_name ||
612
+ projects.find((project) => project.id === task.project_id)?.name
613
  const [comments, setComments] = useState<Comment[]>([])
614
  const [loadingComments, setLoadingComments] = useState(false)
615
  const [commentText, setCommentText] = useState('')
 
793
 
794
  {activeTab === 'details' && (
795
  <div id="tabpanel-details" role="tabpanel" aria-label="Details" className="grid grid-cols-2 gap-4 text-sm mt-4">
796
+ {task.ticket_ref && (
797
+ <div>
798
+ <span className="text-muted-foreground">Ticket:</span>
799
+ <span className="text-foreground ml-2 font-mono">{task.ticket_ref}</span>
800
+ </div>
801
+ )}
802
+ {resolvedProjectName && (
803
+ <div>
804
+ <span className="text-muted-foreground">Project:</span>
805
+ <span className="text-foreground ml-2">{resolvedProjectName}</span>
806
+ </div>
807
+ )}
808
  <div>
809
  <span className="text-muted-foreground">Status:</span>
810
  <span className="text-foreground ml-2">{task.status}</span>
 
980
  // Create Task Modal Component (placeholder)
981
  function CreateTaskModal({
982
  agents,
983
+ projects,
984
  onClose,
985
  onCreated
986
  }: {
987
  agents: Agent[]
988
+ projects: Project[]
989
  onClose: () => void
990
  onCreated: () => void
991
  }) {
 
993
  title: '',
994
  description: '',
995
  priority: 'medium' as Task['priority'],
996
+ project_id: projects[0]?.id ? String(projects[0].id) : '',
997
  assigned_to: '',
998
  tags: '',
999
  })
 
1009
  headers: { 'Content-Type': 'application/json' },
1010
  body: JSON.stringify({
1011
  ...formData,
1012
+ project_id: formData.project_id ? Number(formData.project_id) : undefined,
1013
  tags: formData.tags ? formData.tags.split(',').map(t => t.trim()) : [],
1014
  assigned_to: formData.assigned_to || undefined
1015
  })
 
1075
  <option value="critical">Critical</option>
1076
  </select>
1077
  </div>
1078
+
1079
  <div>
1080
+ <label htmlFor="create-project" className="block text-sm text-muted-foreground mb-1">Project</label>
1081
  <select
1082
+ id="create-project"
1083
+ value={formData.project_id}
1084
+ onChange={(e) => setFormData(prev => ({ ...prev, project_id: e.target.value }))}
1085
  className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
1086
  >
1087
+ {projects.map(project => (
1088
+ <option key={project.id} value={String(project.id)}>
1089
+ {project.name} ({project.ticket_prefix})
 
1090
  </option>
1091
  ))}
1092
  </select>
1093
  </div>
1094
  </div>
1095
+
1096
+ <div>
1097
+ <label htmlFor="create-assignee" className="block text-sm text-muted-foreground mb-1">Assign to</label>
1098
+ <select
1099
+ id="create-assignee"
1100
+ value={formData.assigned_to}
1101
+ onChange={(e) => setFormData(prev => ({ ...prev, assigned_to: e.target.value }))}
1102
+ className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
1103
+ >
1104
+ <option value="">Unassigned</option>
1105
+ {agents.map(agent => (
1106
+ <option key={agent.name} value={agent.name}>
1107
+ {agent.name} ({agent.role})
1108
+ </option>
1109
+ ))}
1110
+ </select>
1111
+ </div>
1112
+
1113
  <div>
1114
  <label htmlFor="create-tags" className="block text-sm text-muted-foreground mb-1">Tags (comma-separated)</label>
1115
  <input
 
1148
  function EditTaskModal({
1149
  task,
1150
  agents,
1151
+ projects,
1152
  onClose,
1153
  onUpdated
1154
  }: {
1155
  task: Task
1156
  agents: Agent[]
1157
+ projects: Project[]
1158
  onClose: () => void
1159
  onUpdated: () => void
1160
  }) {
 
1163
  description: task.description || '',
1164
  priority: task.priority,
1165
  status: task.status,
1166
+ project_id: task.project_id ? String(task.project_id) : (projects[0]?.id ? String(projects[0].id) : ''),
1167
  assigned_to: task.assigned_to || '',
1168
  tags: task.tags ? task.tags.join(', ') : '',
1169
  })
 
1179
  headers: { 'Content-Type': 'application/json' },
1180
  body: JSON.stringify({
1181
  ...formData,
1182
+ project_id: formData.project_id ? Number(formData.project_id) : undefined,
1183
  tags: formData.tags ? formData.tags.split(',').map(t => t.trim()) : [],
1184
  assigned_to: formData.assigned_to || undefined
1185
  })
 
1263
  </div>
1264
  </div>
1265
 
1266
+ <div>
1267
+ <label htmlFor="edit-project" className="block text-sm text-muted-foreground mb-1">Project</label>
1268
+ <select
1269
+ id="edit-project"
1270
+ value={formData.project_id}
1271
+ onChange={(e) => setFormData(prev => ({ ...prev, project_id: e.target.value }))}
1272
+ className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
1273
+ >
1274
+ {projects.map(project => (
1275
+ <option key={project.id} value={String(project.id)}>
1276
+ {project.name} ({project.ticket_prefix})
1277
+ </option>
1278
+ ))}
1279
+ </select>
1280
+ </div>
1281
+
1282
  <div>
1283
  <label htmlFor="edit-assignee" className="block text-sm text-muted-foreground mb-1">Assign to</label>
1284
  <select
 
1329
  </div>
1330
  )
1331
  }
1332
+
1333
+ function ProjectManagerModal({
1334
+ onClose,
1335
+ onChanged
1336
+ }: {
1337
+ onClose: () => void
1338
+ onChanged: () => Promise<void>
1339
+ }) {
1340
+ const [projects, setProjects] = useState<Project[]>([])
1341
+ const [loading, setLoading] = useState(true)
1342
+ const [error, setError] = useState<string | null>(null)
1343
+ const [form, setForm] = useState({ name: '', ticket_prefix: '', description: '' })
1344
+
1345
+ const load = useCallback(async () => {
1346
+ try {
1347
+ setLoading(true)
1348
+ const response = await fetch('/api/projects?includeArchived=1')
1349
+ const data = await response.json()
1350
+ if (!response.ok) throw new Error(data.error || 'Failed to load projects')
1351
+ setProjects(data.projects || [])
1352
+ setError(null)
1353
+ } catch (err) {
1354
+ setError(err instanceof Error ? err.message : 'Failed to load projects')
1355
+ } finally {
1356
+ setLoading(false)
1357
+ }
1358
+ }, [])
1359
+
1360
+ useEffect(() => {
1361
+ load()
1362
+ }, [load])
1363
+
1364
+ const createProject = async (e: React.FormEvent) => {
1365
+ e.preventDefault()
1366
+ if (!form.name.trim()) return
1367
+ try {
1368
+ const response = await fetch('/api/projects', {
1369
+ method: 'POST',
1370
+ headers: { 'Content-Type': 'application/json' },
1371
+ body: JSON.stringify({
1372
+ name: form.name,
1373
+ ticket_prefix: form.ticket_prefix,
1374
+ description: form.description
1375
+ })
1376
+ })
1377
+ const data = await response.json()
1378
+ if (!response.ok) throw new Error(data.error || 'Failed to create project')
1379
+ setForm({ name: '', ticket_prefix: '', description: '' })
1380
+ await load()
1381
+ await onChanged()
1382
+ } catch (err) {
1383
+ setError(err instanceof Error ? err.message : 'Failed to create project')
1384
+ }
1385
+ }
1386
+
1387
+ const archiveProject = async (project: Project) => {
1388
+ try {
1389
+ const response = await fetch(`/api/projects/${project.id}`, {
1390
+ method: 'PATCH',
1391
+ headers: { 'Content-Type': 'application/json' },
1392
+ body: JSON.stringify({ status: project.status === 'active' ? 'archived' : 'active' })
1393
+ })
1394
+ const data = await response.json()
1395
+ if (!response.ok) throw new Error(data.error || 'Failed to update project')
1396
+ await load()
1397
+ await onChanged()
1398
+ } catch (err) {
1399
+ setError(err instanceof Error ? err.message : 'Failed to update project')
1400
+ }
1401
+ }
1402
+
1403
+ const deleteProject = async (project: Project) => {
1404
+ if (!confirm(`Delete project "${project.name}"? Existing tasks will be moved to General.`)) return
1405
+ try {
1406
+ const response = await fetch(`/api/projects/${project.id}?mode=delete`, { method: 'DELETE' })
1407
+ const data = await response.json()
1408
+ if (!response.ok) throw new Error(data.error || 'Failed to delete project')
1409
+ await load()
1410
+ await onChanged()
1411
+ } catch (err) {
1412
+ setError(err instanceof Error ? err.message : 'Failed to delete project')
1413
+ }
1414
+ }
1415
+
1416
+ const dialogRef = useFocusTrap(onClose)
1417
+
1418
+ return (
1419
+ <div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={(e) => { if (e.target === e.currentTarget) onClose() }}>
1420
+ <div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="projects-title" className="bg-card border border-border rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
1421
+ <div className="p-6 space-y-4">
1422
+ <div className="flex items-center justify-between">
1423
+ <h3 id="projects-title" className="text-xl font-bold text-foreground">Project Management</h3>
1424
+ <button onClick={onClose} className="text-muted-foreground hover:text-foreground text-2xl">×</button>
1425
+ </div>
1426
+
1427
+ {error && <div className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded p-2">{error}</div>}
1428
+
1429
+ <form onSubmit={createProject} className="grid grid-cols-1 md:grid-cols-3 gap-3">
1430
+ <input
1431
+ type="text"
1432
+ value={form.name}
1433
+ onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
1434
+ placeholder="Project name"
1435
+ className="bg-surface-1 text-foreground border border-border rounded-md px-3 py-2"
1436
+ required
1437
+ />
1438
+ <input
1439
+ type="text"
1440
+ value={form.ticket_prefix}
1441
+ onChange={(e) => setForm((prev) => ({ ...prev, ticket_prefix: e.target.value }))}
1442
+ placeholder="Ticket prefix (e.g. PA)"
1443
+ className="bg-surface-1 text-foreground border border-border rounded-md px-3 py-2"
1444
+ />
1445
+ <button type="submit" className="bg-primary text-primary-foreground rounded-md px-3 py-2 hover:bg-primary/90">
1446
+ Add Project
1447
+ </button>
1448
+ <input
1449
+ type="text"
1450
+ value={form.description}
1451
+ onChange={(e) => setForm((prev) => ({ ...prev, description: e.target.value }))}
1452
+ placeholder="Description (optional)"
1453
+ className="md:col-span-3 bg-surface-1 text-foreground border border-border rounded-md px-3 py-2"
1454
+ />
1455
+ </form>
1456
+
1457
+ {loading ? (
1458
+ <div className="text-sm text-muted-foreground">Loading projects...</div>
1459
+ ) : (
1460
+ <div className="space-y-2">
1461
+ {projects.map((project) => (
1462
+ <div key={project.id} className="flex items-center justify-between border border-border rounded-md p-3">
1463
+ <div>
1464
+ <div className="text-sm font-medium text-foreground">{project.name}</div>
1465
+ <div className="text-xs text-muted-foreground">{project.ticket_prefix} · {project.slug} · {project.status}</div>
1466
+ </div>
1467
+ <div className="flex gap-2">
1468
+ {project.slug !== 'general' && (
1469
+ <>
1470
+ <button
1471
+ onClick={() => archiveProject(project)}
1472
+ className="px-3 py-1 text-xs rounded border border-border hover:bg-secondary"
1473
+ >
1474
+ {project.status === 'active' ? 'Archive' : 'Activate'}
1475
+ </button>
1476
+ <button
1477
+ onClick={() => deleteProject(project)}
1478
+ className="px-3 py-1 text-xs rounded border border-red-500/30 text-red-400 hover:bg-red-500/10"
1479
+ >
1480
+ Delete
1481
+ </button>
1482
+ </>
1483
+ )}
1484
+ </div>
1485
+ </div>
1486
+ ))}
1487
+ </div>
1488
+ )}
1489
+ </div>
1490
+ </div>
1491
+ </div>
1492
+ )
1493
+ }
src/index.ts CHANGED
@@ -87,6 +87,11 @@ export interface Task {
87
  description?: string
88
  status: 'inbox' | 'assigned' | 'in_progress' | 'review' | 'quality_review' | 'done'
89
  priority: 'low' | 'medium' | 'high' | 'urgent'
 
 
 
 
 
90
  assigned_to?: string
91
  created_by: string
92
  created_at: number
 
87
  description?: string
88
  status: 'inbox' | 'assigned' | 'in_progress' | 'review' | 'quality_review' | 'done'
89
  priority: 'low' | 'medium' | 'high' | 'urgent'
90
+ project_id?: number
91
+ project_ticket_no?: number
92
+ project_name?: string
93
+ project_prefix?: string
94
+ ticket_ref?: string
95
  assigned_to?: string
96
  created_by: string
97
  created_at: number
src/lib/db.ts CHANGED
@@ -164,6 +164,11 @@ export interface Task {
164
  description?: string;
165
  status: 'inbox' | 'assigned' | 'in_progress' | 'review' | 'quality_review' | 'done';
166
  priority: 'low' | 'medium' | 'high' | 'urgent';
 
 
 
 
 
167
  assigned_to?: string;
168
  created_by: string;
169
  created_at: number;
 
164
  description?: string;
165
  status: 'inbox' | 'assigned' | 'in_progress' | 'review' | 'quality_review' | 'done';
166
  priority: 'low' | 'medium' | 'high' | 'urgent';
167
+ project_id?: number;
168
+ project_ticket_no?: number;
169
+ project_name?: string;
170
+ project_prefix?: string;
171
+ ticket_ref?: string;
172
  assigned_to?: string;
173
  created_by: string;
174
  created_at: number;
src/lib/migrations.ts CHANGED
@@ -676,6 +676,83 @@ const migrations: Migration[] = [
676
  db.exec(`CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_workspace_id ON webhook_deliveries(workspace_id)`)
677
  db.exec(`CREATE INDEX IF NOT EXISTS idx_token_usage_workspace_id ON token_usage(workspace_id)`)
678
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
679
  }
680
  ]
681
 
 
676
  db.exec(`CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_workspace_id ON webhook_deliveries(workspace_id)`)
677
  db.exec(`CREATE INDEX IF NOT EXISTS idx_token_usage_workspace_id ON token_usage(workspace_id)`)
678
  }
679
+ },
680
+ {
681
+ id: '024_projects_support',
682
+ up: (db) => {
683
+ db.exec(`
684
+ CREATE TABLE IF NOT EXISTS projects (
685
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
686
+ workspace_id INTEGER NOT NULL DEFAULT 1,
687
+ name TEXT NOT NULL,
688
+ slug TEXT NOT NULL,
689
+ description TEXT,
690
+ ticket_prefix TEXT NOT NULL,
691
+ ticket_counter INTEGER NOT NULL DEFAULT 0,
692
+ status TEXT NOT NULL DEFAULT 'active',
693
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
694
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
695
+ UNIQUE(workspace_id, slug),
696
+ UNIQUE(workspace_id, ticket_prefix)
697
+ )
698
+ `)
699
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_projects_workspace_status ON projects(workspace_id, status)`)
700
+
701
+ const taskCols = db.prepare(`PRAGMA table_info(tasks)`).all() as Array<{ name: string }>
702
+ if (!taskCols.some((c) => c.name === 'project_id')) {
703
+ db.exec(`ALTER TABLE tasks ADD COLUMN project_id INTEGER`)
704
+ }
705
+ if (!taskCols.some((c) => c.name === 'project_ticket_no')) {
706
+ db.exec(`ALTER TABLE tasks ADD COLUMN project_ticket_no INTEGER`)
707
+ }
708
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_workspace_project ON tasks(workspace_id, project_id)`)
709
+
710
+ const workspaceRows = db.prepare(`SELECT id FROM workspaces ORDER BY id ASC`).all() as Array<{ id: number }>
711
+ const ensureDefaultProject = db.prepare(`
712
+ INSERT OR IGNORE INTO projects (workspace_id, name, slug, description, ticket_prefix, ticket_counter, status, created_at, updated_at)
713
+ VALUES (?, 'General', 'general', 'Default project for uncategorized tasks', 'TASK', 0, 'active', unixepoch(), unixepoch())
714
+ `)
715
+ const getDefaultProject = db.prepare(`
716
+ SELECT id, ticket_counter FROM projects
717
+ WHERE workspace_id = ? AND slug = 'general'
718
+ LIMIT 1
719
+ `)
720
+ const setTaskProject = db.prepare(`
721
+ UPDATE tasks SET project_id = ?
722
+ WHERE workspace_id = ? AND (project_id IS NULL OR project_id = 0)
723
+ `)
724
+ const listProjectTasks = db.prepare(`
725
+ SELECT id FROM tasks
726
+ WHERE workspace_id = ? AND project_id = ?
727
+ ORDER BY created_at ASC, id ASC
728
+ `)
729
+ const setTaskNo = db.prepare(`UPDATE tasks SET project_ticket_no = ? WHERE id = ?`)
730
+ const setProjectCounter = db.prepare(`UPDATE projects SET ticket_counter = ?, updated_at = unixepoch() WHERE id = ?`)
731
+
732
+ for (const workspace of workspaceRows) {
733
+ ensureDefaultProject.run(workspace.id)
734
+ const defaultProject = getDefaultProject.get(workspace.id) as { id: number; ticket_counter: number } | undefined
735
+ if (!defaultProject) continue
736
+
737
+ setTaskProject.run(defaultProject.id, workspace.id)
738
+
739
+ const projectRows = db.prepare(`
740
+ SELECT id FROM projects
741
+ WHERE workspace_id = ?
742
+ ORDER BY id ASC
743
+ `).all(workspace.id) as Array<{ id: number }>
744
+
745
+ for (const project of projectRows) {
746
+ const tasks = listProjectTasks.all(workspace.id, project.id) as Array<{ id: number }>
747
+ let counter = 0
748
+ for (const task of tasks) {
749
+ counter += 1
750
+ setTaskNo.run(counter, task.id)
751
+ }
752
+ setProjectCounter.run(counter, project.id)
753
+ }
754
+ }
755
+ }
756
  }
757
  ]
758
 
src/lib/validation.ts CHANGED
@@ -31,6 +31,7 @@ export const createTaskSchema = z.object({
31
  description: z.string().max(5000).optional(),
32
  status: z.enum(['inbox', 'assigned', 'in_progress', 'review', 'quality_review', 'done']).default('inbox'),
33
  priority: z.enum(['critical', 'high', 'medium', 'low']).default('medium'),
 
34
  assigned_to: z.string().max(100).optional(),
35
  created_by: z.string().max(100).optional(),
36
  due_date: z.number().optional(),
 
31
  description: z.string().max(5000).optional(),
32
  status: z.enum(['inbox', 'assigned', 'in_progress', 'review', 'quality_review', 'done']).default('inbox'),
33
  priority: z.enum(['critical', 'high', 'medium', 'low']).default('medium'),
34
+ project_id: z.number().int().positive().optional(),
35
  assigned_to: z.string().max(100).optional(),
36
  created_by: z.string().max(100).optional(),
37
  due_date: z.number().optional(),
src/store/index.ts CHANGED
@@ -93,6 +93,11 @@ export interface Task {
93
  description?: string
94
  status: 'inbox' | 'assigned' | 'in_progress' | 'review' | 'quality_review' | 'done'
95
  priority: 'low' | 'medium' | 'high' | 'critical' | 'urgent'
 
 
 
 
 
96
  assigned_to?: string
97
  created_by: string
98
  created_at: number
 
93
  description?: string
94
  status: 'inbox' | 'assigned' | 'in_progress' | 'review' | 'quality_review' | 'done'
95
  priority: 'low' | 'medium' | 'high' | 'critical' | 'urgent'
96
+ project_id?: number
97
+ project_ticket_no?: number
98
+ project_name?: string
99
+ project_prefix?: string
100
+ ticket_ref?: string
101
  assigned_to?: string
102
  created_by: string
103
  created_at: number