import { NextResponse } from "next/server"; import { getServiceSupabase } from "@/lib/supabase/client"; import { getAuthenticatedUser } from "@/lib/server/auth"; export const runtime = "nodejs"; 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?: unknown[]; tags?: string[]; 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}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; // Field sets are split so board loads can avoid heavy attachment payloads. const TASK_BASE_FIELDS = [ "id", "title", "type", "status", "priority", "project_id", "sprint_id", "created_at", "updated_at", "created_by_id", "updated_by_id", "assignee_id", "due_date", "tags", "comments", "description", ]; const TASK_DETAIL_ONLY_FIELDS = ["attachments"]; type TaskQueryScope = "all" | "active-sprint"; function parseSprintStart(value: string): Date { const match = value.match(/^(\d{4})-(\d{2})-(\d{2})/); if (match) { return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]), 0, 0, 0, 0); } return new Date(value); } function parseSprintEnd(value: string): Date { const match = value.match(/^(\d{4})-(\d{2})-(\d{2})/); if (match) { return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]), 23, 59, 59, 999); } const parsed = new Date(value); parsed.setHours(23, 59, 59, 999); return parsed; } function isSprintInProgress(startDate: string, endDate: string, now: Date = new Date()): boolean { const sprintStart = parseSprintStart(startDate); const sprintEnd = parseSprintEnd(endDate); return sprintStart <= now && sprintEnd >= now; } function parseTaskQueryScope(raw: string | null): TaskQueryScope { return raw === "active-sprint" ? "active-sprint" : "all"; } class HttpError extends Error { readonly status: number; readonly details?: Record; constructor(status: number, message: string, details?: Record) { super(message); this.status = status; this.details = details; } } function toErrorDetails(error: unknown): Record | undefined { if (!error || typeof error !== "object") return undefined; const candidate = error as { code?: unknown; details?: unknown; hint?: unknown; message?: unknown }; return { code: candidate.code, details: candidate.details, hint: candidate.hint, message: candidate.message, }; } function throwQueryError(scope: string, error: unknown): never { throw new HttpError(500, `${scope} query failed`, { scope, ...toErrorDetails(error), }); } 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 requireNonEmptyString(value: unknown, field: string, status = 400): string { const normalized = toNonEmptyString(value); if (!normalized) { throw new HttpError(status, `${field} is required`, { field, value }); } return normalized; } function requireUuid(value: unknown, field: string, status = 400): string { const normalized = requireNonEmptyString(value, field, status); if (!isUuid(normalized)) { throw new HttpError(status, `${field} must be a UUID`, { field, value }); } return normalized; } function requireEnum(value: unknown, allowed: readonly T[], field: string, status = 400): T { if (typeof value !== "string" || !allowed.includes(value as T)) { throw new HttpError(status, `Invalid ${field} value`, { field, value }); } return value as T; } async function resolveRequiredProjectId( supabase: ReturnType, requestedProjectId?: string ): Promise { const projectId = requireUuid(requestedProjectId, "task.projectId"); const { data, error } = await supabase.from("projects").select("id").eq("id", projectId).maybeSingle(); if (error) throw error; if (!data?.id) { throw new HttpError(400, "task.projectId does not exist", { projectId }); } return data.id as string; } async function resolveOptionalForeignId( supabase: ReturnType, table: "sprints" | "users", id: string | undefined, field: string ): Promise { const requested = toNonEmptyString(id); if (!requested) return null; if (!isUuid(requested)) { throw new HttpError(400, `${field} must be a UUID when provided`, { field, value: id }); } const { data, error } = await supabase .from(table) .select("id") .eq("id", requested) .maybeSingle(); if (error) throw error; if (!data?.id) { throw new HttpError(400, `${field} does not exist`, { field, value: requested }); } return data.id as string; } function mapProjectRow(row: Record): Project { return { id: requireNonEmptyString(row.id, "projects.id", 500), name: requireNonEmptyString(row.name, "projects.name", 500), description: toNonEmptyString(row.description), color: requireNonEmptyString(row.color, "projects.color", 500), createdAt: requireNonEmptyString(row.created_at, "projects.created_at", 500), }; } function mapSprintRow(row: Record): Sprint { return { id: requireNonEmptyString(row.id, "sprints.id", 500), name: requireNonEmptyString(row.name, "sprints.name", 500), goal: toNonEmptyString(row.goal), startDate: requireNonEmptyString(row.start_date, "sprints.start_date", 500), endDate: requireNonEmptyString(row.end_date, "sprints.end_date", 500), status: requireEnum(row.status, SPRINT_STATUSES, "sprints.status", 500), projectId: requireNonEmptyString(row.project_id, "sprints.project_id", 500), createdAt: requireNonEmptyString(row.created_at, "sprints.created_at", 500), }; } function mapUserRow(row: Record): UserProfile { const id = requireNonEmptyString(row.id, "users.id", 500); const name = requireNonEmptyString(row.name, "users.name", 500); return { id, name, email: toNonEmptyString(row.email), avatarUrl: toNonEmptyString(row.avatar_url), }; } function mapTaskRow(row: Record, usersById: Map, includeFullData = false): Task { 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; if (!Array.isArray(row.tags)) { throw new HttpError(500, "Invalid tasks.tags value in database", { taskId: row.id, value: row.tags }); } if (row.comments !== undefined && !Array.isArray(row.comments)) { throw new HttpError(500, "Invalid tasks.comments value in database", { taskId: row.id, value: row.comments }); } if (includeFullData && row.attachments !== undefined && !Array.isArray(row.attachments)) { throw new HttpError(500, "Invalid tasks.attachments value in database", { taskId: row.id, value: row.attachments }); } const task: Task = { id: requireNonEmptyString(row.id, "tasks.id", 500), title: requireNonEmptyString(row.title, "tasks.title", 500), description: includeFullData ? toNonEmptyString(row.description) : undefined, type: requireEnum(row.type, TASK_TYPES, "tasks.type", 500), status: requireEnum(row.status, TASK_STATUSES, "tasks.status", 500), priority: requireEnum(row.priority, TASK_PRIORITIES, "tasks.priority", 500), projectId: requireNonEmptyString(row.project_id, "tasks.project_id", 500), sprintId: toNonEmptyString(row.sprint_id), createdAt: requireNonEmptyString(row.created_at, "tasks.created_at", 500), updatedAt: requireNonEmptyString(row.updated_at, "tasks.updated_at", 500), createdById, createdByName: createdByUser?.name, createdByAvatarUrl: createdByUser?.avatarUrl, updatedById, updatedByName: updatedByUser?.name, updatedByAvatarUrl: updatedByUser?.avatarUrl, assigneeId, assigneeName: assigneeUser?.name, assigneeEmail: assigneeUser?.email, assigneeAvatarUrl: assigneeUser?.avatarUrl, dueDate: toNonEmptyString(row.due_date), comments: (row.comments as unknown[] | undefined) ?? [], tags: (row.tags as unknown[]).filter((tag): tag is string => typeof tag === "string"), attachments: includeFullData ? (row.attachments as unknown[] | undefined) ?? [] : [], }; return task; } // GET - fetch all tasks, projects, and sprints // Uses lightweight fields for faster initial load export async function GET(request: Request) { try { const user = await getAuthenticatedUser(); if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const supabase = getServiceSupabase(); const url = new URL(request.url); const includeFullTaskData = url.searchParams.get("include") === "detail"; const scope = parseTaskQueryScope(url.searchParams.get("scope")); const requestedTaskId = toNonEmptyString(url.searchParams.get("taskId")); if (requestedTaskId && !isUuid(requestedTaskId)) { throw new HttpError(400, "taskId must be a UUID", { taskId: requestedTaskId }); } const taskFieldSet = includeFullTaskData ? [...TASK_BASE_FIELDS, ...TASK_DETAIL_ONLY_FIELDS] : TASK_BASE_FIELDS; // Keep non-task entities parallel; task query may be scoped by current sprint. const [ { data: projects, error: projectsError }, { data: sprints, error: sprintsError }, { data: users, error: usersError } ] = await Promise.all([ supabase.from("projects").select("id, name, description, color, created_at").order("created_at", { ascending: true }), supabase.from("sprints").select("id, name, goal, start_date, end_date, status, project_id, created_at").order("start_date", { ascending: true }), supabase.from("users").select("id, name, email, avatar_url"), ]); if (projectsError) throwQueryError("projects", projectsError); if (sprintsError) throwQueryError("sprints", sprintsError); if (usersError) throwQueryError("users", usersError); const mappedSprints = (sprints || []).map((row) => mapSprintRow(row as Record)); let taskRows: Record[] = []; if (requestedTaskId) { const { data, error } = await supabase .from("tasks") .select(taskFieldSet.join(", ")) .eq("id", requestedTaskId) .maybeSingle(); if (error) throwQueryError("tasks", error); taskRows = data ? [data as unknown as Record] : []; } else if (scope === "all") { const { data, error } = await supabase .from("tasks") .select(taskFieldSet.join(", ")) .order("updated_at", { ascending: false }); if (error) throwQueryError("tasks", error); taskRows = (data as unknown as Record[] | null) || []; } else { const now = new Date(); const currentSprint = mappedSprints.find((sprint) => sprint.status === "active" && isSprintInProgress(sprint.startDate, sprint.endDate, now)) ?? mappedSprints.find((sprint) => sprint.status !== "completed" && isSprintInProgress(sprint.startDate, sprint.endDate, now)); if (currentSprint?.id) { const { data, error } = await supabase .from("tasks") .select(taskFieldSet.join(", ")) .eq("sprint_id", currentSprint.id) .order("updated_at", { ascending: false }); if (error) throwQueryError("tasks", error); taskRows = (data as unknown as Record[] | null) || []; } } const usersById = new Map(); for (const row of users || []) { const mapped = mapUserRow(row as Record); usersById.set(mapped.id, mapped); } return NextResponse.json({ projects: (projects || []).map((row) => mapProjectRow(row as Record)), sprints: mappedSprints, tasks: taskRows.map((row) => mapTaskRow(row, usersById, includeFullTaskData)), currentUser: { id: user.id, name: user.name, email: user.email, avatarUrl: user.avatarUrl, }, lastUpdated: Date.now(), }, { headers: { 'Cache-Control': 'no-store', } }); } catch (error) { console.error(">>> API GET error:", error); if (error instanceof HttpError) { return NextResponse.json({ error: error.message, details: error.details }, { status: error.status }); } const message = error instanceof Error ? error.message : "Failed to fetch data"; return NextResponse.json({ error: message, details: toErrorDetails(error) }, { status: 500 }); } } // POST - create or update a single task export async function POST(request: Request) { try { const user = await getAuthenticatedUser(); if (!user) { console.error(">>> API POST: No authenticated user"); return NextResponse.json({ error: "Unauthorized - please log in again" }, { status: 401 }); } console.log(">>> API POST: Authenticated as", user.email); const body = await request.json(); const { task } = body as { task?: Task }; if (!task) { return NextResponse.json({ error: "Missing task" }, { status: 400 }); } const supabase = getServiceSupabase(); const now = new Date().toISOString(); const taskId = requireUuid(task.id, "task.id"); const resolvedProjectId = await resolveRequiredProjectId(supabase, task.projectId); const resolvedSprintId = await resolveOptionalForeignId(supabase, "sprints", task.sprintId, "task.sprintId"); const resolvedAssigneeId = await resolveOptionalForeignId(supabase, "users", task.assigneeId, "task.assigneeId"); // Check if task exists let existing: { id: string } | null = null; const { data: byId, error: byIdError } = await supabase .from("tasks") .select("id") .eq("id", taskId) .maybeSingle(); if (byIdError) throw byIdError; existing = (byId as { id: string } | null) ?? null; const taskData = stripUndefined({ id: taskId, title: requireNonEmptyString(task.title, "task.title"), description: task.description || null, type: task.type ? requireEnum(task.type, TASK_TYPES, "task.type") : "task", status: task.status ? requireEnum(task.status, TASK_STATUSES, "task.status") : "todo", priority: task.priority ? requireEnum(task.priority, TASK_PRIORITIES, "task.priority") : "medium", project_id: resolvedProjectId, sprint_id: resolvedSprintId, created_at: existing ? undefined : toNonEmptyString(task.createdAt), updated_at: now, created_by_id: existing ? undefined : user.id, updated_by_id: user.id, assignee_id: resolvedAssigneeId, due_date: task.dueDate || null, comments: task.comments || [], tags: task.tags || [], attachments: task.attachments || [], }); const query = existing ? supabase.from("tasks").update(taskData).eq("id", taskId).select().single() : supabase.from("tasks").insert(taskData).select().single(); const { data: result, error: saveError } = await query; if (saveError) throw saveError; if (!result) throw new HttpError(500, "Task save succeeded without returning a row"); return NextResponse.json({ success: true, task: result }); } catch (error: unknown) { console.error(">>> API POST error:", error); if (error instanceof HttpError) { return NextResponse.json({ error: error.message, details: error.details }, { status: error.status }); } const message = error instanceof Error ? error.message : "Failed to save"; 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 }); } } // DELETE - remove a task export async function DELETE(request: Request) { try { const user = await getAuthenticatedUser(); if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const { id } = (await request.json()) as { id: string }; const taskId = requireUuid(id, "id"); const supabase = getServiceSupabase(); const { error, count } = await supabase .from("tasks") .delete({ count: "exact" }) .eq("id", taskId); if (error) throw error; if ((count ?? 0) === 0) { throw new HttpError(404, "Task not found", { id: taskId }); } return NextResponse.json({ success: true }); } catch (error: unknown) { console.error(">>> API DELETE error:", error); if (error instanceof HttpError) { return NextResponse.json({ error: error.message, details: error.details }, { status: error.status }); } const message = error instanceof Error ? error.message : "Failed to delete"; return NextResponse.json({ error: message }, { status: 500 }); } }