gantt-board/src/app/api/tasks/route.ts

519 lines
19 KiB
TypeScript

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<string, unknown>;
constructor(status: number, message: string, details?: Record<string, unknown>) {
super(message);
this.status = status;
this.details = details;
}
}
function toErrorDetails(error: unknown): Record<string, unknown> | 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<T extends Record<string, unknown>>(value: T): Record<string, unknown> {
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<T extends string>(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<typeof getServiceSupabase>,
requestedProjectId?: string
): Promise<string> {
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<typeof getServiceSupabase>,
table: "sprints" | "users",
id: string | undefined,
field: string
): Promise<string | null> {
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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown>, usersById: Map<string, UserProfile>, 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<string, unknown>));
let taskRows: Record<string, unknown>[] = [];
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<string, unknown>] : [];
} 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<string, unknown>[] | 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<string, unknown>[] | null) || [];
}
}
const usersById = new Map<string, UserProfile>();
for (const row of users || []) {
const mapped = mapUserRow(row as Record<string, unknown>);
usersById.set(mapped.id, mapped);
}
return NextResponse.json({
projects: (projects || []).map((row) => mapProjectRow(row as Record<string, unknown>)),
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 });
}
}