From 42188457dac5af1f12aafb2ebfa4f4bef947df6a Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 21 Feb 2026 17:40:09 -0600 Subject: [PATCH] Add complete CRUD APIs for projects and sprints - New API endpoints: - GET /api/projects - List projects - POST /api/projects - Create project - PATCH /api/projects - Update project - DELETE /api/projects - Delete project - GET /api/sprints - List sprints - POST /api/sprints - Create sprint - PATCH /api/sprints - Update sprint - DELETE /api/sprints - Delete sprint - Added CLI commands: - project create/update/delete - sprint create/update/delete - Updated help text with new commands Web UI can now do full CRUD on projects/sprints and CLI matches 1:1 --- scripts/gantt.sh | 135 +++++++++++++++++++++++++++++++-- src/app/api/projects/route.ts | 129 +++++++++++++++++++++++++++++++ src/app/api/sprints/route.ts | 139 ++++++++++++++++++++++++++++++++++ 3 files changed, 397 insertions(+), 6 deletions(-) create mode 100644 src/app/api/projects/route.ts create mode 100644 src/app/api/sprints/route.ts diff --git a/scripts/gantt.sh b/scripts/gantt.sh index 45731a8..8846f6a 100755 --- a/scripts/gantt.sh +++ b/scripts/gantt.sh @@ -292,9 +292,54 @@ cmd_task_attach() { cmd_project_list() { log_info "Fetching projects..." - local response - response=$(api_call GET "/tasks") - echo "$response" | jq '.projects' + api_call GET "/projects" | jq '.projects' +} + +cmd_project_create() { + local name="$1" + local description="${2:-}" + local color="${3:-#3b82f6}" + + if [ -z "$name" ]; then + log_error "Usage: project create [description] [color]" + exit 1 + fi + + log_info "Creating project: $name" + local data + data=$(jq -n --arg name "$name" --arg desc "$description" --arg color "$color" \ + '{name: $name, description: (if $desc == "" then null else $desc end), color: $color}') + api_call POST "/projects" "$data" +} + +cmd_project_update() { + local project_id="$1" + local field="$2" + local value="$3" + + if [ -z "$project_id" ] || [ -z "$field" ]; then + log_error "Usage: project update " + echo "Fields: name, description, color" + exit 1 + fi + + log_info "Updating project $project_id: $field = $value" + local data + data=$(jq -n --arg id "$project_id" --arg field "$field" --arg value "$value" \ + '{id: $id, ($field): $value}') + api_call PATCH "/projects" "$data" +} + +cmd_project_delete() { + local project_id="$1" + + if [ -z "$project_id" ]; then + log_error "Usage: project delete " + exit 1 + fi + + log_warn "Deleting project $project_id..." + api_call DELETE "/projects" "{\"id\": \"$project_id\"}" } #=================== @@ -303,9 +348,68 @@ cmd_project_list() { cmd_sprint_list() { log_info "Fetching sprints..." - local response - response=$(api_call GET "/tasks") - echo "$response" | jq '.sprints' + api_call GET "/sprints" | jq '.sprints' +} + +cmd_sprint_create() { + local name="$1" + local project_id="$2" + local start_date="${3:-}" + local end_date="${4:-}" + local goal="${5:-}" + + if [ -z "$name" ]; then + log_error "Usage: sprint create [start-date] [end-date] [goal]" + exit 1 + fi + + log_info "Creating sprint: $name" + local data + data=$(jq -n \ + --arg name "$name" \ + --arg projectId "$project_id" \ + --arg startDate "$start_date" \ + --arg endDate "$end_date" \ + --arg goal "$goal" \ + '{ + name: $name, + projectId: $projectId, + startDate: (if $startDate == "" then null else $startDate end), + endDate: (if $endDate == "" then null else $endDate end), + goal: (if $goal == "" then null else $goal end), + status: "planning" + }') + api_call POST "/sprints" "$data" +} + +cmd_sprint_update() { + local sprint_id="$1" + local field="$2" + local value="$3" + + if [ -z "$sprint_id" ] || [ -z "$field" ]; then + log_error "Usage: sprint update " + echo "Fields: name, goal, startDate, endDate, status, projectId" + exit 1 + fi + + log_info "Updating sprint $sprint_id: $field = $value" + local data + data=$(jq -n --arg id "$sprint_id" --arg field "$field" --arg value "$value" \ + '{id: $id, ($field): $value}') + api_call PATCH "/sprints" "$data" +} + +cmd_sprint_delete() { + local sprint_id="$1" + + if [ -z "$sprint_id" ]; then + log_error "Usage: sprint delete " + exit 1 + fi + + log_warn "Deleting sprint $sprint_id..." + api_call DELETE "/sprints" "{\"id\": \"$sprint_id\"}" } #=================== @@ -434,9 +538,22 @@ TASK COMMANDS: PROJECT COMMANDS: project list List all projects + project create [desc] [color] + Create new project + project update + Update project field + Fields: name, description, color + project delete Delete a project SPRINT COMMANDS: sprint list List all sprints + sprint create [start] [end] [goal] + Create new sprint + sprint update + Update sprint field + Fields: name, goal, startDate, endDate, + status, projectId + sprint delete Delete a sprint AUTH COMMANDS: auth login Log in @@ -505,6 +622,9 @@ main() { shift || true case "$subcmd" in list|ls) cmd_project_list "$@" ;; + create|new|add) cmd_project_create "$@" ;; + update|set|edit) cmd_project_update "$@" ;; + delete|rm|remove) cmd_project_delete "$@" ;; *) log_error "Unknown project command: $subcmd"; show_help; exit 1 ;; esac ;; @@ -513,6 +633,9 @@ main() { shift || true case "$subcmd" in list|ls) cmd_sprint_list "$@" ;; + create|new|add) cmd_sprint_create "$@" ;; + update|set|edit) cmd_sprint_update "$@" ;; + delete|rm|remove) cmd_sprint_delete "$@" ;; *) log_error "Unknown sprint command: $subcmd"; show_help; exit 1 ;; esac ;; diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts new file mode 100644 index 0000000..8d47e96 --- /dev/null +++ b/src/app/api/projects/route.ts @@ -0,0 +1,129 @@ +import { NextResponse } from "next/server"; +import { getServiceSupabase } from "@/lib/supabase/client"; +import { getAuthenticatedUser } from "@/lib/server/auth"; + +export const runtime = "nodejs"; + +// GET - fetch all projects +export async function GET() { + try { + const user = await getAuthenticatedUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const supabase = getServiceSupabase(); + const { data: projects, error } = await supabase + .from("projects") + .select("*") + .order("created_at", { ascending: true }); + + if (error) throw error; + + return NextResponse.json({ projects: projects || [] }); + } catch (error) { + console.error(">>> API GET /projects error:", error); + return NextResponse.json({ error: "Failed to fetch projects" }, { status: 500 }); + } +} + +// POST - create a new project +export async function POST(request: Request) { + try { + const user = await getAuthenticatedUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { name, description, color } = body; + + if (!name || typeof name !== "string") { + return NextResponse.json({ error: "Missing project name" }, { status: 400 }); + } + + const supabase = getServiceSupabase(); + const now = new Date().toISOString(); + + const { data, error } = await supabase + .from("projects") + .insert({ + name, + description: description || null, + color: color || "#3b82f6", + created_at: now, + }) + .select() + .single(); + + if (error) throw error; + + return NextResponse.json({ success: true, project: data }); + } catch (error) { + console.error(">>> API POST /projects error:", error); + return NextResponse.json({ error: "Failed to create project" }, { status: 500 }); + } +} + +// PATCH - update a project +export async function PATCH(request: Request) { + try { + const user = await getAuthenticatedUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { id, ...updates } = body; + + if (!id) { + return NextResponse.json({ error: "Missing project id" }, { status: 400 }); + } + + const supabase = getServiceSupabase(); + const now = new Date().toISOString(); + + const { data, error } = await supabase + .from("projects") + .update({ + ...updates, + updated_at: now, + }) + .eq("id", id) + .select() + .single(); + + if (error) throw error; + + return NextResponse.json({ success: true, project: data }); + } catch (error) { + console.error(">>> API PATCH /projects error:", error); + return NextResponse.json({ error: "Failed to update project" }, { status: 500 }); + } +} + +// DELETE - delete a project +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(); + + if (!id) { + return NextResponse.json({ error: "Missing project id" }, { status: 400 }); + } + + const supabase = getServiceSupabase(); + const { error } = await supabase.from("projects").delete().eq("id", id); + + if (error) throw error; + + return NextResponse.json({ success: true }); + } catch (error) { + console.error(">>> API DELETE /projects error:", error); + return NextResponse.json({ error: "Failed to delete project" }, { status: 500 }); + } +} diff --git a/src/app/api/sprints/route.ts b/src/app/api/sprints/route.ts new file mode 100644 index 0000000..344b1c7 --- /dev/null +++ b/src/app/api/sprints/route.ts @@ -0,0 +1,139 @@ +import { NextResponse } from "next/server"; +import { getServiceSupabase } from "@/lib/supabase/client"; +import { getAuthenticatedUser } from "@/lib/server/auth"; + +export const runtime = "nodejs"; + +// GET - fetch all sprints +export async function GET() { + try { + const user = await getAuthenticatedUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const supabase = getServiceSupabase(); + const { data: sprints, error } = await supabase + .from("sprints") + .select("*") + .order("start_date", { ascending: true }); + + if (error) throw error; + + return NextResponse.json({ sprints: sprints || [] }); + } catch (error) { + console.error(">>> API GET /sprints error:", error); + return NextResponse.json({ error: "Failed to fetch sprints" }, { status: 500 }); + } +} + +// POST - create a new sprint +export async function POST(request: Request) { + try { + const user = await getAuthenticatedUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { name, goal, startDate, endDate, status, projectId } = body; + + if (!name || typeof name !== "string") { + return NextResponse.json({ error: "Missing sprint name" }, { status: 400 }); + } + + const supabase = getServiceSupabase(); + const now = new Date().toISOString(); + + const { data, error } = await supabase + .from("sprints") + .insert({ + name, + goal: goal || null, + start_date: startDate || now, + end_date: endDate || now, + status: status || "planning", + project_id: projectId || null, + created_at: now, + }) + .select() + .single(); + + if (error) throw error; + + return NextResponse.json({ success: true, sprint: data }); + } catch (error) { + console.error(">>> API POST /sprints error:", error); + return NextResponse.json({ error: "Failed to create sprint" }, { status: 500 }); + } +} + +// PATCH - update a sprint +export async function PATCH(request: Request) { + try { + const user = await getAuthenticatedUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { id, ...updates } = body; + + if (!id) { + return NextResponse.json({ error: "Missing sprint id" }, { status: 400 }); + } + + const supabase = getServiceSupabase(); + const now = new Date().toISOString(); + + // Map camelCase to snake_case for database + const dbUpdates: Record = {}; + if (updates.name !== undefined) dbUpdates.name = updates.name; + if (updates.goal !== undefined) dbUpdates.goal = updates.goal; + if (updates.startDate !== undefined) dbUpdates.start_date = updates.startDate; + if (updates.endDate !== undefined) dbUpdates.end_date = updates.endDate; + if (updates.status !== undefined) dbUpdates.status = updates.status; + if (updates.projectId !== undefined) dbUpdates.project_id = updates.projectId; + dbUpdates.updated_at = now; + + const { data, error } = await supabase + .from("sprints") + .update(dbUpdates) + .eq("id", id) + .select() + .single(); + + if (error) throw error; + + return NextResponse.json({ success: true, sprint: data }); + } catch (error) { + console.error(">>> API PATCH /sprints error:", error); + return NextResponse.json({ error: "Failed to update sprint" }, { status: 500 }); + } +} + +// DELETE - delete a sprint +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(); + + if (!id) { + return NextResponse.json({ error: "Missing sprint id" }, { status: 400 }); + } + + const supabase = getServiceSupabase(); + const { error } = await supabase.from("sprints").delete().eq("id", id); + + if (error) throw error; + + return NextResponse.json({ success: true }); + } catch (error) { + console.error(">>> API DELETE /sprints error:", error); + return NextResponse.json({ error: "Failed to delete sprint" }, { status: 500 }); + } +}