diff --git a/.woodpecker.yml b/.woodpecker.yml index 7e7c83c..8cba77a 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -1,9 +1,11 @@ steps: - docker: + build-backend: image: woodpeckerci/plugin-kaniko settings: registry: registry.threesix.ai - repo: "testfix" + repo: "testfix-backend" + dockerfile: backend/Dockerfile + context: backend tags: - latest - ${CI_COMMIT_SHA:0:8} @@ -13,10 +15,34 @@ steps: - event: push branch: main - deploy: - image: bitnami/kubectl:latest - commands: - - kubectl set image deployment/testfix testfix=registry.threesix.ai/testfix:${CI_COMMIT_SHA:0:8} -n projects + build-frontend: + image: woodpeckerci/plugin-kaniko + settings: + registry: registry.threesix.ai + repo: "testfix-frontend" + dockerfile: frontend/Dockerfile + context: frontend + tags: + - latest + - ${CI_COMMIT_SHA:0:8} + cache: true + skip-tls-verify: true + when: + - event: push + branch: main + + deploy-backend: + image: bitnami/kubectl:latest + commands: + - kubectl set image deployment/testfix-backend backend=registry.threesix.ai/testfix-backend:${CI_COMMIT_SHA:0:8} -n projects + when: + - event: push + branch: main + + deploy-frontend: + image: bitnami/kubectl:latest + commands: + - kubectl set image deployment/testfix-frontend frontend=registry.threesix.ai/testfix-frontend:${CI_COMMIT_SHA:0:8} -n projects when: - event: push branch: main diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..f87401c --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,31 @@ +# Binaries +*.exe +*.exe~ +*.dll +*.so +*.dylib +main +taskmanager + +# Test binary +*.test + +# Output of go coverage +*.out + +# Dependency directories +vendor/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Environment +.env +.env.local diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..be27a5d --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,36 @@ +# Build stage +FROM golang:1.22-alpine AS builder + +WORKDIR /app + +# Install dependencies +RUN apk add --no-cache git + +# Copy go mod files +COPY go.mod go.sum ./ + +# Download dependencies +RUN go mod download + +# Copy source code +COPY . . + +# Build the application +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main . + +# Final stage +FROM alpine:3.19 + +WORKDIR /app + +# Install ca-certificates for HTTPS +RUN apk --no-cache add ca-certificates + +# Copy binary from builder +COPY --from=builder /app/main . + +# Expose port +EXPOSE 8080 + +# Run the application +CMD ["./main"] diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..d3a5f9b --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,7 @@ +module taskmanager + +go 1.22 + +require github.com/go-chi/chi/v5 v5.0.12 + +require github.com/go-chi/cors v1.2.1 diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..ef00cda --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,4 @@ +github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= +github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= +github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000..3a5f271 --- /dev/null +++ b/backend/main.go @@ -0,0 +1,224 @@ +package main + +import ( + "encoding/json" + "log" + "net/http" + "strconv" + "sync" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" +) + +type Task struct { + ID int `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Status string `json:"status"` + Priority string `json:"priority"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type TaskStore struct { + mu sync.RWMutex + tasks map[int]Task + nextID int +} + +func NewTaskStore() *TaskStore { + store := &TaskStore{ + tasks: make(map[int]Task), + nextID: 1, + } + // Add some sample tasks + now := time.Now() + store.tasks[1] = Task{ + ID: 1, + Title: "Set up development environment", + Description: "Install all necessary tools and dependencies for the project", + Status: "completed", + Priority: "high", + CreatedAt: now.Add(-48 * time.Hour), + UpdatedAt: now.Add(-24 * time.Hour), + } + store.tasks[2] = Task{ + ID: 2, + Title: "Design database schema", + Description: "Create the initial database schema for task management", + Status: "in_progress", + Priority: "high", + CreatedAt: now.Add(-24 * time.Hour), + UpdatedAt: now, + } + store.tasks[3] = Task{ + ID: 3, + Title: "Implement user authentication", + Description: "Add JWT-based authentication system", + Status: "pending", + Priority: "medium", + CreatedAt: now.Add(-12 * time.Hour), + UpdatedAt: now.Add(-12 * time.Hour), + } + store.nextID = 4 + return store +} + +func (s *TaskStore) GetAll() []Task { + s.mu.RLock() + defer s.mu.RUnlock() + tasks := make([]Task, 0, len(s.tasks)) + for _, task := range s.tasks { + tasks = append(tasks, task) + } + return tasks +} + +func (s *TaskStore) Get(id int) (Task, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + task, ok := s.tasks[id] + return task, ok +} + +func (s *TaskStore) Create(task Task) Task { + s.mu.Lock() + defer s.mu.Unlock() + task.ID = s.nextID + task.CreatedAt = time.Now() + task.UpdatedAt = time.Now() + if task.Status == "" { + task.Status = "pending" + } + if task.Priority == "" { + task.Priority = "medium" + } + s.tasks[task.ID] = task + s.nextID++ + return task +} + +func (s *TaskStore) Delete(id int) bool { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.tasks[id]; ok { + delete(s.tasks, id) + return true + } + return false +} + +type APIResponse struct { + Success bool `json:"success"` + Data interface{} `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + +func respondJSON(w http.ResponseWriter, status int, payload interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(payload) +} + +func main() { + store := NewTaskStore() + + r := chi.NewRouter() + + // Middleware + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(middleware.RequestID) + r.Use(cors.Handler(cors.Options{ + AllowedOrigins: []string{"http://localhost:3000", "http://localhost:8080", "*"}, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, + ExposedHeaders: []string{"Link"}, + AllowCredentials: true, + MaxAge: 300, + })) + + // Health check + r.Get("/health", func(w http.ResponseWriter, r *http.Request) { + respondJSON(w, http.StatusOK, APIResponse{Success: true, Data: "healthy"}) + }) + + // API routes + r.Route("/api", func(r chi.Router) { + r.Route("/tasks", func(r chi.Router) { + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + tasks := store.GetAll() + respondJSON(w, http.StatusOK, APIResponse{Success: true, Data: tasks}) + }) + + r.Post("/", func(w http.ResponseWriter, r *http.Request) { + var task Task + if err := json.NewDecoder(r.Body).Decode(&task); err != nil { + respondJSON(w, http.StatusBadRequest, APIResponse{ + Success: false, + Error: "Invalid request body", + }) + return + } + if task.Title == "" { + respondJSON(w, http.StatusBadRequest, APIResponse{ + Success: false, + Error: "Title is required", + }) + return + } + created := store.Create(task) + respondJSON(w, http.StatusCreated, APIResponse{Success: true, Data: created}) + }) + + r.Get("/{id}", func(w http.ResponseWriter, r *http.Request) { + idStr := chi.URLParam(r, "id") + id, err := strconv.Atoi(idStr) + if err != nil { + respondJSON(w, http.StatusBadRequest, APIResponse{ + Success: false, + Error: "Invalid task ID", + }) + return + } + task, ok := store.Get(id) + if !ok { + respondJSON(w, http.StatusNotFound, APIResponse{ + Success: false, + Error: "Task not found", + }) + return + } + respondJSON(w, http.StatusOK, APIResponse{Success: true, Data: task}) + }) + + r.Delete("/{id}", func(w http.ResponseWriter, r *http.Request) { + idStr := chi.URLParam(r, "id") + id, err := strconv.Atoi(idStr) + if err != nil { + respondJSON(w, http.StatusBadRequest, APIResponse{ + Success: false, + Error: "Invalid task ID", + }) + return + } + if !store.Delete(id) { + respondJSON(w, http.StatusNotFound, APIResponse{ + Success: false, + Error: "Task not found", + }) + return + } + respondJSON(w, http.StatusOK, APIResponse{Success: true, Data: "Task deleted"}) + }) + }) + }) + + log.Println("Starting server on :8080") + if err := http.ListenAndServe(":8080", r); err != nil { + log.Fatal(err) + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bef5cb5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,35 @@ +version: '3.8' + +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + ports: + - "8080:8080" + environment: + - PORT=8080 + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + restart: unless-stopped + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + ports: + - "3000:3000" + environment: + - BACKEND_URL=http://backend:8080 + depends_on: + backend: + condition: service_healthy + restart: unless-stopped + +networks: + default: + driver: bridge diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a68f5d3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,34 @@ +# Dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# Testing +/coverage + +# Next.js +/.next/ +/out/ + +# Production +/build + +# Misc +.DS_Store +*.pem + +# Debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Local env files +.env*.local + +# Vercel +.vercel + +# Typescript +*.tsbuildinfo +next-env.d.ts diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..af33a60 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,51 @@ +# Dependencies stage +FROM node:20-alpine AS deps + +WORKDIR /app + +# Copy package files +COPY package.json package-lock.json* ./ + +# Install dependencies +RUN npm ci || npm install + +# Builder stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy dependencies +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Set environment variables for build +ENV NEXT_TELEMETRY_DISABLED=1 + +# Build the application +RUN npm run build + +# Runner stage +FROM node:20-alpine AS runner + +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +# Create non-root user +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +# Copy necessary files from builder +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "server.js"] diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..e7f52b0 --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,46 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + + --primary: 217.2 91.2% 59.8%; + --primary-foreground: 222.2 47.4% 11.2%; + + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 224.3 76.3% 48%; + + --radius: 0.5rem; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..c928d20 --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,33 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import "./globals.css"; + +const inter = Inter({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: "Task Manager", + description: "A modern task management application", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + +
+
+
+

+ Task Manager +

+
+
+
{children}
+
+ + + ); +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 0000000..e730543 --- /dev/null +++ b/frontend/app/page.tsx @@ -0,0 +1,216 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { Plus, Trash2, CheckCircle, Clock, Circle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Task, fetchTasks, deleteTask } from "@/lib/api"; + +const statusIcons = { + pending: Circle, + in_progress: Clock, + completed: CheckCircle, +}; + +const statusColors = { + pending: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30", + in_progress: "bg-blue-500/20 text-blue-400 border-blue-500/30", + completed: "bg-green-500/20 text-green-400 border-green-500/30", +}; + +const priorityColors = { + low: "bg-slate-500/20 text-slate-400 border-slate-500/30", + medium: "bg-orange-500/20 text-orange-400 border-orange-500/30", + high: "bg-red-500/20 text-red-400 border-red-500/30", +}; + +export default function Dashboard() { + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadTasks = async () => { + try { + setLoading(true); + const data = await fetchTasks(); + setTasks(data); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load tasks"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadTasks(); + }, []); + + const handleDelete = async (id: number) => { + try { + await deleteTask(id); + setTasks(tasks.filter((t) => t.id !== id)); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to delete task"); + } + }; + + const stats = { + total: tasks.length, + pending: tasks.filter((t) => t.status === "pending").length, + inProgress: tasks.filter((t) => t.status === "in_progress").length, + completed: tasks.filter((t) => t.status === "completed").length, + }; + + if (loading) { + return ( +
+
Loading tasks...
+
+ ); + } + + return ( +
+ {/* Stats Overview */} +
+ + + + Total Tasks + + + +
{stats.total}
+
+
+ + + + Pending + + + +
+ {stats.pending} +
+
+
+ + + + In Progress + + + +
+ {stats.inProgress} +
+
+
+ + + + Completed + + + +
+ {stats.completed} +
+
+
+
+ + {/* Actions */} +
+

Tasks

+ + + +
+ + {/* Error Message */} + {error && ( +
+ {error} +
+ )} + + {/* Task List */} + {tasks.length === 0 ? ( + + +

No tasks yet

+ + + +
+
+ ) : ( +
+ {tasks.map((task) => { + const StatusIcon = statusIcons[task.status]; + return ( + + +
+
+ +
+ + {task.title} + + {task.description && ( +

+ {task.description} +

+ )} +
+ + {task.status.replace("_", " ")} + + + {task.priority} + +
+
+
+ +
+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/frontend/app/tasks/[id]/page.tsx b/frontend/app/tasks/[id]/page.tsx new file mode 100644 index 0000000..2cdc3d3 --- /dev/null +++ b/frontend/app/tasks/[id]/page.tsx @@ -0,0 +1,212 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import Link from "next/link"; +import { ArrowLeft, Trash2, CheckCircle, Clock, Circle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Task, fetchTask, deleteTask } from "@/lib/api"; + +const statusIcons = { + pending: Circle, + in_progress: Clock, + completed: CheckCircle, +}; + +const statusColors = { + pending: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30", + in_progress: "bg-blue-500/20 text-blue-400 border-blue-500/30", + completed: "bg-green-500/20 text-green-400 border-green-500/30", +}; + +const priorityColors = { + low: "bg-slate-500/20 text-slate-400 border-slate-500/30", + medium: "bg-orange-500/20 text-orange-400 border-orange-500/30", + high: "bg-red-500/20 text-red-400 border-red-500/30", +}; + +export default function TaskDetail() { + const params = useParams(); + const router = useRouter(); + const [task, setTask] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + + useEffect(() => { + const loadTask = async () => { + try { + setLoading(true); + const id = parseInt(params.id as string); + const data = await fetchTask(id); + setTask(data); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load task"); + } finally { + setLoading(false); + } + }; + + if (params.id) { + loadTask(); + } + }, [params.id]); + + const handleDelete = async () => { + if (!task) return; + try { + await deleteTask(task.id); + router.push("/"); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to delete task"); + } + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleString("en-US", { + dateStyle: "medium", + timeStyle: "short", + }); + }; + + if (loading) { + return ( +
+
Loading task...
+
+ ); + } + + if (error || !task) { + return ( +
+ + + Back to Dashboard + + + +

{error || "Task not found"}

+ + + +
+
+
+ ); + } + + const StatusIcon = statusIcons[task.status]; + + return ( +
+ + + Back to Dashboard + + + + +
+
+ +
+ {task.title} + + Created {formatDate(task.createdAt)} + +
+
+ + + + + + + Delete Task + + Are you sure you want to delete this task? This action cannot + be undone. + + + + + + + + +
+
+ +
+ + {task.status.replace("_", " ")} + + + {task.priority} priority + +
+ + {task.description && ( +
+

+ Description +

+

+ {task.description} +

+
+ )} + +
+
+

+ Created +

+

{formatDate(task.createdAt)}

+
+
+

+ Last Updated +

+

{formatDate(task.updatedAt)}

+
+
+
+
+
+ ); +} diff --git a/frontend/app/tasks/new/page.tsx b/frontend/app/tasks/new/page.tsx new file mode 100644 index 0000000..cb847f8 --- /dev/null +++ b/frontend/app/tasks/new/page.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { createTask } from "@/lib/api"; + +export default function NewTask() { + const router = useRouter(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [formData, setFormData] = useState({ + title: "", + description: "", + status: "pending" as const, + priority: "medium" as const, + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!formData.title.trim()) { + setError("Title is required"); + return; + } + + try { + setLoading(true); + setError(null); + await createTask(formData); + router.push("/"); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create task"); + } finally { + setLoading(false); + } + }; + + return ( +
+ + + Back to Dashboard + + + + + Create New Task + + +
+ {error && ( +
+ {error} +
+ )} + +
+ + + setFormData({ ...formData, title: e.target.value }) + } + /> +
+ +
+ +