Skip to content

Commit 05fd6d4

Browse files
chitcommitclaude
andauthored
feat: add backend-driven task system with Notion agent integration (#24)
Implements cc_tasks table, 6 API endpoints with state machine (queued->running->needs_review->verified->done), verification gate requiring hard artifacts for actionable tasks, Notion cron sync, 4 MCP tools, and frontend API types. Tasks flow from Notion AI agent capture through backend validation to verified completion. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d4d1548 commit 05fd6d4

8 files changed

Lines changed: 781 additions & 3 deletions

File tree

migrations/0011_tasks.sql

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
-- 0011_tasks.sql — Backend-driven task system for Notion agent integration
2+
CREATE TABLE IF NOT EXISTS cc_tasks (
3+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
4+
external_id TEXT UNIQUE NOT NULL,
5+
notion_page_id TEXT UNIQUE,
6+
title TEXT NOT NULL,
7+
description TEXT,
8+
task_type TEXT NOT NULL DEFAULT 'general',
9+
source TEXT NOT NULL DEFAULT 'notion',
10+
priority INTEGER DEFAULT 5,
11+
backend_status TEXT NOT NULL DEFAULT 'queued',
12+
assigned_to TEXT,
13+
due_date DATE,
14+
verification_type TEXT NOT NULL DEFAULT 'soft',
15+
verification_artifact TEXT,
16+
verification_notes TEXT,
17+
verified_at TIMESTAMPTZ,
18+
spawned_recommendation_id UUID REFERENCES cc_recommendations(id),
19+
ledger_record_id TEXT,
20+
metadata JSONB DEFAULT '{}',
21+
created_at TIMESTAMPTZ DEFAULT NOW(),
22+
updated_at TIMESTAMPTZ DEFAULT NOW()
23+
);
24+
25+
CREATE INDEX idx_cc_tasks_status ON cc_tasks(backend_status);
26+
CREATE INDEX idx_cc_tasks_external_id ON cc_tasks(external_id);
27+
CREATE INDEX idx_cc_tasks_notion_page_id ON cc_tasks(notion_page_id);
28+
CREATE INDEX idx_cc_tasks_due_date ON cc_tasks(due_date);
29+
CREATE INDEX idx_cc_tasks_priority ON cc_tasks(priority);
30+
CREATE INDEX idx_cc_tasks_type ON cc_tasks(task_type);

src/db/schema.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ export const ccDisputes = pgTable('cc_disputes', {
118118
disputeType: text('dispute_type').notNull(),
119119
amountClaimed: numeric('amount_claimed', { precision: 12, scale: 2 }),
120120
amountAtStake: numeric('amount_at_stake', { precision: 12, scale: 2 }),
121+
stage: text('stage').default('filed'),
121122
status: text('status').default('open'),
122123
priority: integer('priority').default(5),
123124
description: text('description'),
@@ -291,3 +292,34 @@ export const ccSyncLog = pgTable('cc_sync_log', {
291292
startedAt: timestamp('started_at', { withTimezone: true }).defaultNow(),
292293
completedAt: timestamp('completed_at', { withTimezone: true }),
293294
});
295+
296+
// ── Tasks ────────────────────────────────────────────────────
297+
export const ccTasks = pgTable('cc_tasks', {
298+
id: uuid('id').primaryKey().defaultRandom(),
299+
externalId: text('external_id').unique().notNull(),
300+
notionPageId: text('notion_page_id').unique(),
301+
title: text('title').notNull(),
302+
description: text('description'),
303+
taskType: text('task_type').notNull().default('general'),
304+
source: text('source').notNull().default('notion'),
305+
priority: integer('priority').default(5),
306+
backendStatus: text('backend_status').notNull().default('queued'),
307+
assignedTo: text('assigned_to'),
308+
dueDate: date('due_date'),
309+
verificationType: text('verification_type').notNull().default('soft'),
310+
verificationArtifact: text('verification_artifact'),
311+
verificationNotes: text('verification_notes'),
312+
verifiedAt: timestamp('verified_at', { withTimezone: true }),
313+
spawnedRecommendationId: uuid('spawned_recommendation_id').references(() => ccRecommendations.id),
314+
ledgerRecordId: text('ledger_record_id'),
315+
metadata: jsonb('metadata').default({}),
316+
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
317+
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
318+
}, (table) => ({
319+
statusIdx: index('idx_cc_tasks_status').on(table.backendStatus),
320+
externalIdIdx: index('idx_cc_tasks_external_id').on(table.externalId),
321+
notionPageIdIdx: index('idx_cc_tasks_notion_page_id').on(table.notionPageId),
322+
dueDateIdx: index('idx_cc_tasks_due_date').on(table.dueDate),
323+
priorityIdx: index('idx_cc_tasks_priority').on(table.priority),
324+
typeIdx: index('idx_cc_tasks_type').on(table.taskType),
325+
}));

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { paymentPlanRoutes } from './routes/payment-plan';
2222
import { revenueRoutes } from './routes/revenue';
2323
import { emailConnectionRoutes } from './routes/email-connections';
2424
import { chatRoutes } from './routes/chat';
25+
import { taskRoutes } from './routes/tasks';
2526
import { sendBeacon } from './lib/beacon';
2627
import { contextRoutes } from './routes/context';
2728
import { connectRoutes } from './routes/connect';
@@ -120,6 +121,7 @@ app.route('/api/payment-plan', paymentPlanRoutes);
120121
app.route('/api/revenue', revenueRoutes);
121122
app.route('/api/email-connections', emailConnectionRoutes);
122123
app.route('/api/chat', chatRoutes);
124+
app.route('/api/tasks', taskRoutes);
123125
// Identity (authenticated)
124126
app.route('/api/v1', metaRoutes);
125127
// Context (authenticated)

src/lib/cron.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,14 @@ export async function runCronSync(
112112
} catch (err) {
113113
console.error('[planner] failed:', err);
114114
}
115+
116+
// Phase 9: Notion task reconciliation
117+
try {
118+
const tasksSynced = await syncNotionTasks(env, sql);
119+
if (tasksSynced > 0) console.log(`[cron:notion_tasks] synced ${tasksSynced} tasks`);
120+
} catch (err) {
121+
console.error('[cron:notion_tasks] failed:', err);
122+
}
115123
}
116124

117125
if (source === 'utility_scrape') {
@@ -576,6 +584,125 @@ export async function syncPortal(env: Env, sql: NeonQueryFunction<false, false>,
576584
return synced;
577585
}
578586

587+
/**
588+
* Sync tasks from Notion task database into cc_tasks.
589+
* Queries Notion for pages edited in the last 25 hours and upserts by external_id.
590+
* Won't overwrite tasks in terminal states (done, verified).
591+
*/
592+
export async function syncNotionTasks(env: Env, sql: NeonQueryFunction<false, false>): Promise<number> {
593+
const token = await env.COMMAND_KV.get('notion:task_agent_token');
594+
const dbId = await env.COMMAND_KV.get('notion:task_database_id');
595+
if (!token || !dbId) return 0;
596+
597+
const cutoff = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString();
598+
599+
let synced = 0;
600+
let hasMore = true;
601+
let startCursor: string | undefined;
602+
603+
while (hasMore) {
604+
const body: Record<string, unknown> = {
605+
filter: { timestamp: 'last_edited_time', last_edited_time: { after: cutoff } },
606+
page_size: 100,
607+
};
608+
if (startCursor) body.start_cursor = startCursor;
609+
610+
const res = await fetch(`https://api.notion.com/v1/databases/${dbId}/query`, {
611+
method: 'POST',
612+
headers: {
613+
'Authorization': `Bearer ${token}`,
614+
'Notion-Version': '2022-06-28',
615+
'Content-Type': 'application/json',
616+
},
617+
body: JSON.stringify(body),
618+
signal: AbortSignal.timeout(15000),
619+
});
620+
621+
if (!res.ok) {
622+
console.error(`[notion_tasks] Notion API error: ${res.status}`);
623+
break;
624+
}
625+
626+
const data = await res.json() as {
627+
results: Array<{ id: string; properties: Record<string, unknown> }>;
628+
has_more: boolean;
629+
next_cursor?: string;
630+
};
631+
632+
for (const page of data.results) {
633+
const props = page.properties;
634+
const externalId = page.id;
635+
const title = extractNotionTitle(props['Title'] || props['Name']);
636+
if (!title) continue;
637+
638+
const description = extractNotionRichText(props['Description']);
639+
const taskType = extractNotionSelect(props['Type']) || 'general';
640+
const priority = extractNotionNumber(props['Priority']) || 5;
641+
const dueDate = extractNotionDate(props['Due Date']);
642+
const source = extractNotionSelect(props['Source']) || 'notion';
643+
const verificationType = extractNotionSelect(props['Verification']) || 'soft';
644+
const assignedTo = extractNotionPeople(props['Assigned To']);
645+
646+
await sql`
647+
INSERT INTO cc_tasks (external_id, notion_page_id, title, description, task_type, source, priority, assigned_to, due_date, verification_type)
648+
VALUES (${externalId}, ${externalId}, ${title}, ${description}, ${taskType}, ${source}, ${priority}, ${assignedTo}, ${dueDate}, ${verificationType})
649+
ON CONFLICT (external_id) DO UPDATE SET
650+
title = CASE WHEN cc_tasks.backend_status NOT IN ('done', 'verified') THEN EXCLUDED.title ELSE cc_tasks.title END,
651+
description = CASE WHEN cc_tasks.backend_status NOT IN ('done', 'verified') THEN EXCLUDED.description ELSE cc_tasks.description END,
652+
task_type = CASE WHEN cc_tasks.backend_status NOT IN ('done', 'verified') THEN EXCLUDED.task_type ELSE cc_tasks.task_type END,
653+
priority = CASE WHEN cc_tasks.backend_status NOT IN ('done', 'verified') THEN EXCLUDED.priority ELSE cc_tasks.priority END,
654+
assigned_to = CASE WHEN cc_tasks.backend_status NOT IN ('done', 'verified') THEN EXCLUDED.assigned_to ELSE cc_tasks.assigned_to END,
655+
due_date = CASE WHEN cc_tasks.backend_status NOT IN ('done', 'verified') THEN EXCLUDED.due_date ELSE cc_tasks.due_date END,
656+
verification_type = CASE WHEN cc_tasks.backend_status NOT IN ('done', 'verified') THEN EXCLUDED.verification_type ELSE cc_tasks.verification_type END,
657+
updated_at = NOW()
658+
`;
659+
synced++;
660+
}
661+
662+
hasMore = data.has_more;
663+
startCursor = data.next_cursor;
664+
}
665+
666+
return synced;
667+
}
668+
669+
// Notion property extractors
670+
function extractNotionTitle(prop: unknown): string | null {
671+
if (!prop || typeof prop !== 'object') return null;
672+
const p = prop as { title?: Array<{ plain_text?: string }> };
673+
return p.title?.[0]?.plain_text || null;
674+
}
675+
676+
function extractNotionRichText(prop: unknown): string | null {
677+
if (!prop || typeof prop !== 'object') return null;
678+
const p = prop as { rich_text?: Array<{ plain_text?: string }> };
679+
return p.rich_text?.map(t => t.plain_text).join('') || null;
680+
}
681+
682+
function extractNotionSelect(prop: unknown): string | null {
683+
if (!prop || typeof prop !== 'object') return null;
684+
const p = prop as { select?: { name?: string } };
685+
return p.select?.name?.toLowerCase() || null;
686+
}
687+
688+
function extractNotionNumber(prop: unknown): number | null {
689+
if (!prop || typeof prop !== 'object') return null;
690+
const p = prop as { number?: number | null };
691+
return p.number ?? null;
692+
}
693+
694+
function extractNotionDate(prop: unknown): string | null {
695+
if (!prop || typeof prop !== 'object') return null;
696+
const p = prop as { date?: { start?: string } };
697+
return p.date?.start || null;
698+
}
699+
700+
function extractNotionPeople(prop: unknown): string | null {
701+
if (!prop || typeof prop !== 'object') return null;
702+
const p = prop as { people?: Array<{ name?: string }> };
703+
return p.people?.[0]?.name || null;
704+
}
705+
579706
/**
580707
* Pull email-parsed bills from ChittyRouter and upsert into obligations.
581708
* ChittyRouter parses inbound bill emails and returns structured data via /email/urgent.

src/lib/validators.ts

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,25 @@ export const updateObligationSchema = z.object({
5353
});
5454

5555
// ── Disputes ──────────────────────────────────────────────────
56+
export const disputeStageSchema = z.enum([
57+
'filed',
58+
'response_pending',
59+
'evidence_gathering',
60+
'in_review',
61+
'negotiation',
62+
'resolved',
63+
]);
64+
65+
export const disputeStatusSchema = z.enum(['open', 'resolved', 'dismissed']);
5666

5767
export const createDisputeSchema = z.object({
5868
title: z.string().min(1).max(500),
5969
counterparty: z.string().min(1).max(255),
6070
dispute_type: z.string().min(1).max(100),
6171
amount_claimed: z.number().min(0).optional(),
6272
amount_at_stake: z.number().min(0).optional(),
63-
status: z.enum(['open', 'resolved', 'dismissed']).optional(),
73+
stage: disputeStageSchema.optional(),
74+
status: disputeStatusSchema.optional(),
6475
priority: z.number().int().min(1).max(10).optional(),
6576
description: z.string().max(5000).optional(),
6677
next_action: z.string().max(1000).optional(),
@@ -70,11 +81,19 @@ export const createDisputeSchema = z.object({
7081
});
7182

7283
export const updateDisputeSchema = z.object({
73-
status: z.enum(['open', 'resolved', 'dismissed']).optional(),
84+
status: disputeStatusSchema.optional(),
85+
stage: disputeStageSchema.optional(),
86+
title: z.string().min(1).max(500).optional(),
87+
counterparty: z.string().min(1).max(255).optional(),
88+
dispute_type: z.string().min(1).max(100).optional(),
89+
amount_claimed: z.number().min(0).optional(),
90+
amount_at_stake: z.number().min(0).optional(),
7491
priority: z.number().int().min(1).max(10).optional(),
7592
next_action: z.string().max(1000).optional(),
7693
next_action_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
7794
description: z.string().max(5000).optional(),
95+
resolution_target: z.string().max(1000).optional(),
96+
metadata: z.record(z.string(), z.unknown()).optional(),
7897
});
7998

8099
export const createCorrespondenceSchema = z.object({
@@ -280,3 +299,66 @@ export const disputeQuerySchema = z.object({
280299
export const recommendationQuerySchema = z.object({
281300
status: z.string().max(50).optional(),
282301
});
302+
303+
// ── Tasks ────────────────────────────────────────────────────
304+
305+
export const taskStatusSchema = z.enum(['queued', 'running', 'needs_review', 'verified', 'done', 'failed']);
306+
307+
export const taskTypeSchema = z.enum(['general', 'financial', 'legal', 'administrative', 'maintenance', 'communication']);
308+
309+
export const verificationTypeSchema = z.enum(['hard', 'soft']);
310+
311+
export const createTaskSchema = z.object({
312+
external_id: z.string().min(1).max(500),
313+
notion_page_id: z.string().max(500).optional(),
314+
title: z.string().min(1).max(1000),
315+
description: z.string().max(10000).optional(),
316+
task_type: taskTypeSchema.optional(),
317+
source: z.enum(['notion', 'email', 'mention', 'manual', 'api']).optional(),
318+
priority: z.number().int().min(1).max(10).optional(),
319+
assigned_to: z.string().max(255).optional(),
320+
due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Must be YYYY-MM-DD').optional(),
321+
verification_type: verificationTypeSchema.optional(),
322+
metadata: z.record(z.string(), z.unknown()).optional(),
323+
});
324+
325+
export const updateTaskStatusSchema = z.object({
326+
status: taskStatusSchema,
327+
notes: z.string().max(2000).optional(),
328+
});
329+
330+
export const verifyTaskSchema = z.object({
331+
verification_artifact: z.string().min(1).max(2000),
332+
verification_notes: z.string().max(5000).optional(),
333+
ledger_record_id: z.string().max(500).optional(),
334+
});
335+
336+
export const spawnRecommendationFromTaskSchema = z.object({
337+
rec_type: z.string().min(1).max(100),
338+
priority: z.number().int().min(1).max(5).optional(),
339+
action_type: z.string().max(100).optional(),
340+
estimated_savings: z.number().min(0).optional(),
341+
});
342+
343+
export const taskQuerySchema = z.object({
344+
status: taskStatusSchema.optional(),
345+
task_type: taskTypeSchema.optional(),
346+
source: z.string().max(50).optional(),
347+
priority_max: z.coerce.number().int().min(1).max(10).optional(),
348+
limit: z.coerce.number().int().min(1).max(100).optional(),
349+
offset: z.coerce.number().int().min(0).optional(),
350+
});
351+
352+
export const notionWebhookPayloadSchema = z.object({
353+
external_id: z.string().min(1).max(500),
354+
notion_page_id: z.string().max(500).optional(),
355+
title: z.string().min(1).max(1000),
356+
description: z.string().max(10000).optional(),
357+
task_type: taskTypeSchema.optional(),
358+
source: z.enum(['email', 'mention', 'manual']).optional(),
359+
priority: z.number().int().min(1).max(10).optional(),
360+
assigned_to: z.string().max(255).optional(),
361+
due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
362+
verification_type: verificationTypeSchema.optional(),
363+
metadata: z.record(z.string(), z.unknown()).optional(),
364+
});

0 commit comments

Comments
 (0)