mission-control/lib/server/taskDb.ts
OpenClaw Bot c1c01bd21e feat: merge Gantt Board into Mission Control
- Add Projects page with Sprint Board and Backlog views
- Copy SprintBoard and BacklogView components to components/gantt/
- Copy useTaskStore for project/task/sprint management
- Add API routes for task persistence with SQLite
- Add UI components: dialog, select, table, textarea
- Add avatar and attachment utilities
- Update sidebar with Projects navigation link
- Remove static export config to support API routes
- Add dist to .gitignore
2026-02-20 18:49:52 -06:00

572 lines
20 KiB
TypeScript

import Database from "better-sqlite3";
import { mkdirSync } from "fs";
import { join } from "path";
export interface TaskAttachment {
id: string;
name: string;
type: string;
size: number;
dataUrl: string;
uploadedAt: string;
}
export interface TaskComment {
id: string;
text: string;
createdAt: string;
author: TaskCommentAuthor | "user" | "assistant";
replies?: TaskComment[];
}
export interface TaskCommentAuthor {
id: string;
name: string;
email?: string;
avatarUrl?: string;
type: "human" | "assistant";
}
export interface Task {
id: string;
title: string;
description?: string;
type: "idea" | "task" | "bug" | "research" | "plan";
status: "open" | "todo" | "blocked" | "in-progress" | "review" | "validate" | "archived" | "canceled" | "done";
priority: "low" | "medium" | "high" | "urgent";
projectId: string;
sprintId?: string;
createdAt: string;
updatedAt: string;
createdById?: string;
createdByName?: string;
createdByAvatarUrl?: string;
updatedById?: string;
updatedByName?: string;
updatedByAvatarUrl?: string;
assigneeId?: string;
assigneeName?: string;
assigneeEmail?: string;
assigneeAvatarUrl?: string;
dueDate?: string;
comments: TaskComment[];
tags: string[];
attachments: TaskAttachment[];
}
export interface Project {
id: string;
name: string;
description?: string;
color: string;
createdAt: string;
}
export interface Sprint {
id: string;
name: string;
goal?: string;
startDate: string;
endDate: string;
status: "planning" | "active" | "completed";
projectId: string;
createdAt: string;
}
export interface DataStore {
projects: Project[];
tasks: Task[];
sprints: Sprint[];
lastUpdated: number;
}
const DATA_DIR = join(process.cwd(), "data");
const DB_FILE = join(DATA_DIR, "tasks.db");
const defaultData: DataStore = {
projects: [
{ id: "1", name: "OpenClaw iOS", description: "Main iOS app development", color: "#8b5cf6", createdAt: new Date().toISOString() },
{ id: "2", name: "Web Projects", description: "Web tools and dashboards", color: "#3b82f6", createdAt: new Date().toISOString() },
{ id: "3", name: "Research", description: "Experiments and learning", color: "#10b981", createdAt: new Date().toISOString() },
],
tasks: [],
sprints: [],
lastUpdated: Date.now(),
};
type SqliteDb = InstanceType<typeof Database>;
let db: SqliteDb | null = null;
interface UserProfileLookup {
id: string;
name: string;
email?: string;
avatarUrl?: string;
}
function ensureTaskSchema(database: SqliteDb) {
const taskColumns = database.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
if (!taskColumns.some((column) => column.name === "attachments")) {
database.exec("ALTER TABLE tasks ADD COLUMN attachments TEXT NOT NULL DEFAULT '[]';");
}
if (!taskColumns.some((column) => column.name === "createdById")) {
database.exec("ALTER TABLE tasks ADD COLUMN createdById TEXT;");
}
if (!taskColumns.some((column) => column.name === "createdByName")) {
database.exec("ALTER TABLE tasks ADD COLUMN createdByName TEXT;");
}
if (!taskColumns.some((column) => column.name === "createdByAvatarUrl")) {
database.exec("ALTER TABLE tasks ADD COLUMN createdByAvatarUrl TEXT;");
}
if (!taskColumns.some((column) => column.name === "updatedById")) {
database.exec("ALTER TABLE tasks ADD COLUMN updatedById TEXT;");
}
if (!taskColumns.some((column) => column.name === "updatedByName")) {
database.exec("ALTER TABLE tasks ADD COLUMN updatedByName TEXT;");
}
if (!taskColumns.some((column) => column.name === "updatedByAvatarUrl")) {
database.exec("ALTER TABLE tasks ADD COLUMN updatedByAvatarUrl TEXT;");
}
if (!taskColumns.some((column) => column.name === "assigneeId")) {
database.exec("ALTER TABLE tasks ADD COLUMN assigneeId TEXT;");
}
if (!taskColumns.some((column) => column.name === "assigneeName")) {
database.exec("ALTER TABLE tasks ADD COLUMN assigneeName TEXT;");
}
if (!taskColumns.some((column) => column.name === "assigneeEmail")) {
database.exec("ALTER TABLE tasks ADD COLUMN assigneeEmail TEXT;");
}
if (!taskColumns.some((column) => column.name === "assigneeAvatarUrl")) {
database.exec("ALTER TABLE tasks ADD COLUMN assigneeAvatarUrl TEXT;");
}
}
function safeParseArray<T>(value: string | null, fallback: T[]): T[] {
if (!value) return fallback;
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? (parsed as T[]) : fallback;
} catch {
return fallback;
}
}
function getUserLookup(database: SqliteDb): Map<string, UserProfileLookup> {
const hasUsersTable = database
.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'users' LIMIT 1")
.get() as { 1: number } | undefined;
if (!hasUsersTable) return new Map();
try {
const rows = database
.prepare("SELECT id, name, email, avatarUrl FROM users")
.all() as Array<{ id: string; name: string; email: string | null; avatarUrl: string | null }>;
const lookup = new Map<string, UserProfileLookup>();
for (const row of rows) {
lookup.set(row.id, {
id: row.id,
name: row.name,
email: row.email ?? undefined,
avatarUrl: row.avatarUrl ?? undefined,
});
}
return lookup;
} catch {
return new Map();
}
}
function normalizeAttachments(attachments: unknown): TaskAttachment[] {
if (!Array.isArray(attachments)) return [];
return attachments
.map((attachment) => {
if (!attachment || typeof attachment !== "object") return null;
const value = attachment as Partial<TaskAttachment>;
const name = typeof value.name === "string" ? value.name.trim() : "";
const dataUrl = typeof value.dataUrl === "string" ? value.dataUrl : "";
if (!name || !dataUrl) return null;
return {
id: typeof value.id === "string" && value.id ? value.id : `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name,
type: typeof value.type === "string" ? value.type : "application/octet-stream",
size: typeof value.size === "number" && Number.isFinite(value.size) ? value.size : 0,
dataUrl,
uploadedAt: typeof value.uploadedAt === "string" && value.uploadedAt ? value.uploadedAt : new Date().toISOString(),
};
})
.filter((attachment): attachment is TaskAttachment => attachment !== null);
}
function normalizeComments(comments: unknown): TaskComment[] {
if (!Array.isArray(comments)) return [];
const normalized: TaskComment[] = [];
for (const entry of comments) {
if (!entry || typeof entry !== "object") continue;
const value = entry as Partial<TaskComment>;
if (typeof value.id !== "string" || typeof value.text !== "string") continue;
normalized.push({
id: value.id,
text: value.text,
createdAt: typeof value.createdAt === "string" ? value.createdAt : new Date().toISOString(),
author: normalizeCommentAuthor(value.author),
replies: normalizeComments(value.replies),
});
}
return normalized;
}
function normalizeCommentAuthor(author: unknown): TaskCommentAuthor {
if (author === "assistant") {
return { id: "assistant", name: "Assistant", type: "assistant" };
}
if (author === "user") {
return { id: "legacy-user", name: "User", type: "human" };
}
if (!author || typeof author !== "object") {
return { id: "legacy-user", name: "User", type: "human" };
}
const value = author as Partial<TaskCommentAuthor>;
const type: TaskCommentAuthor["type"] =
value.type === "assistant" || value.id === "assistant" ? "assistant" : "human";
const id = typeof value.id === "string" && value.id.trim().length > 0
? value.id
: type === "assistant"
? "assistant"
: "legacy-user";
const name = typeof value.name === "string" && value.name.trim().length > 0
? value.name.trim()
: type === "assistant"
? "Assistant"
: "User";
const email = typeof value.email === "string" && value.email.trim().length > 0 ? value.email.trim() : undefined;
const avatarUrl = typeof value.avatarUrl === "string" && value.avatarUrl.trim().length > 0 ? value.avatarUrl : undefined;
return { id, name, email, avatarUrl, type };
}
function normalizeTask(task: Partial<Task>): Task {
return {
id: String(task.id ?? Date.now()),
title: String(task.title ?? ""),
description: task.description || undefined,
type: (task.type as Task["type"]) ?? "task",
status: (task.status as Task["status"]) ?? "open",
priority: (task.priority as Task["priority"]) ?? "medium",
projectId: String(task.projectId ?? "2"),
sprintId: task.sprintId || undefined,
createdAt: task.createdAt || new Date().toISOString(),
updatedAt: task.updatedAt || new Date().toISOString(),
createdById: typeof task.createdById === "string" && task.createdById.trim().length > 0 ? task.createdById : undefined,
createdByName: typeof task.createdByName === "string" && task.createdByName.trim().length > 0 ? task.createdByName : undefined,
createdByAvatarUrl: typeof task.createdByAvatarUrl === "string" && task.createdByAvatarUrl.trim().length > 0 ? task.createdByAvatarUrl : undefined,
updatedById: typeof task.updatedById === "string" && task.updatedById.trim().length > 0 ? task.updatedById : undefined,
updatedByName: typeof task.updatedByName === "string" && task.updatedByName.trim().length > 0 ? task.updatedByName : undefined,
updatedByAvatarUrl: typeof task.updatedByAvatarUrl === "string" && task.updatedByAvatarUrl.trim().length > 0 ? task.updatedByAvatarUrl : undefined,
assigneeId: typeof task.assigneeId === "string" && task.assigneeId.trim().length > 0 ? task.assigneeId : undefined,
assigneeName: typeof task.assigneeName === "string" && task.assigneeName.trim().length > 0 ? task.assigneeName : undefined,
assigneeEmail: typeof task.assigneeEmail === "string" && task.assigneeEmail.trim().length > 0 ? task.assigneeEmail : undefined,
assigneeAvatarUrl: typeof task.assigneeAvatarUrl === "string" && task.assigneeAvatarUrl.trim().length > 0 ? task.assigneeAvatarUrl : undefined,
dueDate: task.dueDate || undefined,
comments: normalizeComments(task.comments),
tags: Array.isArray(task.tags) ? task.tags.filter((tag): tag is string => typeof tag === "string") : [],
attachments: normalizeAttachments(task.attachments),
};
}
function setLastUpdated(database: SqliteDb, value: number) {
database
.prepare(`
INSERT INTO meta (key, value)
VALUES ('lastUpdated', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value
`)
.run(String(value));
}
function getLastUpdated(database: SqliteDb): number {
const row = database.prepare("SELECT value FROM meta WHERE key = 'lastUpdated'").get() as { value?: string } | undefined;
const parsed = Number(row?.value ?? Date.now());
return Number.isFinite(parsed) ? parsed : Date.now();
}
function replaceAllData(database: SqliteDb, data: DataStore) {
const write = database.transaction((payload: DataStore) => {
database.exec("DELETE FROM projects;");
database.exec("DELETE FROM sprints;");
database.exec("DELETE FROM tasks;");
const insertProject = database.prepare(`
INSERT INTO projects (id, name, description, color, createdAt)
VALUES (@id, @name, @description, @color, @createdAt)
`);
const insertSprint = database.prepare(`
INSERT INTO sprints (id, name, goal, startDate, endDate, status, projectId, createdAt)
VALUES (@id, @name, @goal, @startDate, @endDate, @status, @projectId, @createdAt)
`);
const insertTask = database.prepare(`
INSERT INTO tasks (id, title, description, type, status, priority, projectId, sprintId, createdAt, updatedAt, createdById, createdByName, createdByAvatarUrl, updatedById, updatedByName, updatedByAvatarUrl, assigneeId, assigneeName, assigneeEmail, assigneeAvatarUrl, dueDate, comments, tags, attachments)
VALUES (@id, @title, @description, @type, @status, @priority, @projectId, @sprintId, @createdAt, @updatedAt, @createdById, @createdByName, @createdByAvatarUrl, @updatedById, @updatedByName, @updatedByAvatarUrl, @assigneeId, @assigneeName, @assigneeEmail, @assigneeAvatarUrl, @dueDate, @comments, @tags, @attachments)
`);
for (const project of payload.projects) {
insertProject.run({
id: project.id,
name: project.name,
description: project.description ?? null,
color: project.color,
createdAt: project.createdAt,
});
}
for (const sprint of payload.sprints) {
insertSprint.run({
id: sprint.id,
name: sprint.name,
goal: sprint.goal ?? null,
startDate: sprint.startDate,
endDate: sprint.endDate,
status: sprint.status,
projectId: sprint.projectId,
createdAt: sprint.createdAt,
});
}
for (const task of payload.tasks.map(normalizeTask)) {
insertTask.run({
...task,
sprintId: task.sprintId ?? null,
createdById: task.createdById ?? null,
createdByName: task.createdByName ?? null,
createdByAvatarUrl: task.createdByAvatarUrl ?? null,
updatedById: task.updatedById ?? null,
updatedByName: task.updatedByName ?? null,
updatedByAvatarUrl: task.updatedByAvatarUrl ?? null,
assigneeId: task.assigneeId ?? null,
assigneeName: task.assigneeName ?? null,
assigneeEmail: task.assigneeEmail ?? null,
assigneeAvatarUrl: task.assigneeAvatarUrl ?? null,
dueDate: task.dueDate ?? null,
comments: JSON.stringify(task.comments ?? []),
tags: JSON.stringify(task.tags ?? []),
attachments: JSON.stringify(task.attachments ?? []),
});
}
setLastUpdated(database, payload.lastUpdated || Date.now());
});
write(data);
}
function seedIfEmpty(database: SqliteDb) {
const counts = database
.prepare(
`
SELECT
(SELECT COUNT(*) FROM projects) AS projectsCount,
(SELECT COUNT(*) FROM sprints) AS sprintsCount,
(SELECT COUNT(*) FROM tasks) AS tasksCount
`
)
.get() as { projectsCount: number; sprintsCount: number; tasksCount: number };
if (counts.projectsCount > 0 || counts.sprintsCount > 0 || counts.tasksCount > 0) return;
replaceAllData(database, defaultData);
}
function getDb(): SqliteDb {
if (db) {
ensureTaskSchema(db);
return db;
}
mkdirSync(DATA_DIR, { recursive: true });
const database = new Database(DB_FILE);
database.pragma("journal_mode = WAL");
database.exec(`
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
color TEXT NOT NULL,
createdAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sprints (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
goal TEXT,
startDate TEXT NOT NULL,
endDate TEXT NOT NULL,
status TEXT NOT NULL,
projectId TEXT NOT NULL,
createdAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
type TEXT NOT NULL,
status TEXT NOT NULL,
priority TEXT NOT NULL,
projectId TEXT NOT NULL,
sprintId TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
createdById TEXT,
createdByName TEXT,
createdByAvatarUrl TEXT,
updatedById TEXT,
updatedByName TEXT,
updatedByAvatarUrl TEXT,
assigneeId TEXT,
assigneeName TEXT,
assigneeEmail TEXT,
assigneeAvatarUrl TEXT,
dueDate TEXT,
comments TEXT NOT NULL DEFAULT '[]',
tags TEXT NOT NULL DEFAULT '[]',
attachments TEXT NOT NULL DEFAULT '[]'
);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`);
ensureTaskSchema(database);
seedIfEmpty(database);
db = database;
return database;
}
export function getData(): DataStore {
const database = getDb();
const usersById = getUserLookup(database);
const projects = database.prepare("SELECT * FROM projects ORDER BY createdAt ASC").all() as Array<{
id: string;
name: string;
description: string | null;
color: string;
createdAt: string;
}>;
const sprints = database.prepare("SELECT * FROM sprints ORDER BY startDate ASC").all() as Array<{
id: string;
name: string;
goal: string | null;
startDate: string;
endDate: string;
status: Sprint["status"];
projectId: string;
createdAt: string;
}>;
const tasks = database.prepare("SELECT * FROM tasks ORDER BY createdAt ASC").all() as Array<{
id: string;
title: string;
description: string | null;
type: Task["type"];
status: Task["status"];
priority: Task["priority"];
projectId: string;
sprintId: string | null;
createdAt: string;
updatedAt: string;
createdById: string | null;
createdByName: string | null;
createdByAvatarUrl: string | null;
updatedById: string | null;
updatedByName: string | null;
updatedByAvatarUrl: string | null;
assigneeId: string | null;
assigneeName: string | null;
assigneeEmail: string | null;
assigneeAvatarUrl: string | null;
dueDate: string | null;
comments: string | null;
tags: string | null;
attachments: string | null;
}>;
return {
projects: projects.map((project) => ({
id: project.id,
name: project.name,
description: project.description ?? undefined,
color: project.color,
createdAt: project.createdAt,
})),
sprints: sprints.map((sprint) => ({
id: sprint.id,
name: sprint.name,
goal: sprint.goal ?? undefined,
startDate: sprint.startDate,
endDate: sprint.endDate,
status: sprint.status,
projectId: sprint.projectId,
createdAt: sprint.createdAt,
})),
tasks: tasks.map((task) => {
const createdByUser = task.createdById ? usersById.get(task.createdById) : undefined;
const updatedByUser = task.updatedById ? usersById.get(task.updatedById) : undefined;
const assigneeUser = task.assigneeId ? usersById.get(task.assigneeId) : undefined;
return {
id: task.id,
title: task.title,
description: task.description ?? undefined,
type: task.type,
status: task.status,
priority: task.priority,
projectId: task.projectId,
sprintId: task.sprintId ?? undefined,
createdAt: task.createdAt,
updatedAt: task.updatedAt,
createdById: task.createdById ?? undefined,
createdByName: task.createdByName ?? createdByUser?.name ?? undefined,
createdByAvatarUrl: createdByUser?.avatarUrl ?? task.createdByAvatarUrl ?? undefined,
updatedById: task.updatedById ?? undefined,
updatedByName: task.updatedByName ?? updatedByUser?.name ?? undefined,
updatedByAvatarUrl: updatedByUser?.avatarUrl ?? task.updatedByAvatarUrl ?? undefined,
assigneeId: task.assigneeId ?? undefined,
assigneeName: assigneeUser?.name ?? task.assigneeName ?? undefined,
assigneeEmail: assigneeUser?.email ?? task.assigneeEmail ?? undefined,
assigneeAvatarUrl: assigneeUser?.avatarUrl ?? undefined,
dueDate: task.dueDate ?? undefined,
comments: normalizeComments(safeParseArray(task.comments, [])),
tags: safeParseArray(task.tags, []),
attachments: normalizeAttachments(safeParseArray(task.attachments, [])),
};
}),
lastUpdated: getLastUpdated(database),
};
}
export function saveData(data: DataStore): DataStore {
const database = getDb();
const payload: DataStore = {
...data,
projects: data.projects ?? [],
sprints: data.sprints ?? [],
tasks: (data.tasks ?? []).map(normalizeTask),
lastUpdated: Date.now(),
};
replaceAllData(database, payload);
return getData();
}