diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index 66f7617..1a39117 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -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>(value: T): Record { + 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, + requestedProjectId?: string +): Promise { + 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, + table: "sprints" | "users", + id?: string +): Promise { + 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): 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): 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): 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, usersById: Map): 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(); + for (const row of users || []) { + const mapped = mapUserRow(row as Record); + if (mapped) usersById.set(mapped.id, mapped); + } return NextResponse.json({ - projects: projects || [], - sprints: sprints || [], - tasks: tasks || [], + projects: (projects || []).map((row) => mapProjectRow(row as Record)), + sprints: (sprints || []).map((row) => mapSprintRow(row as Record)), + tasks: (tasks || []).map((row) => mapTaskRow(row as Record, 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 }); } } diff --git a/src/components/BacklogView.tsx b/src/components/BacklogView.tsx index 4b7242a..5f633c5 100644 --- a/src/components/BacklogView.tsx +++ b/src/components/BacklogView.tsx @@ -42,6 +42,14 @@ const typeLabels: Record = { 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, }} /> diff --git a/src/components/SprintBoard.tsx b/src/components/SprintBoard.tsx index d1845d0..f2ff412 100644 --- a/src/components/SprintBoard.tsx +++ b/src/components/SprintBoard.tsx @@ -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 = { 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() {
- - {format(parseISO(currentSprint.startDate), "MMM d")} -{" "} - {format(parseISO(currentSprint.endDate), "MMM d, yyyy")} - + {formatSprintDateRange(currentSprint.startDate, currentSprint.endDate)}
{ 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, diff --git a/src/stores/useTaskStore.ts b/src/stores/useTaskStore.ts index 0c464cf..5e201dd 100644 --- a/src/stores/useTaskStore.ts +++ b/src/stores/useTaskStore.ts @@ -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()( 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,