Signed-off-by: OpenClaw Bot <ai-agent@topdoglabs.com>
This commit is contained in:
parent
ff3cc87dc8
commit
69984f7d86
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -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={
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user