refactor: migrate to monorepo structure with Docker, PocketBase, and e2e tests
- Reorganize into apps/, packages/, docs/, e2e/, pocketbase/ directories - Add Dockerfiles for web, worker, and PocketBase services - Add docker-compose.yml for local orchestration - Add turbo.json for monorepo task management - Add Playwright e2e test infrastructure - Add PocketBase backend with migrations - Remove Vite/Next.js/ESLint/PostCSS config files - Update package.json with workspace dependencies - Add .env.example and .dockerignore
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
import { createAdminClient } from '../pocketbase';
|
||||
import type { Agent, AgentTask } from '@project-e/shared';
|
||||
|
||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
||||
function asAgent(record: Record<string, unknown>): Agent {
|
||||
return record as unknown as Agent;
|
||||
}
|
||||
|
||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
||||
function asAgentTask(record: Record<string, unknown>): AgentTask {
|
||||
return record as unknown as AgentTask;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse @mentions from text content
|
||||
* Matches @agentname pattern
|
||||
*/
|
||||
export function parseMentions(content: string): string[] {
|
||||
const regex = /@(\w+)/g;
|
||||
const mentions: string[] = [];
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
mentions.push(match[1].toLowerCase());
|
||||
}
|
||||
|
||||
return [...new Set(mentions)]; // Deduplicate
|
||||
}
|
||||
|
||||
/**
|
||||
* Process @mentions in content and route to agents
|
||||
*/
|
||||
export async function processMentions(
|
||||
content: string,
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
userId: string
|
||||
): Promise<AgentTask[]> {
|
||||
const mentionNames = parseMentions(content);
|
||||
if (mentionNames.length === 0) return [];
|
||||
|
||||
const pb = createAdminClient();
|
||||
const createdTasks: AgentTask[] = [];
|
||||
|
||||
for (const mentionName of mentionNames) {
|
||||
// Find agent by name (case-insensitive)
|
||||
const agentResults = await pb.collection('agents').getFullList({
|
||||
filter: 'status = "active"',
|
||||
});
|
||||
const agents = agentResults.map((r) => asAgent(r as unknown as Record<string, unknown>));
|
||||
|
||||
const agent = agents.find((a) => a.name.toLowerCase() === mentionName);
|
||||
if (!agent) continue;
|
||||
|
||||
// Extract instruction text after the @mention
|
||||
const mentionRegex = new RegExp(`@${mentionName}\\s+(.+?)(?=@\\w+|$)`, 'is');
|
||||
const mentionMatch = content.match(mentionRegex);
|
||||
const instruction = mentionMatch ? mentionMatch[1].trim() : '';
|
||||
|
||||
// Create agent task
|
||||
const agentTaskRecord = await pb.collection('agent_tasks').create({
|
||||
agent_id: agent.id,
|
||||
task_type: 'mention',
|
||||
input: {
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
instruction,
|
||||
user_id: userId,
|
||||
},
|
||||
status: 'pending',
|
||||
});
|
||||
const agentTask = asAgentTask(agentTaskRecord as unknown as Record<string, unknown>);
|
||||
|
||||
createdTasks.push(agentTask);
|
||||
|
||||
// Queue webhook delivery to agent if it has a webhook URL in config
|
||||
const agentConfig = (agent.config || {}) as Record<string, unknown>;
|
||||
const webhookUrl = agentConfig.webhook_url as string | undefined;
|
||||
|
||||
if (webhookUrl && agent.api_key) {
|
||||
await pb.collection('queue_jobs').create({
|
||||
queue: 'agents',
|
||||
type: 'agent_mention',
|
||||
payload: {
|
||||
agent_task_id: agentTask.id,
|
||||
agent_id: agent.id,
|
||||
agent_webhook_url: webhookUrl,
|
||||
agent_api_key: agent.api_key,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
instruction,
|
||||
user_id: userId,
|
||||
},
|
||||
status: 'pending',
|
||||
attempts: 0,
|
||||
max_attempts: 3,
|
||||
scheduled_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return createdTasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver @mention to agent via webhook
|
||||
*/
|
||||
export async function deliverAgentMention(payload: {
|
||||
agent_task_id: string;
|
||||
agent_id: string;
|
||||
agent_webhook_url: string;
|
||||
agent_api_key: string;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
instruction: string;
|
||||
user_id: string;
|
||||
}): Promise<{ success: boolean; statusCode?: number; responseBody?: string }> {
|
||||
const { agent_webhook_url, agent_api_key, ...body } = payload;
|
||||
|
||||
try {
|
||||
const response = await fetch(agent_webhook_url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${agent_api_key}`,
|
||||
'X-Agent-Task-Id': payload.agent_task_id,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(30000), // 30 second timeout for agent work
|
||||
});
|
||||
|
||||
const responseBody = await response.text();
|
||||
|
||||
return {
|
||||
success: response.ok,
|
||||
statusCode: response.status,
|
||||
responseBody,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
responseBody: String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { createPocketBaseClient, createAdminClient } from '../pocketbase';
|
||||
import type { Habit, HabitLog, HabitScoreConfig } from '@project-e/shared';
|
||||
|
||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
||||
function asHabit(record: Record<string, unknown>): Habit {
|
||||
return record as unknown as Habit;
|
||||
}
|
||||
|
||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
||||
function asHabitLog(record: Record<string, unknown>): HabitLog {
|
||||
return record as unknown as HabitLog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default habit score weights
|
||||
*/
|
||||
const DEFAULT_SCORE_CONFIG: Required<HabitScoreConfig> = {
|
||||
streak_weight: 0.5,
|
||||
consistency_weight: 0.2,
|
||||
difficulty_weight: 0.3,
|
||||
};
|
||||
|
||||
/**
|
||||
* Difficulty multipliers
|
||||
*/
|
||||
const DIFFICULTY_MULTIPLIER: Record<string, number> = {
|
||||
easy: 0.7,
|
||||
medium: 1.0,
|
||||
hard: 1.3,
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate habit score (0-100)
|
||||
* Weighted composite: streak, consistency, difficulty
|
||||
*/
|
||||
export function calculateHabitScore(
|
||||
habit: Habit,
|
||||
logs: HabitLog[],
|
||||
scoreConfig?: HabitScoreConfig
|
||||
): number {
|
||||
const config = { ...DEFAULT_SCORE_CONFIG, ...scoreConfig };
|
||||
|
||||
// 1. Streak score (0-100)
|
||||
const streakScore = Math.min((habit.current_streak / 30) * 100, 100);
|
||||
|
||||
// 2. Consistency rate (last 30 days)
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
const recentLogs = logs.filter(
|
||||
(l) => new Date(l.logged_at) >= thirtyDaysAgo && l.completed && !l.skipped
|
||||
);
|
||||
const consistencyScore = (recentLogs.length / 30) * 100;
|
||||
|
||||
// 3. Difficulty multiplier
|
||||
const difficultyMultiplier = DIFFICULTY_MULTIPLIER[habit.difficulty] || 1.0;
|
||||
const difficultyScore = difficultyMultiplier * 100;
|
||||
|
||||
// Weighted composite
|
||||
const score = Math.round(
|
||||
streakScore * config.streak_weight +
|
||||
consistencyScore * config.consistency_weight +
|
||||
difficultyScore * config.difficulty_weight
|
||||
);
|
||||
|
||||
return Math.min(Math.max(score, 0), 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update streaks after habit completion
|
||||
*/
|
||||
export function updateStreaks(
|
||||
habit: Habit,
|
||||
completionDate: string
|
||||
): {
|
||||
current_streak: number;
|
||||
best_streak: number;
|
||||
} {
|
||||
const today = new Date(completionDate);
|
||||
const lastCompleted = habit.updated ? new Date(habit.updated) : null;
|
||||
|
||||
let newStreak = habit.current_streak;
|
||||
|
||||
if (lastCompleted) {
|
||||
const daysDiff = Math.floor(
|
||||
(today.getTime() - lastCompleted.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
if (daysDiff === 1) {
|
||||
// Consecutive day — increment streak
|
||||
newStreak = habit.current_streak + 1;
|
||||
} else if (daysDiff === 0) {
|
||||
// Same day — no change (already completed today)
|
||||
newStreak = habit.current_streak;
|
||||
} else {
|
||||
// Streak broken — reset to 1
|
||||
newStreak = 1;
|
||||
}
|
||||
} else {
|
||||
// First completion
|
||||
newStreak = 1;
|
||||
}
|
||||
|
||||
return {
|
||||
current_streak: newStreak,
|
||||
best_streak: Math.max(newStreak, habit.best_streak),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Log habit completion
|
||||
*/
|
||||
export async function logHabitCompletion(
|
||||
habitId: string,
|
||||
data: {
|
||||
logged_at?: string;
|
||||
mood?: number;
|
||||
value?: number;
|
||||
notes?: string;
|
||||
},
|
||||
token?: string
|
||||
): Promise<{ log: HabitLog; habit: Habit }> {
|
||||
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
||||
|
||||
const loggedAt = data.logged_at || new Date().toISOString();
|
||||
|
||||
// Create log entry
|
||||
const log = asHabitLog(
|
||||
await pb.collection('habit_logs').create({
|
||||
habit_id: habitId,
|
||||
completed: true,
|
||||
mood: data.mood || undefined,
|
||||
value: data.value || undefined,
|
||||
notes: data.notes || '',
|
||||
logged_at: loggedAt,
|
||||
}) as unknown as Record<string, unknown>
|
||||
);
|
||||
|
||||
// Get current habit state
|
||||
const habit = asHabit(
|
||||
await pb.collection('habits').getOne(habitId) as unknown as Record<string, unknown>
|
||||
);
|
||||
|
||||
// Update streaks
|
||||
const streaks = updateStreaks(habit, loggedAt);
|
||||
|
||||
// Get all logs for score calculation
|
||||
const allLogsResult = await pb.collection('habit_logs').getFullList({
|
||||
filter: `habit_id = "${habitId}"`,
|
||||
});
|
||||
const allLogs = allLogsResult.map((r) => asHabitLog(r as unknown as Record<string, unknown>));
|
||||
|
||||
// Calculate new score
|
||||
const score = calculateHabitScore(
|
||||
habit,
|
||||
allLogs,
|
||||
habit.score_config as HabitScoreConfig
|
||||
);
|
||||
|
||||
// Update habit
|
||||
const updatedHabit = asHabit(
|
||||
await pb.collection('habits').update(habitId, {
|
||||
current_streak: streaks.current_streak,
|
||||
best_streak: streaks.best_streak,
|
||||
total_completions: habit.total_completions + 1,
|
||||
score,
|
||||
}) as unknown as Record<string, unknown>
|
||||
);
|
||||
|
||||
return { log, habit: updatedHabit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a habit is due today
|
||||
*/
|
||||
export function isHabitDueToday(habit: Habit): boolean {
|
||||
const today = new Date();
|
||||
const dayOfWeek = today.getDay(); // 0 = Sunday, 6 = Saturday
|
||||
|
||||
// Check custom_days if set
|
||||
if (habit.custom_days && habit.custom_days.length > 0) {
|
||||
return habit.custom_days.includes(dayOfWeek);
|
||||
}
|
||||
|
||||
// Check frequency
|
||||
switch (habit.frequency) {
|
||||
case 'daily':
|
||||
return true;
|
||||
case 'weekly': {
|
||||
// Due on the same day of week as start_date
|
||||
const startDate = habit.start_date
|
||||
? new Date(habit.start_date)
|
||||
: new Date(habit.created);
|
||||
return dayOfWeek === startDate.getDay();
|
||||
}
|
||||
case 'custom':
|
||||
// Custom frequency without custom_days — assume always due
|
||||
return true;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get habits due today
|
||||
*/
|
||||
export async function getHabitsDueToday(
|
||||
token?: string
|
||||
): Promise<Habit[]> {
|
||||
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
||||
|
||||
const results = await pb.collection('habits').getFullList({
|
||||
filter: 'active = true',
|
||||
});
|
||||
|
||||
return results
|
||||
.map((r) => asHabit(r as unknown as Record<string, unknown>))
|
||||
.filter((habit) => isHabitDueToday(habit));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get habit streaks summary
|
||||
*/
|
||||
export async function getHabitStreaks(
|
||||
token?: string
|
||||
): Promise<
|
||||
Array<{
|
||||
habit: Habit;
|
||||
current_streak: number;
|
||||
best_streak: number;
|
||||
}>
|
||||
> {
|
||||
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
||||
|
||||
const results = await pb.collection('habits').getFullList({
|
||||
filter: 'active = true',
|
||||
});
|
||||
|
||||
const habits = results.map((r) => asHabit(r as unknown as Record<string, unknown>));
|
||||
|
||||
return habits
|
||||
.map((h) => ({
|
||||
habit: h,
|
||||
current_streak: h.current_streak,
|
||||
best_streak: h.best_streak,
|
||||
}))
|
||||
.sort((a, b) => b.current_streak - a.current_streak);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from './task-service';
|
||||
export * from './habit-service';
|
||||
export * from './project-service';
|
||||
export * from './note-service';
|
||||
export * from './report-service';
|
||||
export * from './webhook-service';
|
||||
export * from './agent-mention-service';
|
||||
@@ -0,0 +1,307 @@
|
||||
import { createPocketBaseClient, createAdminClient } from '../pocketbase';
|
||||
import type { Note, NoteLink, NoteTaskLink, Task } from '@project-e/shared';
|
||||
|
||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
||||
function asNote(record: Record<string, unknown>): Note {
|
||||
return record as unknown as Note;
|
||||
}
|
||||
|
||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
||||
function asNoteLink(record: Record<string, unknown>): NoteLink {
|
||||
return record as unknown as NoteLink;
|
||||
}
|
||||
|
||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
||||
function asNoteTaskLink(record: Record<string, unknown>): NoteTaskLink {
|
||||
return record as unknown as NoteTaskLink;
|
||||
}
|
||||
|
||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
||||
function asTask(record: Record<string, unknown>): Task {
|
||||
return record as unknown as Task;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract wikilinks from note content
|
||||
* Matches [[Note Title]] syntax
|
||||
*/
|
||||
export function extractWikilinks(content: string): string[] {
|
||||
const regex = /\[\[([^\]]+)\]\]/g;
|
||||
const links: string[] = [];
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
links.push(match[1].trim());
|
||||
}
|
||||
|
||||
return [...new Set(links)]; // Deduplicate
|
||||
}
|
||||
|
||||
/**
|
||||
* Update note_links when a note is saved
|
||||
* Re-syncs all outbound wikilinks from the note content
|
||||
*/
|
||||
export async function syncNoteLinks(
|
||||
noteId: string,
|
||||
content: string,
|
||||
token?: string
|
||||
): Promise<void> {
|
||||
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
||||
|
||||
// Extract wikilinks from content
|
||||
const linkTitles = extractWikilinks(content);
|
||||
|
||||
// Get existing links from this note
|
||||
const existingResults = await pb.collection('note_links').getFullList({
|
||||
filter: `source_note_id = "${noteId}"`,
|
||||
});
|
||||
const existingLinks = existingResults.map((r) =>
|
||||
asNoteLink(r as unknown as Record<string, unknown>)
|
||||
);
|
||||
|
||||
// Delete existing links
|
||||
for (const link of existingLinks) {
|
||||
await pb.collection('note_links').delete(link.id);
|
||||
}
|
||||
|
||||
// Create new links
|
||||
for (const title of linkTitles) {
|
||||
// Find target note by title
|
||||
const targetNotes = await pb.collection('notes').getFullList({
|
||||
filter: `title = "${title}"`,
|
||||
});
|
||||
|
||||
if (targetNotes.length > 0) {
|
||||
await pb.collection('note_links').create({
|
||||
source_note_id: noteId,
|
||||
target_note_id: targetNotes[0].id,
|
||||
label: title,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backlinks for a note (notes that link TO this note)
|
||||
*/
|
||||
export async function getBacklinks(
|
||||
noteId: string,
|
||||
token?: string
|
||||
): Promise<Note[]> {
|
||||
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
||||
|
||||
// Find all note_links where this note is the target
|
||||
const linkResults = await pb.collection('note_links').getFullList({
|
||||
filter: `target_note_id = "${noteId}"`,
|
||||
});
|
||||
const links = linkResults.map((r) =>
|
||||
asNoteLink(r as unknown as Record<string, unknown>)
|
||||
);
|
||||
|
||||
// Get the source notes
|
||||
const backlinks: Note[] = [];
|
||||
for (const link of links) {
|
||||
const note = asNote(
|
||||
await pb
|
||||
.collection('notes')
|
||||
.getOne(link.source_note_id) as unknown as Record<string, unknown>
|
||||
);
|
||||
backlinks.push(note);
|
||||
}
|
||||
|
||||
return backlinks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync checkboxes in note content with tasks
|
||||
* Maps checkboxes to tasks via note_task_links
|
||||
*/
|
||||
export async function syncNoteTasks(
|
||||
noteId: string,
|
||||
content: string,
|
||||
token?: string
|
||||
): Promise<void> {
|
||||
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
||||
|
||||
// Extract checkbox patterns: - [ ] Task title or - [x] Task title
|
||||
const checkboxRegex = /^- \[([ x])\] (.+)$/gm;
|
||||
const checkboxes: Array<{
|
||||
done: boolean;
|
||||
title: string;
|
||||
position: number;
|
||||
}> = [];
|
||||
let match;
|
||||
let position = 0;
|
||||
|
||||
while ((match = checkboxRegex.exec(content)) !== null) {
|
||||
checkboxes.push({
|
||||
done: match[1] === 'x',
|
||||
title: match[2].trim(),
|
||||
position: position++,
|
||||
});
|
||||
}
|
||||
|
||||
// Get existing note_task_links
|
||||
const existingResults = await pb.collection('note_task_links').getFullList({
|
||||
filter: `note_id = "${noteId}"`,
|
||||
});
|
||||
const existingLinks = existingResults.map((r) =>
|
||||
asNoteTaskLink(r as unknown as Record<string, unknown>)
|
||||
);
|
||||
|
||||
// Match checkboxes to existing links by index position
|
||||
for (let i = 0; i < checkboxes.length; i++) {
|
||||
const checkbox = checkboxes[i];
|
||||
const existingLink = existingLinks.find(
|
||||
(l) => l.label === `checkbox_${i}` || (!l.label && existingLinks.indexOf(l) === i)
|
||||
);
|
||||
|
||||
if (existingLink) {
|
||||
// Update existing task
|
||||
const task = asTask(
|
||||
await pb
|
||||
.collection('tasks')
|
||||
.getOne(existingLink.task_id) as unknown as Record<string, unknown>
|
||||
);
|
||||
|
||||
// Update title if changed
|
||||
if (task.title !== checkbox.title) {
|
||||
await pb.collection('tasks').update(existingLink.task_id, {
|
||||
title: checkbox.title,
|
||||
});
|
||||
}
|
||||
|
||||
// Update done status if changed
|
||||
const taskDone = task.status === 'done';
|
||||
if (taskDone !== checkbox.done) {
|
||||
await pb.collection('tasks').update(existingLink.task_id, {
|
||||
status: checkbox.done ? 'done' : 'todo',
|
||||
...(checkbox.done && { completed_at: new Date().toISOString() }),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Create new task for this checkbox
|
||||
const note = asNote(
|
||||
await pb
|
||||
.collection('notes')
|
||||
.getOne(noteId) as unknown as Record<string, unknown>
|
||||
);
|
||||
|
||||
const newTask = await pb.collection('tasks').create({
|
||||
title: checkbox.title,
|
||||
description: '',
|
||||
status: checkbox.done ? 'done' : 'todo',
|
||||
priority: 'medium',
|
||||
domain: note.domain,
|
||||
tags: [],
|
||||
...(checkbox.done && { completed_at: new Date().toISOString() }),
|
||||
});
|
||||
|
||||
// Create mapping
|
||||
await pb.collection('note_task_links').create({
|
||||
note_id: noteId,
|
||||
task_id: newTask.id,
|
||||
label: `checkbox_${i}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Remove links for checkboxes that no longer exist
|
||||
for (const link of existingLinks) {
|
||||
const matchIndex = parseInt(
|
||||
(link.label || '').replace('checkbox_', ''),
|
||||
10
|
||||
);
|
||||
if (isNaN(matchIndex) || matchIndex >= checkboxes.length) {
|
||||
await pb.collection('note_task_links').delete(link.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse frontmatter from note content
|
||||
* Frontmatter is YAML between --- delimiters at the top
|
||||
*/
|
||||
export function parseFrontmatter(content: string): {
|
||||
frontmatter: Record<string, unknown>;
|
||||
contentWithoutFrontmatter: string;
|
||||
} {
|
||||
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
|
||||
const match = content.match(frontmatterRegex);
|
||||
|
||||
if (!match) {
|
||||
return { frontmatter: {}, contentWithoutFrontmatter: content };
|
||||
}
|
||||
|
||||
// Simple YAML parsing (key: value pairs)
|
||||
const yamlText = match[1];
|
||||
const frontmatter: Record<string, unknown> = {};
|
||||
|
||||
for (const line of yamlText.split('\n')) {
|
||||
const colonIndex = line.indexOf(':');
|
||||
if (colonIndex > 0) {
|
||||
const key = line.slice(0, colonIndex).trim();
|
||||
const value = line.slice(colonIndex + 1).trim();
|
||||
|
||||
// Try to parse as JSON for arrays/objects
|
||||
try {
|
||||
frontmatter[key] = JSON.parse(value);
|
||||
} catch {
|
||||
frontmatter[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
frontmatter,
|
||||
contentWithoutFrontmatter: match[2],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get graph data for note visualization
|
||||
*/
|
||||
export async function getNoteGraph(
|
||||
token?: string
|
||||
): Promise<{
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
domain: string;
|
||||
connectionCount: number;
|
||||
}>;
|
||||
edges: Array<{ source: string; target: string }>;
|
||||
}> {
|
||||
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
||||
|
||||
const noteResults = await pb.collection('notes').getFullList();
|
||||
const notes = noteResults.map((r) => asNote(r as unknown as Record<string, unknown>));
|
||||
|
||||
const linkResults = await pb.collection('note_links').getFullList();
|
||||
const links = linkResults.map((r) =>
|
||||
asNoteLink(r as unknown as Record<string, unknown>)
|
||||
);
|
||||
|
||||
// Count connections per note
|
||||
const connectionCounts: Record<string, number> = {};
|
||||
for (const link of links) {
|
||||
connectionCounts[link.source_note_id] =
|
||||
(connectionCounts[link.source_note_id] || 0) + 1;
|
||||
connectionCounts[link.target_note_id] =
|
||||
(connectionCounts[link.target_note_id] || 0) + 1;
|
||||
}
|
||||
|
||||
const nodes = notes.map((n) => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
domain: n.domain,
|
||||
connectionCount: connectionCounts[n.id] || 0,
|
||||
}));
|
||||
|
||||
const edges = links.map((l) => ({
|
||||
source: l.source_note_id,
|
||||
target: l.target_note_id,
|
||||
}));
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { createPocketBaseClient, createAdminClient } from '../pocketbase';
|
||||
import type { Project, Task, Milestone } from '@project-e/shared';
|
||||
|
||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
||||
function asProject(record: Record<string, unknown>): Project {
|
||||
return record as unknown as Project;
|
||||
}
|
||||
|
||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
||||
function asTask(record: Record<string, unknown>): Task {
|
||||
return record as unknown as Task;
|
||||
}
|
||||
|
||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
||||
function asMilestone(record: Record<string, unknown>): Milestone {
|
||||
return record as unknown as Milestone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute project progress from task completion percentage
|
||||
* Returns the percentage but does not persist it (Project schema has no progress field)
|
||||
*/
|
||||
export async function computeProjectProgress(
|
||||
projectId: string,
|
||||
token?: string
|
||||
): Promise<number> {
|
||||
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
||||
|
||||
// Get all tasks for this project
|
||||
const results = await pb.collection('tasks').getFullList({
|
||||
filter: `project_id = "${projectId}"`,
|
||||
});
|
||||
const tasks = results.map((r) => asTask(r as unknown as Record<string, unknown>));
|
||||
|
||||
if (tasks.length === 0) return 0;
|
||||
|
||||
const done = tasks.filter((t) => t.status === 'done').length;
|
||||
return Math.round((done / tasks.length) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project with computed progress and task counts
|
||||
*/
|
||||
export async function getProjectWithProgress(
|
||||
projectId: string,
|
||||
token?: string
|
||||
): Promise<Project & { taskCount: number; doneCount: number; progress: number }> {
|
||||
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
||||
|
||||
const project = asProject(
|
||||
await pb.collection('projects').getOne(projectId) as unknown as Record<string, unknown>
|
||||
);
|
||||
|
||||
const results = await pb.collection('tasks').getFullList({
|
||||
filter: `project_id = "${projectId}"`,
|
||||
});
|
||||
const tasks = results.map((r) => asTask(r as unknown as Record<string, unknown>));
|
||||
|
||||
const doneCount = tasks.filter((t) => t.status === 'done').length;
|
||||
const progress = tasks.length > 0 ? Math.round((doneCount / tasks.length) * 100) : 0;
|
||||
|
||||
return {
|
||||
...project,
|
||||
taskCount: tasks.length,
|
||||
doneCount,
|
||||
progress,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check milestone dependency enforcement
|
||||
*/
|
||||
export async function canStartMilestone(
|
||||
milestoneId: string,
|
||||
token?: string
|
||||
): Promise<{ allowed: boolean; blockedBy: string[] }> {
|
||||
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
||||
|
||||
// Get the milestone with its embedded dependencies
|
||||
const milestone = asMilestone(
|
||||
await pb.collection('milestones').getOne(milestoneId) as unknown as Record<string, unknown>
|
||||
);
|
||||
|
||||
const blockedBy: string[] = [];
|
||||
|
||||
for (const dep of milestone.dependencies || []) {
|
||||
const depMilestone = asMilestone(
|
||||
await pb
|
||||
.collection('milestones')
|
||||
.getOne(dep.depends_on_id) as unknown as Record<string, unknown>
|
||||
);
|
||||
if (depMilestone.status !== 'complete') {
|
||||
blockedBy.push(dep.depends_on_id);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: blockedBy.length === 0,
|
||||
blockedBy,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get milestone timeline for a project
|
||||
*/
|
||||
export async function getMilestoneTimeline(
|
||||
projectId: string,
|
||||
token?: string
|
||||
): Promise<Milestone[]> {
|
||||
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
||||
|
||||
const results = await pb.collection('milestones').getFullList({
|
||||
filter: `project_id = "${projectId}"`,
|
||||
sort: 'sort_order',
|
||||
});
|
||||
|
||||
return results.map((r) => asMilestone(r as unknown as Record<string, unknown>));
|
||||
}
|
||||
|
||||
/**
|
||||
* Log milestone status change to milestone_history
|
||||
*/
|
||||
export async function logMilestoneStatusChange(
|
||||
milestoneId: string,
|
||||
field: string,
|
||||
oldValue: string | undefined,
|
||||
newValue: string,
|
||||
changedBy?: string,
|
||||
token?: string
|
||||
): Promise<void> {
|
||||
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
||||
|
||||
await pb.collection('milestone_history').create({
|
||||
milestone_id: milestoneId,
|
||||
field,
|
||||
old_value: oldValue,
|
||||
new_value: newValue,
|
||||
changed_by: changedBy,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { createAdminClient } from '../pocketbase';
|
||||
import type {
|
||||
Task,
|
||||
Habit,
|
||||
HabitLog,
|
||||
Milestone,
|
||||
TimeEntry,
|
||||
} from '@project-e/shared';
|
||||
|
||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
||||
function asTask(record: Record<string, unknown>): Task {
|
||||
return record as unknown as Task;
|
||||
}
|
||||
|
||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
||||
function asHabit(record: Record<string, unknown>): Habit {
|
||||
return record as unknown as Habit;
|
||||
}
|
||||
|
||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
||||
function asHabitLog(record: Record<string, unknown>): HabitLog {
|
||||
return record as unknown as HabitLog;
|
||||
}
|
||||
|
||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
||||
function asMilestone(record: Record<string, unknown>): Milestone {
|
||||
return record as unknown as Milestone;
|
||||
}
|
||||
|
||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
||||
function asTimeEntry(record: Record<string, unknown>): TimeEntry {
|
||||
return record as unknown as TimeEntry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate weekly summary report data
|
||||
*/
|
||||
export async function generateWeeklySummary(
|
||||
weekStart: string,
|
||||
weekEnd: string,
|
||||
token?: string
|
||||
): Promise<{
|
||||
tasksCompleted: number;
|
||||
habitsTracked: number;
|
||||
timeLogged: number;
|
||||
streaks: Array<{ name: string; streak: number }>;
|
||||
byDomain: Record<string, { tasks: number; habits: number }>;
|
||||
}> {
|
||||
const pb = token ? createAdminClient() : createAdminClient();
|
||||
|
||||
// Tasks completed this week
|
||||
const taskResults = await pb.collection('tasks').getFullList({
|
||||
filter: `status = "done" && completed_at >= "${weekStart}" && completed_at <= "${weekEnd}"`,
|
||||
});
|
||||
const tasks = taskResults.map((r) => asTask(r as unknown as Record<string, unknown>));
|
||||
|
||||
// Habits logged this week
|
||||
const habitLogResults = await pb.collection('habit_logs').getFullList({
|
||||
filter: `logged_at >= "${weekStart}" && logged_at <= "${weekEnd}"`,
|
||||
});
|
||||
const habitLogs = habitLogResults.map((r) =>
|
||||
asHabitLog(r as unknown as Record<string, unknown>)
|
||||
);
|
||||
|
||||
// Time entries this week
|
||||
const timeEntryResults = await pb.collection('time_entries').getFullList({
|
||||
filter: `started_at >= "${weekStart}" && started_at <= "${weekEnd}"`,
|
||||
});
|
||||
const timeEntries = timeEntryResults.map((r) =>
|
||||
asTimeEntry(r as unknown as Record<string, unknown>)
|
||||
);
|
||||
|
||||
const totalTime = timeEntries.reduce(
|
||||
(sum, e) => sum + e.duration_minutes,
|
||||
0
|
||||
);
|
||||
|
||||
// Streaks
|
||||
const habitResults = await pb.collection('habits').getFullList();
|
||||
const habits = habitResults.map((r) => asHabit(r as unknown as Record<string, unknown>));
|
||||
const streaks = habits
|
||||
.map((h) => ({ name: h.name, streak: h.current_streak }))
|
||||
.sort((a, b) => b.streak - a.streak);
|
||||
|
||||
// By domain
|
||||
const byDomain: Record<string, { tasks: number; habits: number }> = {};
|
||||
for (const task of tasks) {
|
||||
if (!byDomain[task.domain])
|
||||
byDomain[task.domain] = { tasks: 0, habits: 0 };
|
||||
byDomain[task.domain].tasks++;
|
||||
}
|
||||
|
||||
return {
|
||||
tasksCompleted: tasks.length,
|
||||
habitsTracked: habitLogs.length,
|
||||
timeLogged: totalTime,
|
||||
streaks,
|
||||
byDomain,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate project health report data
|
||||
*/
|
||||
export async function generateProjectHealth(
|
||||
projectId: string,
|
||||
token?: string
|
||||
): Promise<{
|
||||
taskCount: number;
|
||||
doneCount: number;
|
||||
overdueCount: number;
|
||||
milestoneStatus: Record<string, number>;
|
||||
completionRate: number;
|
||||
}> {
|
||||
const pb = token ? createAdminClient() : createAdminClient();
|
||||
|
||||
const taskResults = await pb.collection('tasks').getFullList({
|
||||
filter: `project_id = "${projectId}"`,
|
||||
});
|
||||
const tasks = taskResults.map((r) => asTask(r as unknown as Record<string, unknown>));
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const doneCount = tasks.filter((t) => t.status === 'done').length;
|
||||
const overdueCount = tasks.filter(
|
||||
(t) => t.due_date && t.due_date < today && t.status !== 'done'
|
||||
).length;
|
||||
|
||||
// Milestone status
|
||||
const milestoneResults = await pb.collection('milestones').getFullList({
|
||||
filter: `project_id = "${projectId}"`,
|
||||
});
|
||||
const milestones = milestoneResults.map((r) =>
|
||||
asMilestone(r as unknown as Record<string, unknown>)
|
||||
);
|
||||
|
||||
const milestoneStatus: Record<string, number> = {
|
||||
planned: 0,
|
||||
in_progress: 0,
|
||||
complete: 0,
|
||||
};
|
||||
for (const m of milestones) {
|
||||
milestoneStatus[m.status] = (milestoneStatus[m.status] || 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
taskCount: tasks.length,
|
||||
doneCount,
|
||||
overdueCount,
|
||||
milestoneStatus,
|
||||
completionRate:
|
||||
tasks.length > 0 ? Math.round((doneCount / tasks.length) * 100) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate habit analysis report data
|
||||
*/
|
||||
export async function generateHabitAnalysis(
|
||||
days: number = 30,
|
||||
token?: string
|
||||
): Promise<{
|
||||
habits: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
streak: number;
|
||||
score: number;
|
||||
consistencyRate: number;
|
||||
completions: number;
|
||||
}>;
|
||||
atRiskHabits: string[];
|
||||
}> {
|
||||
const pb = token ? createAdminClient() : createAdminClient();
|
||||
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - days);
|
||||
const startStr = startDate.toISOString().split('T')[0];
|
||||
|
||||
const habitResults = await pb.collection('habits').getFullList();
|
||||
const habits = habitResults.map((r) => asHabit(r as unknown as Record<string, unknown>));
|
||||
|
||||
const habitAnalysis = [];
|
||||
const atRiskHabits: string[] = [];
|
||||
|
||||
for (const habit of habits) {
|
||||
const logResults = await pb.collection('habit_logs').getFullList({
|
||||
filter: `habit_id = "${habit.id}" && logged_at >= "${startStr}" && completed = true && skipped = false`,
|
||||
});
|
||||
const logs = logResults.map((r) =>
|
||||
asHabitLog(r as unknown as Record<string, unknown>)
|
||||
);
|
||||
|
||||
const consistencyRate = Math.round((logs.length / days) * 100);
|
||||
|
||||
habitAnalysis.push({
|
||||
id: habit.id,
|
||||
name: habit.name,
|
||||
streak: habit.current_streak,
|
||||
score: habit.score,
|
||||
consistencyRate,
|
||||
completions: logs.length,
|
||||
});
|
||||
|
||||
// At risk: consistency < 50% or streak broken
|
||||
if (consistencyRate < 50 || habit.current_streak === 0) {
|
||||
atRiskHabits.push(habit.name);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
habits: habitAnalysis.sort((a, b) => b.score - a.score),
|
||||
atRiskHabits,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate time audit report data
|
||||
*/
|
||||
export async function generateTimeAudit(
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
token?: string
|
||||
): Promise<{
|
||||
totalMinutes: number;
|
||||
byDomain: Record<string, number>;
|
||||
byProject: Record<string, number>;
|
||||
byTag: Record<string, number>;
|
||||
}> {
|
||||
const pb = token ? createAdminClient() : createAdminClient();
|
||||
|
||||
const entryResults = await pb.collection('time_entries').getFullList({
|
||||
filter: `started_at >= "${startDate}" && started_at <= "${endDate}"`,
|
||||
});
|
||||
const entries = entryResults.map((r) =>
|
||||
asTimeEntry(r as unknown as Record<string, unknown>)
|
||||
);
|
||||
|
||||
const byDomain: Record<string, number> = {};
|
||||
const byProject: Record<string, number> = {};
|
||||
const byTag: Record<string, number> = {};
|
||||
let totalMinutes = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
totalMinutes += entry.duration_minutes;
|
||||
|
||||
// Get the entity to access domain/project/tags
|
||||
// TimeEntry uses entity_type and entity_id
|
||||
if (entry.entity_type === 'task') {
|
||||
const task = asTask(
|
||||
await pb
|
||||
.collection('tasks')
|
||||
.getOne(entry.entity_id) as unknown as Record<string, unknown>
|
||||
);
|
||||
|
||||
byDomain[task.domain] =
|
||||
(byDomain[task.domain] || 0) + entry.duration_minutes;
|
||||
|
||||
if (task.project_id) {
|
||||
byProject[task.project_id] =
|
||||
(byProject[task.project_id] || 0) + entry.duration_minutes;
|
||||
}
|
||||
|
||||
for (const tag of task.tags || []) {
|
||||
byTag[tag] = (byTag[tag] || 0) + entry.duration_minutes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { totalMinutes, byDomain, byProject, byTag };
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { createPocketBaseClient, createAdminClient } from '../pocketbase';
|
||||
import type { Task, Subtask } from '@project-e/shared';
|
||||
|
||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
||||
function asTask(record: Record<string, unknown>): Task {
|
||||
return record as unknown as Task;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate task progress from subtask completion ratio
|
||||
*/
|
||||
export function calculateTaskProgress(subtasks: Subtask[]): number {
|
||||
if (subtasks.length === 0) return 0;
|
||||
const done = subtasks.filter((s) => s.done).length;
|
||||
return Math.round((done / subtasks.length) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a task is blocked by dependencies
|
||||
* Returns array of blocking task IDs
|
||||
*/
|
||||
export async function getBlockingDependencies(
|
||||
taskId: string,
|
||||
dependencies: string[]
|
||||
): Promise<string[]> {
|
||||
if (dependencies.length === 0) return [];
|
||||
|
||||
const pb = createAdminClient();
|
||||
const blocking: string[] = [];
|
||||
|
||||
for (const depId of dependencies) {
|
||||
const depTask = asTask(
|
||||
await pb.collection('tasks').getOne(depId)
|
||||
);
|
||||
if (depTask.status !== 'done') {
|
||||
blocking.push(depId);
|
||||
}
|
||||
}
|
||||
|
||||
return blocking;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check dependency gating — can this task be started?
|
||||
*/
|
||||
export async function canStartTask(
|
||||
taskId: string,
|
||||
dependencies: string[]
|
||||
): Promise<{ allowed: boolean; blockedBy: string[] }> {
|
||||
const blockedBy = await getBlockingDependencies(taskId, dependencies);
|
||||
return {
|
||||
allowed: blockedBy.length === 0,
|
||||
blockedBy,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle task completion — trigger recurring task spawn if needed
|
||||
*/
|
||||
export async function completeTask(
|
||||
taskId: string,
|
||||
token?: string
|
||||
): Promise<{ task: Task; nextRecurringTaskId?: string }> {
|
||||
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
||||
|
||||
// Mark task as done
|
||||
const task = asTask(
|
||||
await pb.collection('tasks').update(taskId, {
|
||||
status: 'done',
|
||||
completed_at: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
let nextRecurringTaskId: string | undefined;
|
||||
|
||||
// If recurring, spawn next occurrence
|
||||
if (task.recurring_config?.rule) {
|
||||
nextRecurringTaskId = await spawnNextRecurringTask(task);
|
||||
}
|
||||
|
||||
return { task, nextRecurringTaskId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn next recurring task from RRULE
|
||||
*/
|
||||
async function spawnNextRecurringTask(task: Task): Promise<string> {
|
||||
// Import rrule dynamically to avoid bundling issues
|
||||
const { RRule } = await import('rrule');
|
||||
|
||||
const rule = RRule.fromString(task.recurring_config!.rule);
|
||||
const now = new Date();
|
||||
const nextDate = rule.after(now, true);
|
||||
|
||||
if (!nextDate) {
|
||||
throw new Error('No next occurrence found for recurring task');
|
||||
}
|
||||
|
||||
const pb = createAdminClient();
|
||||
|
||||
// Create next occurrence
|
||||
const nextTask = await pb.collection('tasks').create({
|
||||
title: task.title,
|
||||
description: task.description || '',
|
||||
status: 'todo',
|
||||
priority: task.priority,
|
||||
due_date: nextDate.toISOString(),
|
||||
project_id: task.project_id || '',
|
||||
milestone_id: task.milestone_id || '',
|
||||
tags: task.tags || [],
|
||||
domain: task.domain,
|
||||
estimate: task.estimate || null,
|
||||
recurring_config: task.recurring_config,
|
||||
dependencies: task.dependencies || [],
|
||||
custom_fields: task.custom_fields || {},
|
||||
});
|
||||
|
||||
return nextTask.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote a subtask to a full task
|
||||
*/
|
||||
export async function promoteSubtask(
|
||||
parentTaskId: string,
|
||||
subtaskId: string,
|
||||
token?: string
|
||||
): Promise<Task> {
|
||||
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
||||
|
||||
// Get parent task
|
||||
const parentTask = asTask(
|
||||
await pb.collection('tasks').getOne(parentTaskId)
|
||||
);
|
||||
|
||||
// Find the subtask
|
||||
const subtasks = (parentTask.subtasks || []) as Subtask[];
|
||||
const subtask = subtasks.find((s) => s.id === subtaskId);
|
||||
|
||||
if (!subtask) {
|
||||
throw new Error('Subtask not found');
|
||||
}
|
||||
|
||||
// Create new task from subtask
|
||||
const newTask = await pb.collection('tasks').create({
|
||||
title: subtask.title,
|
||||
description: '',
|
||||
status: subtask.done ? 'done' : 'todo',
|
||||
priority: parentTask.priority,
|
||||
project_id: parentTask.project_id || '',
|
||||
domain: parentTask.domain,
|
||||
tags: parentTask.tags || [],
|
||||
});
|
||||
|
||||
// Remove subtask from parent
|
||||
const updatedSubtasks = subtasks.filter((s) => s.id !== subtaskId);
|
||||
await pb.collection('tasks').update(parentTaskId, {
|
||||
subtasks: updatedSubtasks,
|
||||
});
|
||||
|
||||
return asTask(newTask as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-compute parent task progress from subtask completion
|
||||
*/
|
||||
export async function updateTaskProgress(
|
||||
taskId: string,
|
||||
token?: string
|
||||
): Promise<number> {
|
||||
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
||||
const task = asTask(await pb.collection('tasks').getOne(taskId));
|
||||
|
||||
const subtasks = (task.subtasks || []) as Subtask[];
|
||||
return calculateTaskProgress(subtasks);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { createAdminClient } from '../pocketbase';
|
||||
import { eventBus, EVENTS } from '../events/event-bus';
|
||||
import type { Webhook } from '@project-e/shared';
|
||||
|
||||
/**
|
||||
* Initialize webhook service — subscribe to all events
|
||||
* Call this once at app startup
|
||||
*/
|
||||
export function initializeWebhookService(): void {
|
||||
// Subscribe to all domain events
|
||||
eventBus.on(EVENTS.TASK_COMPLETED, (data) => {
|
||||
queueWebhookDelivery('task.completed', data);
|
||||
});
|
||||
|
||||
eventBus.on(EVENTS.HABIT_COMPLETED, (data) => {
|
||||
queueWebhookDelivery('habit.completed', data);
|
||||
});
|
||||
|
||||
eventBus.on(EVENTS.HABIT_STREAK_BROKEN, (data) => {
|
||||
queueWebhookDelivery('habit.streak_broken', data);
|
||||
});
|
||||
|
||||
eventBus.on(EVENTS.MILESTONE_REACHED, (data) => {
|
||||
queueWebhookDelivery('milestone.reached', data);
|
||||
});
|
||||
|
||||
eventBus.on(EVENTS.PROJECT_STATUS_CHANGED, (data) => {
|
||||
queueWebhookDelivery('project.status_changed', data);
|
||||
});
|
||||
|
||||
eventBus.on(EVENTS.REPORT_GENERATED, (data) => {
|
||||
queueWebhookDelivery('report.generated', data);
|
||||
});
|
||||
|
||||
eventBus.on(EVENTS.AGENT_TASK_COMPLETED, (data) => {
|
||||
queueWebhookDelivery('agent_task.completed', data);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a webhook delivery for all matching webhooks
|
||||
*/
|
||||
async function queueWebhookDelivery(eventType: string, payload: unknown): Promise<void> {
|
||||
const pb = createAdminClient();
|
||||
|
||||
try {
|
||||
// Get all active webhooks that subscribe to this event type
|
||||
const webhooks = await pb.collection('webhooks').getFullList({
|
||||
filter: 'active = true',
|
||||
}) as Webhook[];
|
||||
|
||||
const matchingWebhooks = webhooks.filter((webhook) => {
|
||||
const events = webhook.events as string[];
|
||||
return events.includes(eventType) || events.includes('*');
|
||||
});
|
||||
|
||||
// Queue delivery for each matching webhook
|
||||
for (const webhook of matchingWebhooks) {
|
||||
await pb.collection('queue_jobs').create({
|
||||
queue: 'webhooks',
|
||||
type: 'webhook_delivery',
|
||||
payload: {
|
||||
webhook_id: webhook.id,
|
||||
webhook_url: webhook.url,
|
||||
webhook_secret: webhook.secret || '',
|
||||
event_type: eventType,
|
||||
event_payload: payload,
|
||||
},
|
||||
status: 'pending',
|
||||
attempts: 0,
|
||||
max_attempts: webhook.retry_count || 3,
|
||||
scheduled_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to queue webhook delivery:', error);
|
||||
// Log to error_logs collection
|
||||
await pb.collection('error_logs').create({
|
||||
level: 'error',
|
||||
source: 'webhook-service',
|
||||
message: 'Failed to queue webhook delivery',
|
||||
metadata: { eventType, payload, error: String(error) },
|
||||
}).catch(() => {
|
||||
// Ignore logging errors
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver a webhook (called by the worker)
|
||||
*/
|
||||
export async function deliverWebhook(job: {
|
||||
webhook_id: string;
|
||||
webhook_url: string;
|
||||
webhook_secret: string;
|
||||
event_type: string;
|
||||
event_payload: unknown;
|
||||
}): Promise<{ success: boolean; statusCode?: number; responseBody?: string }> {
|
||||
const { webhook_url, webhook_secret, event_type, event_payload } = job;
|
||||
|
||||
try {
|
||||
// Create HMAC signature if secret is provided
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Event-Type': event_type,
|
||||
};
|
||||
|
||||
if (webhook_secret) {
|
||||
const crypto = await import('node:crypto');
|
||||
const payload = JSON.stringify(event_payload);
|
||||
const signature = crypto
|
||||
.createHmac('sha256', webhook_secret)
|
||||
.update(payload)
|
||||
.digest('hex');
|
||||
headers['X-Webhook-Signature'] = signature;
|
||||
}
|
||||
|
||||
const response = await fetch(webhook_url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(event_payload),
|
||||
signal: AbortSignal.timeout(10000), // 10 second timeout
|
||||
});
|
||||
|
||||
const responseBody = await response.text();
|
||||
|
||||
return {
|
||||
success: response.ok,
|
||||
statusCode: response.status,
|
||||
responseBody,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
responseBody: String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record webhook delivery result
|
||||
*/
|
||||
export async function recordWebhookDelivery(
|
||||
webhookId: string,
|
||||
eventType: string,
|
||||
payload: unknown,
|
||||
result: { success: boolean; statusCode?: number; responseBody?: string },
|
||||
attempts: number
|
||||
): Promise<void> {
|
||||
const pb = createAdminClient();
|
||||
|
||||
await pb.collection('webhook_deliveries').create({
|
||||
webhook_id: webhookId,
|
||||
event: eventType,
|
||||
payload: payload as Record<string, unknown>,
|
||||
status: result.success ? 'success' : 'failed',
|
||||
status_code: result.statusCode || 0,
|
||||
response_body: result.responseBody || '',
|
||||
attempts,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user