diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index bc0ef47..f82e3fd 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -38,6 +38,29 @@ export interface ProgressNote { note: string; } +// ─── Projects ─── + +export interface ProjectLink { + label: string; + url: string; +} + +export const projects = pgTable("projects", { + id: uuid("id").defaultRandom().primaryKey(), + name: text("name").notNull(), + description: text("description"), + context: text("context"), // Architecture notes, how-to, credentials references + repos: jsonb("repos").$type().default([]), // Git repo URLs + links: jsonb("links").$type().default([]), // Related URLs (docs, domains, dashboards) + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), +}); + +export type Project = typeof projects.$inferSelect; +export type NewProject = typeof projects.$inferInsert; + +// ─── Tasks ─── + export const tasks = pgTable("tasks", { id: uuid("id").defaultRandom().primaryKey(), taskNumber: integer("task_number"), @@ -49,6 +72,7 @@ export const tasks = pgTable("tasks", { position: integer("position").notNull().default(0), assigneeId: text("assignee_id"), assigneeName: text("assignee_name"), + projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }), progressNotes: jsonb("progress_notes").$type().default([]), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), diff --git a/backend/src/index.ts b/backend/src/index.ts index 0fa4803..e1dd284 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -2,6 +2,7 @@ import { Elysia } from "elysia"; import { cors } from "@elysiajs/cors"; import { taskRoutes } from "./routes/tasks"; import { adminRoutes } from "./routes/admin"; +import { projectRoutes } from "./routes/projects"; import { auth } from "./lib/auth"; import { db } from "./db"; import { tasks, users } from "./db/schema"; @@ -113,6 +114,7 @@ const app = new Elysia() }) .use(taskRoutes) + .use(projectRoutes) .use(adminRoutes) // Current user info (role, etc.) diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts new file mode 100644 index 0000000..52dd4ef --- /dev/null +++ b/backend/src/routes/projects.ts @@ -0,0 +1,153 @@ +import { Elysia, t } from "elysia"; +import { db } from "../db"; +import { projects, tasks } from "../db/schema"; +import { eq, asc, desc } from "drizzle-orm"; +import { auth } from "../lib/auth"; + +const BEARER_TOKEN = process.env.API_BEARER_TOKEN || "hammer-dev-token"; + +async function requireSessionOrBearer( + request: Request, + headers: Record +) { + const authHeader = headers["authorization"]; + if (authHeader === `Bearer ${BEARER_TOKEN}`) return; + try { + const session = await auth.api.getSession({ headers: request.headers }); + if (session) return; + } catch {} + throw new Error("Unauthorized"); +} + +export const projectRoutes = new Elysia({ prefix: "/api/projects" }) + .onError(({ error, set }) => { + const msg = error?.message || String(error); + if (msg === "Unauthorized") { + set.status = 401; + return { error: "Unauthorized" }; + } + if (msg === "Project not found") { + set.status = 404; + return { error: "Project not found" }; + } + console.error("Project route error:", msg); + set.status = 500; + return { error: "Internal server error" }; + }) + + // GET all projects + .get("/", async ({ request, headers }) => { + await requireSessionOrBearer(request, headers); + const allProjects = await db + .select() + .from(projects) + .orderBy(asc(projects.name)); + return allProjects; + }) + + // POST create project + .post( + "/", + async ({ body, request, headers }) => { + await requireSessionOrBearer(request, headers); + const newProject = await db + .insert(projects) + .values({ + name: body.name, + description: body.description, + context: body.context, + repos: body.repos || [], + links: body.links || [], + }) + .returning(); + return newProject[0]; + }, + { + body: t.Object({ + name: t.String(), + description: t.Optional(t.String()), + context: t.Optional(t.String()), + repos: t.Optional(t.Array(t.String())), + links: t.Optional( + t.Array(t.Object({ label: t.String(), url: t.String() })) + ), + }), + } + ) + + // GET single project with its tasks + .get( + "/:id", + async ({ params, request, headers }) => { + await requireSessionOrBearer(request, headers); + const project = await db + .select() + .from(projects) + .where(eq(projects.id, params.id)); + if (!project.length) throw new Error("Project not found"); + + const projectTasks = await db + .select() + .from(tasks) + .where(eq(tasks.projectId, params.id)) + .orderBy(asc(tasks.position), desc(tasks.createdAt)); + + return { ...project[0], tasks: projectTasks }; + }, + { params: t.Object({ id: t.String() }) } + ) + + // PATCH update project + .patch( + "/:id", + async ({ params, body, request, headers }) => { + await requireSessionOrBearer(request, headers); + const updates: Record = { updatedAt: new Date() }; + if (body.name !== undefined) updates.name = body.name; + if (body.description !== undefined) updates.description = body.description; + if (body.context !== undefined) updates.context = body.context; + if (body.repos !== undefined) updates.repos = body.repos; + if (body.links !== undefined) updates.links = body.links; + + const updated = await db + .update(projects) + .set(updates) + .where(eq(projects.id, params.id)) + .returning(); + if (!updated.length) throw new Error("Project not found"); + return updated[0]; + }, + { + params: t.Object({ id: t.String() }), + body: t.Object({ + name: t.Optional(t.String()), + description: t.Optional(t.String()), + context: t.Optional(t.String()), + repos: t.Optional(t.Array(t.String())), + links: t.Optional( + t.Array(t.Object({ label: t.String(), url: t.String() })) + ), + }), + } + ) + + // DELETE project (unlinks tasks, doesn't delete them) + .delete( + "/:id", + async ({ params, request, headers }) => { + await requireSessionOrBearer(request, headers); + // Unlink tasks first + await db + .update(tasks) + .set({ projectId: null, updatedAt: new Date() }) + .where(eq(tasks.projectId, params.id)); + // Delete project + const deleted = await db + .delete(projects) + .where(eq(projects.id, params.id)) + .returning(); + if (!deleted.length) throw new Error("Project not found"); + return { success: true }; + }, + { params: t.Object({ id: t.String() }) } + ); diff --git a/backend/src/routes/tasks.ts b/backend/src/routes/tasks.ts index 0c4d10e..fd1eae6 100644 --- a/backend/src/routes/tasks.ts +++ b/backend/src/routes/tasks.ts @@ -152,6 +152,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" }) priority: body.priority || "medium", position: (maxPos[0]?.max ?? 0) + 1, taskNumber: nextNumber, + projectId: body.projectId || null, progressNotes: [], }) .returning(); @@ -188,6 +189,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" }) t.Literal("low"), ]) ), + projectId: t.Optional(t.Union([t.String(), t.Null()])), }), } ) @@ -233,6 +235,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" }) if (body.position !== undefined) updates.position = body.position; if (body.assigneeId !== undefined) updates.assigneeId = body.assigneeId; if (body.assigneeName !== undefined) updates.assigneeName = body.assigneeName; + if (body.projectId !== undefined) updates.projectId = body.projectId; const updated = await db .update(tasks) @@ -259,6 +262,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" }) position: t.Optional(t.Number()), assigneeId: t.Optional(t.Union([t.String(), t.Null()])), assigneeName: t.Optional(t.Union([t.String(), t.Null()])), + projectId: t.Optional(t.Union([t.String(), t.Null()])), }), } ) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2a0f43f..5e67423 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import { DashboardLayout } from "./components/DashboardLayout"; import { QueuePage } from "./pages/QueuePage"; import { ChatPage } from "./pages/ChatPage"; +import { ProjectsPage } from "./pages/ProjectsPage"; import { AdminPage } from "./components/AdminPage"; import { LoginPage } from "./components/LoginPage"; import { useSession } from "./lib/auth-client"; @@ -12,6 +13,7 @@ function AuthenticatedApp() { }> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/DashboardLayout.tsx b/frontend/src/components/DashboardLayout.tsx index 0566470..a4baf7f 100644 --- a/frontend/src/components/DashboardLayout.tsx +++ b/frontend/src/components/DashboardLayout.tsx @@ -5,6 +5,7 @@ import { signOut } from "../lib/auth-client"; const navItems = [ { to: "/queue", label: "Queue", icon: "📋" }, + { to: "/projects", label: "Projects", icon: "📁" }, { to: "/chat", label: "Chat", icon: "💬" }, ]; diff --git a/frontend/src/components/TaskDetailPanel.tsx b/frontend/src/components/TaskDetailPanel.tsx index bb809ae..f2d3514 100644 --- a/frontend/src/components/TaskDetailPanel.tsx +++ b/frontend/src/components/TaskDetailPanel.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from "react"; -import type { Task, TaskStatus, TaskPriority, TaskSource } from "../lib/types"; -import { updateTask } from "../lib/api"; +import type { Task, TaskStatus, TaskPriority, TaskSource, Project } from "../lib/types"; +import { updateTask, fetchProjects } from "../lib/api"; const priorityColors: Record = { critical: "bg-red-500 text-white", @@ -250,6 +250,13 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, const [draftDescription, setDraftDescription] = useState(task.description || ""); const [draftPriority, setDraftPriority] = useState(task.priority); const [draftSource, setDraftSource] = useState(task.source); + const [draftProjectId, setDraftProjectId] = useState(task.projectId || ""); + const [projects, setProjects] = useState([]); + + // Fetch projects for the selector + useEffect(() => { + fetchProjects().then(setProjects).catch(() => {}); + }, []); // Reset drafts when task changes useEffect(() => { @@ -257,31 +264,35 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, setDraftDescription(task.description || ""); setDraftPriority(task.priority); setDraftSource(task.source); - }, [task.id, task.title, task.description, task.priority, task.source]); + setDraftProjectId(task.projectId || ""); + }, [task.id, task.title, task.description, task.priority, task.source, task.projectId]); // Detect if any field has been modified const isDirty = draftTitle !== task.title || draftDescription !== (task.description || "") || draftPriority !== task.priority || - draftSource !== task.source; + draftSource !== task.source || + draftProjectId !== (task.projectId || ""); const handleCancel = () => { setDraftTitle(task.title); setDraftDescription(task.description || ""); setDraftPriority(task.priority); setDraftSource(task.source); + setDraftProjectId(task.projectId || ""); }; const handleSave = async () => { if (!hasToken || !isDirty) return; setSaving(true); try { - const updates: Record = {}; + const updates: Record = {}; if (draftTitle !== task.title) updates.title = draftTitle.trim(); if (draftDescription !== (task.description || "")) updates.description = draftDescription.trim(); if (draftPriority !== task.priority) updates.priority = draftPriority; if (draftSource !== task.source) updates.source = draftSource; + if (draftProjectId !== (task.projectId || "")) updates.projectId = draftProjectId || null; await updateTask(task.id, updates, token); onTaskUpdated(); } catch (e) { @@ -426,6 +437,29 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, + {/* Project */} + {(hasToken || task.projectId) && ( +
+

Project

+ {hasToken ? ( + + ) : ( + + {projects.find((p) => p.id === task.projectId)?.name || task.projectId || "None"} + + )} +
+ )} + {/* Description */}

Description

diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d0d736b..9978f83 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,4 +1,4 @@ -import type { Task } from "./types"; +import type { Task, Project, ProjectWithTasks } from "./types"; const BASE = "/api/tasks"; @@ -64,6 +64,57 @@ export async function deleteTask(id: string, token?: string): Promise { if (!res.ok) throw new Error("Failed to delete task"); } +// ─── Projects API ─── + +const PROJECTS_BASE = "/api/projects"; + +export async function fetchProjects(): Promise { + const res = await fetch(PROJECTS_BASE, { credentials: "include" }); + if (!res.ok) throw new Error(res.status === 401 ? "Unauthorized" : "Failed to fetch projects"); + return res.json(); +} + +export async function fetchProject(id: string): Promise { + const res = await fetch(`${PROJECTS_BASE}/${id}`, { credentials: "include" }); + if (!res.ok) throw new Error("Failed to fetch project"); + return res.json(); +} + +export async function createProject( + project: { name: string; description?: string; context?: string; repos?: string[]; links?: { label: string; url: string }[] } +): Promise { + const res = await fetch(PROJECTS_BASE, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(project), + }); + if (!res.ok) throw new Error("Failed to create project"); + return res.json(); +} + +export async function updateProject( + id: string, + updates: Record +): Promise { + const res = await fetch(`${PROJECTS_BASE}/${id}`, { + method: "PATCH", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updates), + }); + if (!res.ok) throw new Error("Failed to update project"); + return res.json(); +} + +export async function deleteProject(id: string): Promise { + const res = await fetch(`${PROJECTS_BASE}/${id}`, { + method: "DELETE", + credentials: "include", + }); + if (!res.ok) throw new Error("Failed to delete project"); +} + // Admin API export async function fetchUsers(): Promise { const res = await fetch("/api/admin/users", { credentials: "include" }); diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index ed18859..612be50 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -7,6 +7,26 @@ export interface ProgressNote { note: string; } +export interface ProjectLink { + label: string; + url: string; +} + +export interface Project { + id: string; + name: string; + description: string | null; + context: string | null; + repos: string[]; + links: ProjectLink[]; + createdAt: string; + updatedAt: string; +} + +export interface ProjectWithTasks extends Project { + tasks: Task[]; +} + export interface Task { id: string; taskNumber: number; @@ -16,6 +36,7 @@ export interface Task { status: TaskStatus; priority: TaskPriority; position: number; + projectId: string | null; progressNotes: ProgressNote[]; createdAt: string; updatedAt: string; diff --git a/frontend/src/pages/ProjectsPage.tsx b/frontend/src/pages/ProjectsPage.tsx new file mode 100644 index 0000000..450d022 --- /dev/null +++ b/frontend/src/pages/ProjectsPage.tsx @@ -0,0 +1,665 @@ +import { useState, useEffect, useCallback } from "react"; +import type { Project, ProjectWithTasks, Task } from "../lib/types"; +import { + fetchProjects, + fetchProject, + createProject, + updateProject, + updateTask, +} from "../lib/api"; + +// ─── Status/priority helpers ─── +const statusColors: Record = { + active: "bg-green-100 text-green-700", + queued: "bg-blue-100 text-blue-700", + blocked: "bg-red-100 text-red-700", + completed: "bg-gray-100 text-gray-500", + cancelled: "bg-gray-100 text-gray-400", +}; + +function StatusBadge({ status }: { status: string }) { + return ( + + {status} + + ); +} + +// ─── Create Project Modal ─── +function CreateProjectModal({ + onClose, + onCreated, +}: { + onClose: () => void; + onCreated: (p: Project) => void; +}) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [context, setContext] = useState(""); + const [repos, setRepos] = useState(""); + const [saving, setSaving] = useState(false); + + const handleSave = async () => { + if (!name.trim()) return; + setSaving(true); + try { + const repoList = repos + .split("\n") + .map((r) => r.trim()) + .filter(Boolean); + const project = await createProject({ + name: name.trim(), + description: description.trim() || undefined, + context: context.trim() || undefined, + repos: repoList.length ? repoList : undefined, + }); + onCreated(project); + } catch (e) { + console.error(e); + } finally { + setSaving(false); + } + }; + + return ( +
+
+
+

New Project

+
+
+
+ + setName(e.target.value)} + className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-200" + placeholder="e.g. Hammer Dashboard" + /> +
+
+ +