Signed-off-by: OpenClaw Bot <ai-agent@topdoglabs.com>

This commit is contained in:
OpenClaw Bot 2026-02-21 16:11:23 -06:00
parent ff3cc87dc8
commit 69984f7d86
5 changed files with 277 additions and 65 deletions

View File

@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { randomUUID } from "crypto";
import { getServiceSupabase } from "@/lib/supabase/client";
import { getAuthenticatedUser } from "@/lib/server/auth";
@ -31,6 +32,187 @@ interface Task {
attachments?: unknown[];
}
interface Project {
id: string;
name: string;
description?: string;
color: string;
createdAt: string;
}
interface Sprint {
id: string;
name: string;
goal?: string;
startDate: string;
endDate: string;
status: "planning" | "active" | "completed";
projectId: string;
createdAt: string;
}
interface UserProfile {
id: string;
name: string;
email?: string;
avatarUrl?: string;
}
const TASK_TYPES: Task["type"][] = ["idea", "task", "bug", "research", "plan"];
const TASK_STATUSES: Task["status"][] = ["open", "todo", "blocked", "in-progress", "review", "validate", "archived", "canceled", "done"];
const TASK_PRIORITIES: Task["priority"][] = ["low", "medium", "high", "urgent"];
const SPRINT_STATUSES: Sprint["status"][] = ["planning", "active", "completed"];
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function isTaskType(value: unknown): value is Task["type"] {
return typeof value === "string" && TASK_TYPES.includes(value as Task["type"]);
}
function isTaskStatus(value: unknown): value is Task["status"] {
return typeof value === "string" && TASK_STATUSES.includes(value as Task["status"]);
}
function isTaskPriority(value: unknown): value is Task["priority"] {
return typeof value === "string" && TASK_PRIORITIES.includes(value as Task["priority"]);
}
function isSprintStatus(value: unknown): value is Sprint["status"] {
return typeof value === "string" && SPRINT_STATUSES.includes(value as Sprint["status"]);
}
function toNonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
}
function isUuid(value: string | undefined): value is string {
return typeof value === "string" && UUID_PATTERN.test(value);
}
function stripUndefined<T extends Record<string, unknown>>(value: T): Record<string, unknown> {
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
}
function getMissingColumnFromError(error: unknown): string | null {
if (!error || typeof error !== "object") return null;
const candidate = error as { code?: string; message?: string };
if (candidate.code !== "PGRST204" || typeof candidate.message !== "string") return null;
const match = candidate.message.match(/Could not find the '([^']+)' column/);
return match?.[1] ?? null;
}
function getForeignKeyColumnFromError(error: unknown): string | null {
if (!error || typeof error !== "object") return null;
const candidate = error as { code?: string; details?: string };
if (candidate.code !== "23503" || typeof candidate.details !== "string") return null;
const match = candidate.details.match(/\(([^)]+)\)=/);
return match?.[1] ?? null;
}
async function resolveRequiredProjectId(
supabase: ReturnType<typeof getServiceSupabase>,
requestedProjectId?: string
): Promise<string> {
const requested = toNonEmptyString(requestedProjectId);
if (requested) {
const { data } = await supabase.from("projects").select("id").eq("id", requested).maybeSingle();
if (data?.id) return data.id as string;
}
const { data: firstProject } = await supabase
.from("projects")
.select("id")
.order("created_at", { ascending: true })
.limit(1)
.maybeSingle();
if (firstProject?.id) return firstProject.id as string;
throw new Error("No projects available to assign task");
}
async function resolveOptionalForeignId(
supabase: ReturnType<typeof getServiceSupabase>,
table: "sprints" | "users",
id?: string
): Promise<string | null> {
const requested = toNonEmptyString(id);
if (!requested) return null;
const { data } = await supabase.from(table).select("id").eq("id", requested).maybeSingle();
return data?.id ? (data.id as string) : null;
}
function mapProjectRow(row: Record<string, unknown>): Project {
return {
id: String(row.id ?? ""),
name: toNonEmptyString(row.name) ?? "Untitled Project",
description: toNonEmptyString(row.description),
color: toNonEmptyString(row.color) ?? "#3b82f6",
createdAt: toNonEmptyString(row.created_at) ?? new Date().toISOString(),
};
}
function mapSprintRow(row: Record<string, unknown>): Sprint {
const fallbackDate = new Date().toISOString();
return {
id: String(row.id ?? ""),
name: toNonEmptyString(row.name) ?? "Untitled Sprint",
goal: toNonEmptyString(row.goal),
startDate: toNonEmptyString(row.start_date) ?? fallbackDate,
endDate: toNonEmptyString(row.end_date) ?? fallbackDate,
status: isSprintStatus(row.status) ? row.status : "planning",
projectId: String(row.project_id ?? ""),
createdAt: toNonEmptyString(row.created_at) ?? fallbackDate,
};
}
function mapUserRow(row: Record<string, unknown>): UserProfile | null {
const id = toNonEmptyString(row.id);
const name = toNonEmptyString(row.name);
if (!id || !name) return null;
return {
id,
name,
email: toNonEmptyString(row.email),
avatarUrl: toNonEmptyString(row.avatar_url),
};
}
function mapTaskRow(row: Record<string, unknown>, usersById: Map<string, UserProfile>): Task {
const fallbackDate = new Date().toISOString();
const createdById = toNonEmptyString(row.created_by_id);
const updatedById = toNonEmptyString(row.updated_by_id);
const assigneeId = toNonEmptyString(row.assignee_id);
const createdByUser = createdById ? usersById.get(createdById) : undefined;
const updatedByUser = updatedById ? usersById.get(updatedById) : undefined;
const assigneeUser = assigneeId ? usersById.get(assigneeId) : undefined;
return {
id: String(row.id ?? ""),
title: toNonEmptyString(row.title) ?? "",
description: toNonEmptyString(row.description),
type: isTaskType(row.type) ? row.type : "task",
status: isTaskStatus(row.status) ? row.status : "todo",
priority: isTaskPriority(row.priority) ? row.priority : "medium",
projectId: String(row.project_id ?? ""),
sprintId: toNonEmptyString(row.sprint_id),
createdAt: toNonEmptyString(row.created_at) ?? fallbackDate,
updatedAt: toNonEmptyString(row.updated_at) ?? fallbackDate,
createdById,
createdByName: toNonEmptyString(row.created_by_name) ?? createdByUser?.name,
createdByAvatarUrl: toNonEmptyString(row.created_by_avatar_url) ?? createdByUser?.avatarUrl,
updatedById,
updatedByName: toNonEmptyString(row.updated_by_name) ?? updatedByUser?.name,
updatedByAvatarUrl: toNonEmptyString(row.updated_by_avatar_url) ?? updatedByUser?.avatarUrl,
assigneeId,
assigneeName: toNonEmptyString(row.assignee_name) ?? assigneeUser?.name,
assigneeEmail: toNonEmptyString(row.assignee_email) ?? assigneeUser?.email,
assigneeAvatarUrl: toNonEmptyString(row.assignee_avatar_url) ?? assigneeUser?.avatarUrl,
dueDate: toNonEmptyString(row.due_date),
comments: Array.isArray(row.comments) ? row.comments : [],
tags: Array.isArray(row.tags) ? row.tags.filter((tag): tag is string => typeof tag === "string") : [],
attachments: Array.isArray(row.attachments) ? row.attachments : [],
};
}
// GET - fetch all tasks, projects, and sprints
export async function GET() {
try {
@ -41,17 +223,24 @@ export async function GET() {
const supabase = getServiceSupabase();
const [{ data: projects }, { data: sprints }, { data: tasks }, { data: meta }] = await Promise.all([
const [{ data: projects }, { data: sprints }, { data: tasks }, { data: users }, { data: meta }] = await Promise.all([
supabase.from("projects").select("*").order("created_at", { ascending: true }),
supabase.from("sprints").select("*").order("start_date", { ascending: true }),
supabase.from("tasks").select("*").order("created_at", { ascending: true }),
supabase.from("users").select("id, name, email, avatar_url"),
supabase.from("meta").select("value").eq("key", "lastUpdated").maybeSingle(),
]);
const usersById = new Map<string, UserProfile>();
for (const row of users || []) {
const mapped = mapUserRow(row as Record<string, unknown>);
if (mapped) usersById.set(mapped.id, mapped);
}
return NextResponse.json({
projects: projects || [],
sprints: sprints || [],
tasks: tasks || [],
projects: (projects || []).map((row) => mapProjectRow(row as Record<string, unknown>)),
sprints: (sprints || []).map((row) => mapSprintRow(row as Record<string, unknown>)),
tasks: (tasks || []).map((row) => mapTaskRow(row as Record<string, unknown>, usersById)),
lastUpdated: Number(meta?.value ?? Date.now()),
});
} catch (error) {
@ -79,63 +268,74 @@ export async function POST(request: Request) {
const supabase = getServiceSupabase();
const now = new Date().toISOString();
const clientTaskId = toNonEmptyString(task.id);
const canonicalTaskId = isUuid(clientTaskId) ? clientTaskId : randomUUID();
const resolvedProjectId = await resolveRequiredProjectId(supabase, task.projectId);
const resolvedSprintId = await resolveOptionalForeignId(supabase, "sprints", task.sprintId);
const resolvedAssigneeId = await resolveOptionalForeignId(supabase, "users", task.assigneeId);
// Check if task exists
const { data: existing } = await supabase
.from("tasks")
.select("id")
.eq("id", task.id)
.eq("id", canonicalTaskId)
.maybeSingle();
const taskData = {
id: task.id,
let taskData = stripUndefined({
id: canonicalTaskId,
legacy_id: clientTaskId && !isUuid(clientTaskId) ? clientTaskId : null,
title: task.title,
description: task.description || null,
type: task.type || "task",
status: task.status || "todo",
priority: task.priority || "medium",
project_id: task.projectId,
sprint_id: task.sprintId || null,
project_id: resolvedProjectId,
sprint_id: resolvedSprintId,
created_at: existing ? undefined : (task.createdAt || now),
updated_at: now,
created_by_id: existing ? undefined : (task.createdById || user.id),
created_by_name: existing ? undefined : (task.createdByName || user.name),
created_by_avatar_url: existing ? undefined : task.createdByAvatarUrl,
created_by_id: existing ? undefined : user.id,
updated_by_id: user.id,
updated_by_name: user.name,
updated_by_avatar_url: user.avatarUrl,
assignee_id: task.assigneeId || null,
assignee_name: task.assigneeName || null,
assignee_email: task.assigneeEmail || null,
assignee_avatar_url: task.assigneeAvatarUrl || null,
assignee_id: resolvedAssigneeId,
due_date: task.dueDate || null,
comments: task.comments || [],
tags: task.tags || [],
attachments: task.attachments || [],
};
});
let result;
if (existing) {
// Update existing
const { data, error } = await supabase
.from("tasks")
.update(taskData)
.eq("id", task.id)
.select()
.single();
if (error) throw error;
result = data;
} else {
// Insert new
const { data, error } = await supabase
.from("tasks")
.insert(taskData)
.select()
.single();
if (error) throw error;
result = data;
for (let attempt = 0; attempt < 8; attempt += 1) {
const query = existing
? supabase.from("tasks").update(taskData).eq("id", canonicalTaskId).select().single()
: supabase.from("tasks").insert(taskData).select().single();
const { data, error } = await query;
if (!error) {
result = data;
break;
}
const missingColumn = getMissingColumnFromError(error);
if (missingColumn && missingColumn in taskData) {
const nextPayload = { ...taskData };
delete nextPayload[missingColumn];
taskData = nextPayload;
console.warn(`>>> API POST: removing unsupported tasks column '${missingColumn}' and retrying`);
continue;
}
const foreignKeyColumn = getForeignKeyColumnFromError(error);
if (foreignKeyColumn && foreignKeyColumn in taskData) {
const nextPayload = { ...taskData, [foreignKeyColumn]: null };
taskData = nextPayload;
console.warn(`>>> API POST: clearing invalid foreign key '${foreignKeyColumn}' and retrying`);
continue;
}
throw error;
}
if (!result) throw new Error("Failed to save task after schema fallback attempts");
// Update lastUpdated
await supabase.from("meta").upsert({
key: "lastUpdated",
@ -147,7 +347,15 @@ export async function POST(request: Request) {
} catch (error: unknown) {
console.error(">>> API POST error:", error);
const message = error instanceof Error ? error.message : "Failed to save";
return NextResponse.json({ error: message }, { status: 500 });
const details =
error && typeof error === "object"
? {
code: "code" in error ? (error as { code?: unknown }).code : undefined,
details: "details" in error ? (error as { details?: unknown }).details : undefined,
hint: "hint" in error ? (error as { hint?: unknown }).hint : undefined,
}
: undefined;
return NextResponse.json({ error: message, details }, { status: 500 });
}
}

View File

@ -42,6 +42,14 @@ const typeLabels: Record<string, string> = {
plan: "📐",
}
function formatSprintDateRange(startDate?: string, endDate?: string): string {
if (!startDate || !endDate) return "No dates"
const start = parseISO(startDate)
const end = parseISO(endDate)
if (!isValid(start) || !isValid(end)) return "Invalid dates"
return `${format(start, "MMM d")} - ${format(end, "MMM d")}`
}
interface AssignableUser {
id: string
name: string
@ -380,12 +388,7 @@ export function BacklogView() {
currentSprint
? {
name: currentSprint.name,
date: `${(() => {
const start = parseISO(currentSprint.startDate)
const end = parseISO(currentSprint.endDate)
if (!isValid(start) || !isValid(end)) return "Invalid dates"
return `${format(start, "MMM d")} - ${format(end, "MMM d")}`
})()}`,
date: formatSprintDateRange(currentSprint.startDate, currentSprint.endDate),
status: currentSprint.status,
}
: undefined
@ -410,12 +413,7 @@ export function BacklogView() {
resolveAssigneeAvatar={resolveAssigneeAvatar}
sprintInfo={{
name: sprint.name,
date: (() => {
const start = parseISO(sprint.startDate)
const end = parseISO(sprint.endDate)
if (!isValid(start) || !isValid(end)) return "Invalid dates"
return `${format(start, "MMM d")} - ${format(end, "MMM d")}`
})(),
date: formatSprintDateRange(sprint.startDate, sprint.endDate),
status: sprint.status,
}}
/>

View File

@ -22,7 +22,7 @@ import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Plus, Calendar, Flag, GripVertical } from "lucide-react"
import { format, parseISO } from "date-fns"
import { format, isValid, parseISO } from "date-fns"
const statusColumns = ["backlog", "in-progress", "review", "done"] as const
type SprintColumnStatus = typeof statusColumns[number]
@ -48,6 +48,14 @@ const priorityColors: Record<string, string> = {
urgent: "bg-red-600",
}
function formatSprintDateRange(startDate?: string, endDate?: string): string {
if (!startDate || !endDate) return "No dates"
const start = parseISO(startDate)
const end = parseISO(endDate)
if (!isValid(start) || !isValid(end)) return "Invalid dates"
return `${format(start, "MMM d")} - ${format(end, "MMM d, yyyy")}`
}
// Sortable Task Card Component
function SortableTaskCard({
task,
@ -347,10 +355,7 @@ export function SprintBoard() {
<div className="flex items-center gap-4 text-sm text-slate-400">
<div className="flex items-center gap-1">
<Calendar className="w-4 h-4" />
<span>
{format(parseISO(currentSprint.startDate), "MMM d")} -{" "}
{format(parseISO(currentSprint.endDate), "MMM d, yyyy")}
</span>
<span>{formatSprintDateRange(currentSprint.startDate, currentSprint.endDate)}</span>
</div>
<Badge
variant={

View File

@ -389,15 +389,8 @@ export async function saveData(data: DataStore): Promise<DataStore> {
created_at: t.createdAt,
updated_at: now,
created_by_id: t.createdById,
created_by_name: t.createdByName,
created_by_avatar_url: t.createdByAvatarUrl,
updated_by_id: t.updatedById,
updated_by_name: t.updatedByName,
updated_by_avatar_url: t.updatedByAvatarUrl,
assignee_id: t.assigneeId,
assignee_name: t.assigneeName,
assignee_email: t.assigneeEmail,
assignee_avatar_url: t.assigneeAvatarUrl,
due_date: t.dueDate,
comments: t.comments,
tags: t.tags,

View File

@ -552,6 +552,13 @@ const removeCommentFromThread = (comments: Comment[], targetId: string): Comment
replies: removeCommentFromThread(normalizeComments(comment.replies), targetId),
}))
const generateTaskId = (): string => {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID()
}
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
// Helper to sync a single task to server (lightweight)
async function syncTaskToServer(task: Task) {
console.log('>>> syncTaskToServer: saving task', task.id, task.title)
@ -565,7 +572,8 @@ async function syncTaskToServer(task: Task) {
console.log('>>> syncTaskToServer: saved successfully')
return true
} else {
console.error('>>> syncTaskToServer: failed with status', res.status)
const errorPayload = await res.json().catch(() => null)
console.error('>>> syncTaskToServer: failed with status', res.status, errorPayload)
return false
}
} catch (error) {
@ -721,7 +729,7 @@ export const useTaskStore = create<TaskStore>()(
const actor = profileToCommentAuthor(get().currentUser)
const newTask: Task = {
...task,
id: Date.now().toString(),
id: generateTaskId(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
createdById: actor.id,