519 lines
19 KiB
TypeScript
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 });
|
|
}
|
|
}
|